diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-06 18:11:36 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-07 13:59:12 -0300 |
| commit | 60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch) | |
| tree | 310fc734173cf771881f4691c71909135fadde97 /src/config.zig | |
| parent | fa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff) | |
| download | pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.tar.gz pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.zip | |
Refactor panes and filesystem; replace FUSE with 9P
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.
Diffstat (limited to 'src/config.zig')
| -rw-r--r-- | src/config.zig | 1435 |
1 files changed, 758 insertions, 677 deletions
diff --git a/src/config.zig b/src/config.zig index 331cb4ab..5dcfef01 100644 --- a/src/config.zig +++ b/src/config.zig @@ -1,39 +1,14 @@ -//! Every syntactic choice pardes makes, in one file: which key runs what, -//! which mouse button means what, how a looked-at word is SPELLED, and the -//! handful of layout numbers that are taste rather than structure. -//! -//! The point is the editing session, not the architecture: retargeting a key, -//! a chord or a piece of Look syntax is an edit HERE and nowhere else. Nothing -//! below is read at runtime from a file — this IS the config format, recompiled -//! — so a binding may be any comptime expression and a wrong one is a compile -//! error rather than a silent no-op. -//! -//! Order is deliberate. PART 1 is pardes's OWN vocabulary, the part a user -//! actually fiddles with, so it is where a reader lands. PART 2 is Look/Exec -//! syntax. PART 3 is the helix keymap, which is under a differential-testing -//! contract — see the banner there before touching it. -//! -//! These COMPILED bindings are distinct from the small startup command file -//! described in docs/config.md. That file can run builtins such as `Theme` -//! and `Font`; it does not replace or mutate this keymap at runtime. -//! -//! What is NOT a binding: a named key's own identity. Insert mode's Backspace, -//! Delete, Enter and Tab are dispatched on `Key.<name>` in handleInsert and -//! stay there — Backspace deleting backwards is what the key IS, not a choice -//! anyone remaps. `Ctrl-w` deleting a word backwards is a choice, and it is -//! here. const std = @import("std"); +const builtin = @import("builtin"); +const layout = @import("layout.zig"); +const limits = @import("memory.zig").limits; const pardes = @import("pardes.zig"); const builtins = @import("builtins.zig"); const Key = pardes.Key; const Mouse = pardes.Mouse; const Builtin = builtins.registry.Builtin(); -/// One key press a binding matches. `shift` is only consulted when a binding -/// ASKS for it: shift is already carried in the codepoint for anything -/// printable (`A` is `A`, not shift-`a`), so the one chord that needs the flag -/// is Shift-Esc, on a key that has no shifted codepoint. pardes.zig's `hit` -/// is the matcher. +// Printable shift is encoded in cp; shift only constrains chords that request it. pub const Chord = struct { cp: u21, ctrl: bool = false, @@ -41,62 +16,17 @@ pub const Chord = struct { shift: bool = false, }; -// A binding is a LIST because most have two spellings that must reach the same -// arm — a letter and an arrow, `Ctrl-f` and PageDown. One list, one dispatch -// site; the `or` chains this replaces had the modifier logic written out three -// ways (the old is/isC/isA). - -// ============================================================================ -// PART 1 — pardes's own bindings. Nothing here is inherited from anywhere; -// these are the ones to fiddle with. -// ============================================================================ - -// ---- the SPC leader ---- - -/// opens the leader: a key path from here runs a BUILTIN with no arguments. -/// Body normal mode only — a tag is always insert, and a tty pane's keys -/// belong to the program. +// Leader paths apply in body normal mode, not tags or raw terminals. pub const leader: []const Chord = &.{.{ .cp = ' ' }}; -/// Help's key, honored at ANY depth: it lists what the prefix typed so far can -/// still reach. Also Help's own path below, so the character is spelled once. pub const leader_help: u8 = '?'; -/// what an unlisted builtin's path is until someone says otherwise: a value no -/// key path can be, so the loop at the bottom of the table can refuse it const undecided: []const u8 = "<undecided>"; -/// SPC leader: ONE key path per builtin, the whole remapping surface. A new -/// enum field is a compile error until someone has DECIDED its path — that is -/// what `undecided` and the loop under the table are for, and it used to be -/// EnumArray.init's own doing (it demands every field). It cannot be any more: -/// the gui-only builtins are not fields of this literal's type on tty or web, -/// so the literal cannot name them and a default is the only way to have both. -/// Groups are just shared first letters (f files, h docs, c columns, t -/// toggles, s session, l language, w windows). -/// -/// `null` = this builtin's shortcut is not a leader path. Look and Exec are -/// the two: their shortcuts are Enter/Tab and the two mouse buttons below, and -/// a third spelling under SPC would be a key that does nothing you cannot -/// already do with the key your hand is on. The option is the honest type — -/// "every builtin has a leader path" was only ever true by accident. +// null means word/chord-only. Every enabled builtin must explicitly choose a path. pub const leader_path = paths: { var table = std.EnumArray(Builtin, ?[]const u8).initDefault(@as(?[]const u8, undecided), .{ .Help = &[_]u8{leader_help}, - // The whole LANGUAGE group lives under `l`, and pardes's own builtins keep - // the letters they always had — `SPC d` is Del, `SPC k` is Kill. - // - // Helix puts these on bare `<space>` letters, and an earlier pass followed - // it there, which cost `d`, `k`, `s`, `h` and the session group. That is - // the wrong trade: those five are pardes's most-pressed keys and predate - // the language work, whereas an LSP command is something you reach for - // deliberately and can afford one more keystroke. Each one still keeps - // HELIX'S OWN LETTER inside the group, so the mapping is `<space>X` -> - // `SPC l X` with nothing to re-learn but the prefix. - // - // The five GOTOS are untouched and remain exactly helix's — `gd` `gD` `gy` - // `gi` `gr`, plus `]d`/`[d` and `=`. Those never collided with anything, so - // there was never a reason to move them. They are in PART 3. .Hover = "lk", .Rename = "lr", .CodeAction = "la", @@ -107,110 +37,61 @@ pub const leader_path = paths: { .WsDiagnostics = "lD", .Lspinfo = "li", .Lspwhy = "lw", - // The hierarchy group, protocol servers only. `c`/`t` were free under - // `l`; helix has no spelling for these at all (they postdate its - // keymap), so the letters are pardes's own: who Calls me / whom I - // Call, and the Type lattice up / down. .Callers = "lc", .Callees = "lC", .Supertypes = "lt", .Subtypes = "lT", .Del = "d", - // A terminal-pane tag owns this presentation switch. It deliberately - // has no global leader path: executing the word beside that terminal - // makes the pane-local scope visible at the point of use. .Filter = null, - .Kill = "k", - // THE CLIPBOARD MENU, on helix's own five letters and nowhere else. - // These are the only paths in the table that keep their helix spelling - // unprefixed, and they can: `y` `Y` `p` `P` `R` were free at the top - // level, and moving them into a group would have made the one thing - // here that IS helix's leader stop looking like it. - // - // Bare `y`/`p`/`P`/`R` remain the DEFAULT register — that split is the - // whole design (see builtins.zig's clipboard section), and it is why - // an ordinary delete no longer reaches past the editor. + .Kill = null, .ClipYank = "y", .ClipYankMain = "Y", .ClipPaste = "p", .ClipPasteBefore = "P", .ClipReplace = "R", - // the `f` file group (spacemacs): Save left vim's `w` to join Find and - // New here, which frees `w` for the window group (SPC w h/j/k/l). .Save = "fs", .New = "fn", .Newtty = "nt", .Find = "ff", .Grep = "fg", - // the config FILE joins the file group: `SPC f c` says where pardes - // read (or would read) its startup commands from. .Config = "fc", .Tutor = "ht", .Changelog = "hc", - // `Messages` joins the help group because it answers the same kind of - // question they do — "what did that say?" — about lines that have - // already left the screen. .Messages = "hm", .Newcol = "cn", .Delcol = "cd", .Joincol = "cj", .Debug = "td", - // `Msg` takes the text to post, so it has no path, for the reason - // `Theme` and the two acme verbs below have none: a key path names a - // builtin and can never carry an argument. Bare `Msg` still runs — it - // reports itself through the error path. .Msg = null, .Colors = "tc", .Wrap = "tw", .Tagbottom = "tb", .NextColor = "tn", - // the theme picker joins the toggles it belongs with; `Theme` itself takes - // a NAME, and a key path can never carry one, so it has none (the same - // reason Look and Exec have none) .ThemeSel = "tt", .Theme = null, - // ...and `Shell` takes the name of a binary, so it has none either .Shell = null, - // the image toggles join the same `t` group; Palette takes `l` because - // `p` is Petscii's and `c` is Colors'. .Petscii = "tp", .Palette = "tl", .Ascii = "ta", .Dump = "sd", .Restore = "sr", - // the `w` window group `Save` vacated: the four directional focus moves - // the Ctrl-w prefix does, spelled h/j/k/l because focus IS a motion, plus - // `t` for the file<->terminal hop. .Left = "wh", .Down = "wj", .Up = "wk", .Right = "wl", - // the `j` JUMP group, its own letter rather than more of `w`: the window - // group moves focus by GEOMETRY (the pane left of this one), these move it - // by TIME (the pane I was in before). `o` and `i` are the letters of the - // chords that do the same thing, `jj` is the group's obvious verb, and - // `jl` is the list itself. .Back = "jo", .Forward = "ji", .Last = "jj", .Jumplist = "jl", - // the two acme verbs: keys and buttons, no leader path — see above .Look = null, + .Mini = null, .Exec = null, }); - // The GUI's font setting and picker, in the same `t` group as the theme - // picker they mirror. Their explicit availability metadata means tty and - // web Builtin enums have no fields for them. `Font` takes a NAME and - // `TaglineSize` takes a percentage, so neither has a path: a leader chord - // cannot carry either argument. if (builtins.capabilities.font_picker) { table.set(.FontSel, "tf"); table.set(.Font, null); table.set(.TaglineSize, null); } - // Panel transitions are implemented by the fixed cell grid in TTY and by - // shader-capable native GUI shells. The DOM web shell does not advertise - // them until it has an equivalent renderer. if (builtins.capabilities.panel_transitions) { table.set(.PanelSlide, "as"); table.set(.PanelZoom, "az"); @@ -229,105 +110,73 @@ pub const leader_path = paths: { table.set(.Ripple, "tR"); table.set(.Glitch, "tg"); } - // Takes the name of an effect builtin; a leader path cannot carry it. - // The web build has no runnable effect argument, so it has no command, - // help row, dispatcher case, or leader entry for EffectCode either. if (builtins.EffectCode.enabled) table.set(.EffectCode, null); - // Native-only filesystem theme commands. ThemeFile needs an operand and - // DumpThemes is intentionally occasional, so both stay word-executed - // rather than spending leader chords. if (pardes.hosted) { table.set(.ThemeFile, null); table.set(.DumpThemes, null); + table.set(.Mount, null); + table.set(.Unmount, null); } - // ...and the one native word that DOES earn a chord. Gated on `can_attach` - // and NOT on `hosted`, because this table may only name a builtin that - // exists: macOS is hosted but never polls `takeAttach`, so the two words - // below are compiled out there and naming them would be a compile error — - // which is the good outcome, and the reason the predicate exists. if (pardes.can_attach) { - // In the `s` session group beside Dump and Restore. Bare Attach means - // "whichever detached session is there", which is the whole case worth - // a key; the named form is typed, like every other builtin that takes - // an operand. Safe to press by accident, uniquely among the three: it - // connects before it swaps, so nothing to attach to costs you a - // message row. table.set(.Attach, "sa"); - // ...and the way back out, which is where the group runs out of - // letters: `sd` has been Dump's since before there was anything to - // detach from, and Detach is not worth breaking that muscle memory - // for. A capital where the lowercase is taken is what this table - // already does one group over (`lS` beside `ls`, `lD` beside `ld`). table.set(.Detach, "sD"); } - // The bare-metal memory words. Peek, Poke and Hexdump all take an ADDRESS, - // so none of them can have a leader path for the reason Theme and Msg have - // none: a key path names a builtin and can never carry an operand. if (builtins.Peek.enabled) { table.set(.Peek, null); table.set(.Poke, null); table.set(.Hexdump, null); table.set(.Gpio, null); } - // The 9P client word. Takes a dial AND a path, so it has no leader path - // for the reason the three above have none, twice over. Its gate is the - // presence of unix sockets, which is narrower than `hosted`. - if (builtins.@"9p".enabled) table.set(.@"9p", null); - // The pane-local PDF commands exist only in MuPDF builds through their - // explicit registry availability, so name their paths inside the same - // comptime branch. PdfTint/PdfFit retain their display slots and - // PdfSections takes the mnemonic `s` between them. if (pardes.pdf_enabled) { table.set(.PdfTint, "ti"); table.set(.PdfSections, "ts"); table.set(.PdfFit, "tz"); } - // ...and the property EnumArray.init used to give for free: every builtin - // this build HAS is a builtin someone decided a path (or a null) for. for (std.enums.values(Builtin)) |b| if (table.get(b)) |p| { if (std.mem.eql(u8, p, undecided)) @compileError("builtin has no leader path decided: " ++ @tagName(b)); }; break :paths table; }; -// ---- the acme chords ---- +test "Space-k is unbound while Kill and other k chords remain available" { + try std.testing.expect(leader_path.get(.Kill) == null); + try std.testing.expectEqualStrings("wk", leader_path.get(.Up).?); + try std.testing.expectEqualStrings("lk", leader_path.get(.Hover).?); + try std.testing.expectEqualStrings("d", leader_path.get(.Del).?); + for (pardes.builtin_rows) |row| if (row.cmd == .Kill) { + try std.testing.expect(row.path == null); + try std.testing.expect(std.mem.indexOf(u8, row.line, "SPC") == null); + try std.testing.expect(std.mem.indexOf(u8, row.line, "Kill") != null); + }; + const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("first\nsecond\n"); + pane.cur_row = 1; + while (p.nextEffect()) |_| {} + p.update(.{ .key = .{ .cp = ' ' } }); + try std.testing.expect(p.leader_on); + p.update(.{ .key = .{ .cp = 'k' } }); + try std.testing.expect(!p.leader_on and !p.quit); + try std.testing.expectEqual(@as(i32, 1), pane.cur_row); + while (p.nextEffect()) |effect| try std.testing.expect(effect != .quit); + p.update(.{ .key = .{ .cp = 'k' } }); + try std.testing.expectEqual(@as(i32, 0), pane.cur_row); + try std.testing.expect(p.executeBuiltinLine(p.active, "Kill")); + try std.testing.expect(p.quit); +} -// Look and Execute, the two verbs the whole environment is built on. Each has -// a KEY and a mouse BUTTON, and they are the same two verbs: Enter on a word -// does what a right click on it does. Swap the two `_key` lines for the vim -// reading, where Enter runs the command line. (This pair replaced a -// `swap_enter_tab` bool, which could only ever exchange them — two bindings -// can also be moved somewhere else entirely.) pub const look_key: []const Chord = &.{.{ .cp = Key.enter }}; pub const exec_key: []const Chord = &.{.{ .cp = Key.tab }}; pub const look_button: Mouse.Button = .right; pub const exec_button: Mouse.Button = .middle; -/// sweep, focus, place the cursor, and the left half of every acme chord pub const select_button: Mouse.Button = .left; -/// ...and WHAT those four run. Look and Exec are ORDINARY builtins — the same -/// kind of thing Save and Grep are, executable by name wherever text lives -/// (`Look main.zig` in a tag does what a right click on `main.zig` does) — so -/// the four bindings above are bindings like any other, and these two lines -/// are the whole of what makes them special. Point `look_cmd` at `.Grep` and -/// Enter greps. pub const look_cmd: Builtin = .Look; pub const exec_cmd: Builtin = .Exec; -// ---- windows ---- - -/// helix's window prefix. It stays despite `SPC w` covering the same four -/// builtins because it reaches one place the leader cannot: a pane in raw tty -/// mode never sees SPC (the shell owns every printable key), so this is the -/// only keyboard way out of one. +// Ctrl-w remains available when a terminal owns printable keys. pub const window_prefix: []const Chord = &.{.{ .cp = 'w', .ctrl = true }}; -/// The four directional focus moves, as data: `Ctrl-w <letter|arrow>` in a -/// body, and the bare LETTER on a focused tagline, where focus IS the motion -/// (the arrows stay grapheme motion inside the tag, which is why the two -/// spellings are separate fields rather than one list). One table, two -/// readers — and the `cmd` column is what makes the chord discoverable from -/// the builtin as well as the other way round. pub const window_keys = [_]struct { letter: Chord, arrow: Chord, cmd: Builtin }{ .{ .letter = .{ .cp = 'h' }, .arrow = .{ .cp = Key.left }, .cmd = .Left }, .{ .letter = .{ .cp = 'j' }, .arrow = .{ .cp = Key.down }, .cmd = .Down }, @@ -335,346 +184,92 @@ pub const window_keys = [_]struct { letter: Chord, arrow: Chord, cmd: Builtin }{ .{ .letter = .{ .cp = 'l' }, .arrow = .{ .cp = Key.right }, .cmd = .Right }, }; -/// global window ops, live in ANY mode (which is why they are Alt-, not a -/// leader path): a new terminal below, and moving this terminal into a fresh -/// column. Alt-c is a deliberate divergence from helix's change-noyank — -/// hxdiff waives it by name. pub const new_shell_below: []const Chord = &.{.{ .cp = 'n', .alt = true }}; pub const pane_to_new_column: []const Chord = &.{.{ .cp = 'c', .alt = true }}; -// ---- jumps ---- - -/// vim's Ctrl-o / Ctrl-i, walking the focus history back and forward. Global -/// in any mode, like the two Alt- ops above and for the same reason: getting -/// BACK has to work from inside a pane that owns its keys. -/// -/// CAREFUL, and this is why the table has a comment: **Ctrl-i is Tab**. On the -/// wire they are the same byte (0x09), so on a host that speaks only the -/// legacy encoding this binding never fires and 0x09 keeps meaning `exec_key` -/// below — which is the right way round, since Tab-executes is the older and -/// more used of the two. Where the host speaks the kitty keyboard protocol -/// (`CSI 105;5u`) the two are distinct keys and both work. Shift-Esc -/// (tty_toggle_alt) is already spelled on that same bet. -/// -/// Shaped like window_keys: the `cmd` column is what makes the chord -/// discoverable from the builtin as well as the other way round, and it is -/// what keeps ONE implementation — pressing the chord and executing the word -/// `Back` are the same call. +// Legacy Ctrl-i is Tab; distinguishing them requires the kitty keyboard protocol. pub const jump_keys = [_]struct { chord: Chord, cmd: Builtin }{ .{ .chord = .{ .cp = 'o', .ctrl = true }, .cmd = .Back }, .{ .chord = .{ .cp = 'i', .ctrl = true }, .cmd = .Forward }, }; -/// `j` off the topbar drops back onto a tagline — the mirror of the `k` that -/// got you there (window_keys' letter, answered by tagNormalKey). pub const topbar_down: []const Chord = &.{.{ .cp = 'j' }}; -/// the topbar has no neighbouring window to walk to, so up here h/l are plain -/// grapheme motion instead pub const topbar_left: []const Chord = &.{.{ .cp = 'h' }}; pub const topbar_right: []const Chord = &.{.{ .cp = 'l' }}; -/// LEAVE the pane you are in — the `Last` builtin — from a pane whose own -/// plain Escape already means something else. A terminal in raw tty mode has -/// had it since it existed (tty_toggle_alt below, same chord, same job); a PDF -/// needs it because Escape there cancels the selection and the search overlay -/// without moving focus out of the document you are reading. -/// -/// On a host that reports no modifier on Escape it arrives as a plain Escape -/// and still means what Escape always means in that pane. pub const leave_pane: []const Chord = &.{.{ .cp = Key.escape, .shift = true }}; -// ---- raw tty mode ---- - -/// Ctrl-<this> toggles raw tty mode in and out (terminals only); tty is -/// deliberately off the normal editing path. Not a Chord because the shell can -/// override it at runtime (`--tty-toggle`), so this is only Options' default. pub const tty_toggle_default: u21 = 'b'; -/// the second spelling, for hosts that report modifiers on Escape (the kitty -/// keyboard protocol). Where they don't it arrives as a plain Escape and still -/// means what Escape always means. pub const tty_toggle_alt: []const Chord = &.{.{ .cp = Key.escape, .shift = true }}; -/// Paste INTO the program a tty pane is running. `SPC p` and the acme 1-3 -/// chord cannot be reached there — the pty owns every keystroke and every -/// button — so raw tty mode needs its own pair, and these are the two a -/// terminal user already has in their hands. -/// -/// The split is the one the whole clipboard design rests on: Ctrl-V types the -/// DEFAULT register (what `y` put there, no round trip, no desktop involved), -/// Ctrl-Shift-V asks for the SYSTEM clipboard. Two spellings for the second -/// because a host may or may not fold the shift into the codepoint, and it -/// must be tested BEFORE the first: `hit` ignores an unasked shift, so plain -/// Ctrl-V matches a shifted key too. -/// -/// What this TAKES: forwardKey encoded both as the same byte, 0x16, so -/// Ctrl-Shift-V was a duplicate ^V and costs nothing to claim. Ctrl-V was -/// readline's quoted-insert, and that one is now unreachable in a tty pane — -/// the trade a terminal user expects, and one line to give back. +// Test the shifted/system-clipboard chord first; unrequested shift is ignored. pub const tty_paste: []const Chord = &.{.{ .cp = 'v', .ctrl = true }}; pub const tty_paste_clipboard: []const Chord = &.{ .{ .cp = 'v', .ctrl = true, .shift = true }, .{ .cp = 'V', .ctrl = true }, }; -/// What LEAVING raw tty mode hides on the shell's prompt rows. -/// -/// A prompt is CHROME. `user@host ~/src $` is redrawn on every keystroke, says -/// nothing a second time, and is never what you want to select, look at or -/// edit — so blanking it is most of what turns a scrollback into a readable -/// document, and pardes has always done it (OSC 133 is how it knows). -/// -/// What it USED to take with it was the command you had typed at that prompt, -/// because the two share a grid row and the row was the unit. That command is -/// content: the one thing on the row worth keeping, and the thing you reach -/// for `b` to get at in the first place. OSC 133 marks the two separately — -/// per CELL, not just per row — so `.prompt` blanks the prompt's own cells and -/// leaves the input sitting in the COLUMNS it really occupies. Those columns -/// are not cosmetic: clicking the command in normal mode and pressing the -/// toggle carries the click into the shell's own cursor (promptClickMove), -/// which counts them. -/// -/// `.prompt_and_input` is the older behaviour, kept for anyone who wants a -/// terminal to read as output and nothing else. +// OSC 133 prompt cells are hidden in normal mode; input columns stay intact. pub const tty_blank: enum { prompt, prompt_and_input } = .prompt; -/// The WCAG contrast ratio a filtered terminal foreground has to keep against -/// the default background before `Filter` will paint it in the theme colour -/// the projection chose. 1.0 is "the same colour"; 21.0 is black on white. -/// -/// `Filter` maps the default foreground and background roles FIRST — they are -/// the anchors Ghostty generates the 256-colour projection from — and every -/// other colour after them, by reducing it to its nearest canonical xterm key -/// and reading that key out of the projection. That reduction is a distance -/// between two RGB triples: it knows about hue and nothing about the page. The -/// cube's own corners ARE the two anchors, so the nearest key to a truecolour -/// extreme is the background itself — `\x1b[38;2;255;255;255m` on acme's -/// #ffffea paper resolved to #ffffea, ratio 1.000, text painted the colour of -/// the page under it. Every curated theme owns such a key: 231 on the light -/// one, 0 (ANSI black, which a shell writes with `\x1b[30m`) on both dark ones. -/// -/// A foreground that misses this floor is not mapped. It takes whichever of -/// the theme's own two anchors contrasts BETTER with the background actually -/// behind it, which is the choice the vendored renderer's `contrasted_color` -/// makes between white and black for the same reason. -/// -/// 1.5 is deliberately low: the point is legibility, not WCAG body text, and a -/// theme's comment and dim colours are MEANT to sit close to the page. Measured -/// across the curated three it rejects 12, 16 and 7 of 256 keys, where 3.0 -/// would reject 34, 92 and 41 and flatten a third of the dark palette. It also -/// has to stay below the contrast a theme's own pair achieves — 4.71 on `dark` -/// — or the fallback would fail the very test it answers. 1.0 accepts every -/// projected colour, collapses included. +// Projected colors below this contrast use the theme's more legible anchor. pub const tty_filter_min_contrast: f64 = 1.5; -// ---- the tag line and the topbar ---- - -// The topbar is a HAND-PICKED subset in a fixed order, not a derivation: row 0 -// is where topbar clicks land, so its exact bytes are load-bearing (every -// snapshot golden records the column each word starts at). Comptime-checked -// against the enum in pardes.zig so a rename cannot silently rot it. -// Colors and the scene/panel effects are NOT here: they are display switches -// you flip and forget, and a bar read every frame should not spend width on -// them now that their leader paths are discoverable through Help. NextColor -// stays — it is the one you cycle repeatedly, so a click beats a three-key -// path. -// -// Ordered by day-to-day usefulness, in stable functional groups: creation -// (New/Newcol), search/navigation (Find/Grep), learning (Help/Tutor), session -// persistence (Dump), appearance/diagnostics (NextColor/Debug), then the one -// destructive global action (Kill) exactly last. Find and Grep stay adjacent: -// the former matches file NAMES, the latter their CONTENTS. -// -// Help has to be here even though it is secondary. A bare `pardes` boots -// straight into tty mode (main.zig: `args.len == 1`), where every printable -// key belongs to the shell — so SPC never reaches the leader and `SPC ?`, the -// thing that would tell you the leader exists, is exactly what you cannot -// press. Row 0 is not a pane, so a middle-click on it is dispatched before any -// pane's mode is consulted: Help works in tty mode, which earns its width. pub const topbar_str = "New Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill"; -/// The default editable tail of a pane's tag, per kind. Save LEADS wherever the -/// pane holds text of its own to write — a file, an output buffer, a terminal's -/// scrollback — because `:` parks at the tail boundary and the established -/// `:w<Tab>` spelling walks to the first word from there. Images and PDFs get -/// the plain tail: their bytes on disk already are exactly what they are, so -/// there is nothing of the pane's own left to save. Terminals alone expose -/// Filter, the pane-local theme-keyed colour projection. pub const pane_builtins_str = "New Newtty Del"; pub const file_pane_builtins_str = "Save New Newtty Del"; pub const terminal_pane_builtins_str = "Save New Newtty Del Filter"; -/// Columns kept clear to the RIGHT of a tagline's builtins. The path stays at -/// the left edge and the builtins are pushed over to end this far short of the -/// pane's, which leaves somewhere to type: a word executed from the tagline is -/// how you run anything here, and with the builtins hard against the edge -/// there was nowhere to put one without first making room. -/// -/// The gap that does the pushing is made of ordinary spaces inside the tag, so -/// both it and this run are editable text — see Pardes.tagGap. Widen it and -/// every untouched tagline reflows on the next frame; taglines you have -/// already edited keep the spacing you left them with. pub const tag_right_pad: u16 = 20; -/// the pane's mode, as ONE character in the layout box at its top-left — live -/// chrome, not text you own. It used to be a three-letter word leading every -/// tagline; the box was already there carrying no information at all, so the -/// mode moved into it and the taglines got their four columns back. -/// -/// These are NOT the initials. A badge you read at a glance every time your -/// eye crosses a pane should LOOK like what it means, and each of these is a -/// mark that already means its mode somewhere else: `^` is the proofreader's -/// caret, the mark that says text goes in HERE; `$` is the shell prompt, and a -/// pane wearing it has the keyboard wired straight to the program on the other -/// end. NORMAL is a SPACE, and the empty box is the point: a pane at rest has -/// nothing waiting to eat what you type, and two thirds of the screen wearing -/// a bullet would be two thirds of the screen saying nothing loudly. (The -/// caret's true form is `‸` U+2038 and the ASCII `^` is only its -/// stand-in — but `^` is in every font ever made and `‸` is in about four, and -/// a mode badge that renders blank on someone's terminal is worse than one -/// spelled with the near-miss.) -/// -/// ONE CODEPOINT each. The box prints a single cell, so a two-character string -/// here would be pushed into one cell as a single grapheme and come out wrong. -/// A font missing the glyph draws a blank box, which is exactly what the box -/// drew before there was anything in it. +// Each mode badge is one codepoint. pub const tag_normal = " "; pub const tag_insert = "^"; pub const tag_tty = "$"; -/// ...and `img` stays a WORD at the head of an image pane's tagline, because -/// it is not a mode: it says what the pane IS, which no amount of watching the -/// box will tell you. The box on an image pane still shows its mode. pub const tag_image = "img"; -/// on a focused tag: yank what the chord would run (the selection, else the -/// word under the cursor). The path is selectable, so this is how you copy it. pub const tag_yank: []const Chord = &.{.{ .cp = 'y' }}; -// ---- the command line and search ---- - -/// vim's command line with acme's vocabulary: focus the pane's own tag in -/// normal mode, parked at the tail's start, and the execute chord runs the -/// word under the cursor (`:w<Tab>` = Save). pub const command_line: []const Chord = &.{.{ .cp = ':' }}; -/// `/` types a pattern into the tag; n/N walk the results. Same keys on every -/// kind of pane — a terminal with no search armed falls back to n/N as a -/// motion over the lookable tokens in its output. -/// -/// Helix's letters, but NOT helix's commands (it searches by regex and pardes -/// has no regex engine), so these three sit out here rather than under the -/// contract in PART 3 — the differential suites never press them. pub const search: []const Chord = &.{.{ .cp = '/' }}; pub const search_next: []const Chord = &.{.{ .cp = 'n' }}; pub const search_prev: []const Chord = &.{.{ .cp = 'N' }}; -/// Helix `|`: in body normal mode, pipe every file selection through one -/// command typed in the pane's visible tag-tail input, and REPLACE each -/// selection with what the command wrote. pub const pipe_selection: []const Chord = &.{.{ .cp = '|' }}; -/// Helix `A-|`: the same, and throw the output away. For a command run FOR its -/// effect — `| git add -` — where replacing the text with its chatter is the -/// last thing you want. pub const pipe_selection_to: []const Chord = &.{.{ .cp = '|', .alt = true }}; -/// Helix `!`: run a command with NO stdin and insert what it wrote BEFORE each -/// selection. `date`, a license header, the output of a generator. pub const insert_output: []const Chord = &.{.{ .cp = '!' }}; -/// Helix `A-!`: the same, appended AFTER each selection. pub const append_output: []const Chord = &.{.{ .cp = '!', .alt = true }}; -/// What the pane's tag-tail input shows while each of the four is armed, so -/// the prompt says which one you are in — they take the same command line and -/// do very different things to the buffer. `submitPipe` reads the command back -/// from after the marker, so these must stay distinct and non-empty. +// Armed inputs are parsed from their distinct, nonempty tag markers. pub const pipe_marker_to = " |-"; pub const pipe_marker_insert = " !"; pub const pipe_marker_append = " !+"; -/// Enter on an armed search input runs it; `escape` (PART 3) abandons it. pub const search_submit: []const Chord = &.{.{ .cp = Key.enter }}; -// ---- mouse ---- - -/// wheel step, in rows / in columns. The horizontal step is bigger because a -/// column is narrower than a row is tall and a wheel tick should move a -/// comparable distance either way. pub const wheel_rows: i32 = 1; pub const wheel_cols: i32 = 4; -/// Enabled by default: rest the pointer over text for this many animation -/// frames before showing the exact span a right-click Look would expand. -/// Repeated motion inside the same cell does not restart the count; moving to -/// another cell does. Set to `null` to compile the preview out while retaining -/// ordinary mouse hover and resize-handle hints. pub const look_preview_delay_frames: ?u16 = 2; -/// GUI shells rasterize pane-tag text at this percentage of the body face -/// while retaining the same cell geometry. TTY ignores the visual role. +// Tag fonts keep body-cell geometry; valid sizes are 1...100 percent. pub const gui_tagline_font_percent: u8 = 82; comptime { if (gui_tagline_font_percent == 0 or gui_tagline_font_percent > 100) @compileError("config.gui_tagline_font_percent must be in 1...100"); } -/// Physical-pixel rule between the global topbar and pane taglines, in both -/// pixel GUIs (SDL and native macOS, which reach the shared rule in -/// `pardes.taglineBandOffset`). Their smaller font bands retain body-sized grid -/// rows; without an explicit join, centering both bands leaves the two unused -/// half-bands touching and makes a wide strip of the window background show -/// through. Zero disables the rule and joins the two bands directly. +// Physical pixels between topbar and pane tag bands; zero disables the rule. pub const gui_topbar_pane_border_px: u8 = 1; -/// Fixed RGB for that rule, or null to follow the active theme's scrollbar -/// track. The themed default stays quiet across light and dark themes while a -/// build that wants a deliberate accent can pin one here. +// null follows the theme's scrollbar track. pub const gui_topbar_pane_border_rgb: ?[3]u8 = null; -/// Open the SDL window with a transparent buffer, so that a theme declaring NO -/// background of its own (the curated `dark`, every vendored `*_transparent`) -/// shows the desktop through the grid instead of a colour this shell had to -/// invent. That is what the AppKit shell does over its NSVisualEffectView, and -/// on linux the compositor supplies the backdrop — a niri `background-effect -/// { blur true }` window rule, picom, whatever is running. -/// -/// OFF by default because it is not free, and the cost is structural rather -/// than ours: `SDL_ClaimWindowForGPUDevice` refuses a transparent window -/// outright ("The GPU API doesn't support transparent windows", SDL_gpu.c, -/// still upstream), because D3D12 has no transparent swapchain and the API -/// says no everywhere rather than only where it must. A transparent window -/// therefore has no swapchain to render into, and the frame reaches the screen -/// down the same readback-and-blit path a compositor that cannot back a Vulkan -/// swapchain already uses (`soft_present` in `src/gui/gui.zig`): one -/// GPU->CPU download plus one upload per PAINTED frame, measured at 1.2 ms for -/// 2240x1440 and 3.0 ms for 3840x2160 on an RTX 3050. Idle frames cost -/// nothing — this shell only paints when something changed — but an animation -/// at 60 Hz spends that every frame. -/// -/// With a theme that DOES bring a background this changes nothing visible: the -/// ground is painted at full alpha, exactly as an opaque window would. It -/// still pays the readback, because window transparency is fixed at creation -/// and a `Theme` command may reach a transparent theme later. +// Transparency requires the SDL readback/blit path, even with an opaque theme. pub const gui_transparent: bool = false; -/// Touchpad drift guard, in ticks. A two-finger swipe that is MEANT to be -/// vertical carries a little sideways drift, and the pad faithfully turns that -/// drift into wheel_left/wheel_right — so a plain scroll slides the view -/// sideways under you. Every vertical tick re-arms the guard to this many -/// ticks and every horizontal tick spends one instead of scrolling, which -/// makes horizontal EARN its way back: it has to land this many ticks in a row -/// with no vertical among them. 3, because drift arrives in ones and twos — -/// at 1 or 2 a doubled drift tick mid-swipe still gets through, and much -/// higher starts eating deliberate swipes. Set 0 to disable the heuristic. -/// -/// It costs nothing at rest: the guard is only armed by vertical scrolling, so -/// a horizontal swipe that starts from a still view moves on its FIRST tick. -/// Note this applies to a tilt wheel too, where "recent vertical" is a much -/// weaker signal of accident — a mouse would rather not have it. Living with -/// that is deliberate: the only honest fix is a per-device flag out of the -/// shell (libinput/SDL know which is which, vaxis does not), and paying for a -/// device-detection layer to spare a tilt wheel three clicks after a scroll is -/// a worse trade than the three clicks. +// A vertical wheel tick suppresses this many subsequent horizontal ticks. pub const wheel_guard_ticks: u8 = 3; -/// The whole guard, as one state machine, so it can be tested as one thing: -/// fold a wheel tick into `guard` and answer whether it scrolls. The core owns -/// the counter (Pardes.wheel_guard) because the gesture belongs to the DEVICE, -/// not to whichever pane the pointer happens to sit over. pub fn wheelTick(guard: *u8, vertical: bool) bool { if (vertical) { guard.* = wheel_guard_ticks; @@ -685,107 +280,53 @@ pub fn wheelTick(guard: *u8, vertical: bool) bool { return false; } -// written to hold for ANY tuning of wheel_guard_ticks, since tuning it by hand -// is what this file is for — a test that pinned the number 3 would just be a -// second place to edit it test "wheel drift guard" { var g: u8 = 0; - // from rest, horizontal moves on the first tick — nothing to prove try std.testing.expect(wheelTick(&g, false)); try std.testing.expect(wheelTick(&g, false)); if (wheel_guard_ticks == 0) return; // guard disabled: nothing left to check - // a vertical swipe with drift mixed in: every sideways tick is swallowed, - // because each vertical tick re-arms the guard in full for (0..4) |_| try std.testing.expect(wheelTick(&g, true)); try std.testing.expect(!wheelTick(&g, false)); try std.testing.expect(wheelTick(&g, true)); try std.testing.expect(!wheelTick(&g, false)); - // the deliberate horizontal swipe that follows pays off the rest of the - // guard tick by tick, then runs free for (1..wheel_guard_ticks) |_| try std.testing.expect(!wheelTick(&g, false)); try std.testing.expect(wheelTick(&g, false)); try std.testing.expect(wheelTick(&g, false)); } -// ---- layout numbers that are taste ---- - -/// What a terminal pane runs until someone says otherwise (the Shell builtin, -/// or a `Shell <name>` line in the config file). A bare NAME, resolved against -/// the usual bin directories at spawn time — so a machine without it falls -/// back rather than opening a pane that dies at exec. pub const default_shell = "fish"; -/// the file pane's line-number gutter, in columns +// Minimum file gutter width, including the space after the line number. pub const PREFIX_W: u16 = 5; -/// Display width of a literal tab in every Surface-backed frontend. This is a -/// compile-time setting: edit it and rebuild; zero cannot advance the renderer. pub const tab_width: u16 = 4; comptime { if (tab_width == 0) @compileError("config.tab_width must be greater than zero"); } -/// soft wrap (the Wrap builtin): the glyph a wrapped row ends with, in the one -/// column bodyText keeps free for it. A break is the one thing about a wrapped -/// line you cannot see — the text simply continues, and a missing line number -/// on the row below is an absence, which is a poor thing to read a document by. -/// So the break says so at the point it happens, in the chrome's own colour -/// because it is not in the file. pub const wrap_marker = "↩"; -/// vim 'scrolloff': keyboard cursor moves keep this many context rows visible -/// above/below the cursor (clamped at file boundaries and short panes), and -/// the same count of COLUMNS horizontally +// Cursor motion preserves context in both rows and columns when space permits. pub const scroll_off = 3; -/// the pane's left chrome: scrollbar + the layout box in the tag row. The -/// scrollbar PAINTS only the first of these columns; the second is the pane's -/// own background, so the bar reads as one column with a column of page -/// between it and the text. Layout is untouched by that — the gutter is still -/// GUTTER columns and a click anywhere in them still scrolls; only the ink -/// narrowed. A half-block glyph in the second column was tried and rejected. pub const GUTTER: u16 = 2; -/// a pane never shrinks past this (the h-handle can still take it to its tag -/// row alone, which is BOX_H and a structural fact, not this) pub const MINW: u16 = 10; pub const MINH: u16 = 3; -// ============================================================================ -// PART 2 — Look/Exec syntax: how a click on text is SPELLED. What the -// resolution then DOES with it is look.zig. -// ============================================================================ - -/// file-ish word chars (acme's isfilec): alnum + these. This set is the whole -/// definition of "the word under the cursor" for Look, Execute, the tag chord -/// and every search result row. +// Accept every UTF-8 byte so word boundaries never split a codepoint. pub fn isFileChar(c: u8) bool { - // Non-ASCII bytes belong to their UTF-8 word as a unit. Bounds are byte - // offsets, so accepting every high byte keeps Unicode paths/identifiers - // intact instead of returning a slice through one codepoint. return c >= 0x80 or std.ascii.isAlphanumeric(c) or switch (c) { '.', '-', '+', '/', ':', '@', '_', '~' => true, else => false, }; } -/// `` @`ls -la` `` — a word that names a COMMAND to run rather than a file to -/// open. Both halves are named here because they are a CHOICE: the `@` marks -/// it as ours (it is already a file char, so it can never split a path) and -/// the backquotes hold a command line with spaces in it, which is the whole -/// point — a file-ish word cannot. Respell them here and nowhere else. pub const cmd_open = "@`"; pub const cmd_close: u8 = '`'; -/// The command inside `` @`...` ``, or null when `w` is not one. pub fn commandWord(w: []const u8) ?[]const u8 { if (w.len <= cmd_open.len or !std.mem.startsWith(u8, w, cmd_open)) return null; if (w[w.len - 1] != cmd_close) return null; return w[cmd_open.len .. w.len - 1]; } -/// The bounds of the word at `col` in `line` — THE expansion a no-drag -/// look/execute click and the tag chord both use. -/// -/// A `` @`...` `` run is taken WHOLE and wins outright: a backtick is not a -/// file char, so the plain scan below would stop dead inside one and hand a -/// look the fragment `ls` out of `` @`ls -la` ``. Acme does exactly this for -/// its own `<`/`|`/`>` command words. Otherwise it is the file-ish word. +// A complete command word wins over the ordinary file-character scan. pub fn wordBounds(line: []const u8, col: usize) struct { lo: usize, hi: usize } { var i: usize = 0; while (std.mem.indexOfPos(u8, line, i, cmd_open)) |o| { @@ -800,58 +341,19 @@ pub fn wordBounds(line: []const u8, col: usize) struct { lo: usize, hi: usize } return .{ .lo = lo, .hi = hi }; } -/// separates a path from its LINE and COL: `main.zig:100:7`. Must be a member -/// of isFileChar or the suffix would not be part of the word in the first -/// place. pub const line_col_sep: u8 = ':'; -/// ...and separates that spot from the END of a RANGE. A look at a ranged path -/// SELECTS the span rather than just parking on its first cell, which is what -/// lets a search result carry the text it matched and `n` land ON it. -/// -/// Three spellings. The long one subsumes the other two, but the short ones -/// are what a person actually types and what a grep-alike emits, so all three -/// parse: -/// main.zig:412-418 lines 412 through 418, whole -/// main.zig:412:9-21 line 412, columns 9 through 21 -/// main.zig:412:9-418:1 line 412 column 9 through line 418 column 1 -/// Both ends are INCLUSIVE and 1-based, like the spot they extend — `412-418` -/// reads as seven lines, not six. `main.zig:412` and `main.zig:412:9` keep -/// meaning exactly what they always did. -/// -/// Must be an isFileChar member, same as the separator above, or a click would -/// expand to half a range. That is also why reading it is FUSSY (look.zig, -/// parsePathLine): ordinary paths are full of dashes, so the suffix counts as -/// a range only when a NUMBER follows the dash — `my-file:10` and `build-2` -/// stay the paths they are. +// Ranges are inclusive and 1-based: path:2-4, path:2:3-7, path:2:3-4:1. pub const range_sep: u8 = '-'; -/// `@p7:10:5` — pane 7, line 10, column 5. The one look target that names a -/// live pane instead of a path, because terminals and output buffers have no -/// file for a location to point at. Both the writer (a `/` result row) and the -/// reader (look.resolve) spell it from here. +// @p7:10:5 addresses pane 7, line 10, column 5. pub const pane_addr = "@p"; -/// a word starting with one of these is a URL and leaves the app entirely: no -/// filesystem can answer it pub const url_schemes = [_][]const u8{ "http://", "https://" }; -/// a path ending in one of these opens an image pane instead of a file pane pub const image_exts = [_][]const u8{ ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ppm", ".pgm", ".tga" }; -/// What `Ctrl-c` (comment_toggle) puts at the front of a line, by file -/// EXTENSION — which is how src/syntax.zig already tells one language from -/// another, so this is that same notion and not a second one. A pane whose -/// path matches nothing here (and every terminal, which has no path at all) -/// gets `comment_token_default`. -/// -/// `#` as the default is not a guess: it is helix's own DEFAULT_COMMENT_TOKEN, -/// which is what a helix buffer with no language configured comments with — -/// and therefore what the differential oracle answers, since the harness runs -/// with zero language configs. -/// Languages with no LINE comment at all (css, html, json, ocaml) are absent -/// on purpose: helix leaves those buffers on its default too, and inventing a -/// token for them would be a divergence nothing asked for. +// Match Helix's fallback for unknown languages and languages without line comments. pub const comment_token_default = "#"; pub const comment_tokens: []const struct { exts: []const []const u8, token: []const u8 } = &.{ .{ .token = "//", .exts = &.{ ".zig", ".zon", ".c", ".h", ".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx", ".rs", ".go", ".java", ".scala", ".sc", ".kt", ".kts", ".cs", ".csx", ".php", ".pas", ".pp", ".p", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".typ", ".typst", ".swift", ".dart" } }, @@ -863,37 +365,16 @@ pub const comment_tokens: []const struct { exts: []const []const u8, token: []co .{ .token = "\"", .exts = &.{ ".vim", ".vimrc" } }, }; -/// What an armed search writes into the tag tail — and the ONLY record of -/// which search it is: Enter reads the marker back (submitSearch) instead of -/// pardes carrying a second piece of pane state per command. The pattern is -/// everything past the `/`, so a pattern may itself contain slashes; the word -/// before it is the builtin's own name, so the armed tag reads as the command -/// it will run. The bare `/` has no name — it searches the pane itself. pub const search_marker = " /"; pub const find_marker = " Find /"; pub const grep_marker = " Grep /"; pub const rename_marker = " Rename /"; pub const symbol_marker = " WsSymbols /"; -/// Save on a pane with no file of its own yet — an output buffer or a terminal: -/// the tail is the whole PATH to write (no `/` separator, since a path is made -/// of them), prefilled with the pane's directory so only a filename need be -/// typed. pub const save_marker = " Save "; -/// helix `s` / `S`. The only two markers whose word is NOT a builtin — there -/// is no Select/Split command to run from a tag, they name the key that armed -/// the input so the tag still reads as what it is about to do. They also mark -/// the one input that previews as you type (pardes.zig, previewSelRegex). pub const select_marker = " Select /"; pub const split_marker = " Split /"; -/// The `|` prompt is not an executable tag word: the marker only makes the -/// pending shell filter visible, and everything after it is preserved as the -/// exact command passed to `/bin/sh -c`. pub const pipe_marker = " |"; -/// Output-buffer names (acme's +Errors). Cosmetic now, and deliberately so: a -/// buffer is DERIVED from the command that opened it (output_pane.traits), and -/// nothing identifies one by matching this text any more — renaming any of -/// these changes only what you read in a tag. pub const search_buffer = "+Search"; pub const help_buffer = "+Help"; pub const config_buffer = "+Config"; @@ -906,62 +387,25 @@ pub const hover_buffer = "+Hover"; pub const lsp_buffer = "+Lsp"; pub const changelog_buffer = "+Changelog"; pub const messages_buffer = "+Messages"; -/// What `9p <dial> <path>` opens a remote file into. NOT the remote path: an -/// output buffer's name comes off the command that filled it, and the path is -/// the command's ARGUMENT, which is what makes two remote files two panes. -pub const ninep_buffer = "+9p"; -/// The two memory windows a bare-metal build's Peek and Hexdump render. Absent -/// from every hosted build along with the builtins that name them. pub const peek_buffer = "+Peek"; pub const hexdump_buffer = "+Hexdump"; pub const gpio_buffer = "+Gpio"; -/// The empty buffer New and Newcol open: no file behind it yet, so Save asks -/// for a path (prefilled with the inherited directory). pub const scratch_buffer = "+New"; -/// acme's own `+Errors`, and the one output buffer no keystroke opens: a -/// script writes it, through a pane's `errors` file or the top-level `cons` -/// (src/acmefs.zig). pub const errors_buffer = "+Errors"; -// ============================================================================ -// PART 3 — THE HELIX KEYMAP. READ THIS BEFORE RETARGETING ANYTHING BELOW. -// -// These are not free choices. `zig build hxdiff` (481 cases) and -// `zig build hxparity` (561 cases) are DIFFERENTIAL suites: they drive a real -// helix and this core with the same keystrokes and compare the results, and a -// mismatch is a failure, not a diff to accept. Moving a key here therefore -// breaks the build until the divergence is written down as a waiver with a -// reason — which is the correct workflow for a DELIBERATE divergence (Alt-c -// above is one) and an alarm for an accidental one. -// -// Everything in PART 1 is outside that contract and free to move. -// ============================================================================ - -// ---- modal prefixes ---- - -// These are STORED — pane.pending / pending2 / find_op hold the codepoint -// ITSELF until the next key completes the sequence, and the continuation reads -// it back — so they are bare codepoints rather than Chords, and pardes.zig -// matches them with `isPrefix` instead of `hit`. A prefix must therefore be an -// unmodified printable key. Untyped so they compare against both the u21 and -// the u8 fields that hold them. +// Modal bindings below are checked against Helix by hxdiff and hxparity. pub const goto_prefix = 'g'; pub const view_prefix = 'z'; pub const match_prefix = 'm'; pub const replace_prefix = 'r'; pub const next_prefix = ']'; pub const prev_prefix = '['; -// f/F/t/T: the stored byte IS the operator — `f`/`t` mean forward and `t`/`T` -// mean stop short — so the four are read back as values, not just matched. pub const find_char_fwd = 'f'; pub const find_char_back = 'F'; pub const till_char_fwd = 't'; pub const till_char_back = 'T'; -/// repeat the last f/F/t/T pub const repeat_find: []const Chord = &.{.{ .cp = '.', .alt = true }}; -// ---- motion ---- - pub const move_left: []const Chord = &.{ .{ .cp = 'h' }, .{ .cp = Key.left } }; pub const move_right: []const Chord = &.{ .{ .cp = 'l' }, .{ .cp = Key.right } }; pub const move_down: []const Chord = &.{ .{ .cp = 'j' }, .{ .cp = Key.down } }; @@ -975,31 +419,21 @@ pub const next_long_word_end: []const Chord = &.{.{ .cp = 'E' }}; pub const line_start: []const Chord = &.{ .{ .cp = '0' }, .{ .cp = Key.home } }; pub const line_end: []const Chord = &.{ .{ .cp = '$' }, .{ .cp = Key.end } }; pub const line_first_nonws: []const Chord = &.{.{ .cp = '^' }}; -/// helix goto_line: only acts WITH a count (bare G is a no-op; `ge` is -/// goto-last-line) +// Bare G does nothing; ge reaches the last line. pub const goto_line: []const Chord = &.{.{ .cp = 'G' }}; -/// grapheme motion in a ONE-LINE context (a tag, the topbar): the arrows only. -/// A tagline spends h/l on the layout (window_keys) and the topbar answers -/// them itself (topbar_left/right), so the letters are not in this vocabulary. pub const line_move_left: []const Chord = &.{.{ .cp = Key.left }}; pub const line_move_right: []const Chord = &.{.{ .cp = Key.right }}; -// ---- paging and the view ---- - pub const half_page_down: []const Chord = &.{.{ .cp = 'd', .ctrl = true }}; pub const half_page_up: []const Chord = &.{.{ .cp = 'u', .ctrl = true }}; pub const page_down: []const Chord = &.{ .{ .cp = 'f', .ctrl = true }, .{ .cp = Key.page_down } }; pub const page_up: []const Chord = &.{ .{ .cp = 'b', .ctrl = true }, .{ .cp = Key.page_up } }; -/// under `z`: put the cursor's line at the top / centre / bottom of the view pub const view_top: []const Chord = &.{.{ .cp = 't' }}; pub const view_center: []const Chord = &.{ .{ .cp = 'z' }, .{ .cp = 'c' } }; pub const view_bottom: []const Chord = &.{.{ .cp = 'b' }}; -/// under `z`: scroll the view one line, cursor snapped to the scrolloff edge pub const view_scroll_down: []const Chord = &.{ .{ .cp = 'j' }, .{ .cp = Key.down } }; pub const view_scroll_up: []const Chord = &.{ .{ .cp = 'k' }, .{ .cp = Key.up } }; -// ---- under `g` (goto) ---- - pub const goto_file_start: []const Chord = &.{.{ .cp = 'g' }}; pub const goto_last_line: []const Chord = &.{.{ .cp = 'e' }}; pub const goto_line_start: []const Chord = &.{.{ .cp = 'h' }}; @@ -1008,43 +442,27 @@ pub const goto_first_nonws: []const Chord = &.{.{ .cp = 's' }}; pub const goto_line_down: []const Chord = &.{.{ .cp = 'j' }}; pub const goto_line_up: []const Chord = &.{.{ .cp = 'k' }}; pub const goto_column: []const Chord = &.{.{ .cp = '|' }}; -/// view-relative rows (helix goto_window) pub const goto_view_top: []const Chord = &.{.{ .cp = 't' }}; pub const goto_view_center: []const Chord = &.{.{ .cp = 'c' }}; pub const goto_view_bottom: []const Chord = &.{.{ .cp = 'b' }}; -// helix's five LSP gotos, all under `g` and nowhere else. They are motions, -// not builtins — a motion has no business being a word you can middle-click, -// which is why they are not in the language group under `SPC l`. pub const goto_definition: []const Chord = &.{.{ .cp = 'd' }}; pub const goto_declaration: []const Chord = &.{.{ .cp = 'D' }}; pub const goto_type_definition: []const Chord = &.{.{ .cp = 'y' }}; pub const goto_implementation: []const Chord = &.{.{ .cp = 'i' }}; pub const goto_references: []const Chord = &.{.{ .cp = 'r' }}; -// ---- under `m` (match) ---- - -/// `mm` acts at once, so it is an ordinary chord pub const match_bracket: []const Chord = &.{.{ .cp = 'm' }}; -// The other five are SUB-prefixes: each waits for a textobject or surround -// character (`mi(`, `mr[{`), so pardes stores them the way it stores `m` and -// they are bare codepoints for the same reason as the block above. pub const match_inside = 'i'; pub const match_around = 'a'; pub const surround_add = 's'; pub const surround_replace = 'r'; pub const surround_delete = 'd'; -// ---- under `]` / `[` ---- - pub const goto_paragraph: []const Chord = &.{.{ .cp = 'p' }}; pub const add_newline: []const Chord = &.{.{ .cp = ' ' }}; -/// step the diagnostics list, asking for one if it is not up yet pub const goto_diagnostic: []const Chord = &.{.{ .cp = 'd' }}; -/// ]D / [D — the last / the first pub const goto_diagnostic_end: []const Chord = &.{.{ .cp = 'D' }}; -// ---- insert entry ---- - pub const insert: []const Chord = &.{.{ .cp = 'i' }}; pub const append: []const Chord = &.{.{ .cp = 'a' }}; pub const insert_line_start: []const Chord = &.{.{ .cp = 'I' }}; @@ -1052,8 +470,6 @@ pub const insert_line_end: []const Chord = &.{.{ .cp = 'A' }}; pub const open_below: []const Chord = &.{.{ .cp = 'o' }}; pub const open_above: []const Chord = &.{.{ .cp = 'O' }}; -// ---- selection ---- - pub const select_mode: []const Chord = &.{.{ .cp = 'v' }}; pub const select_line: []const Chord = &.{.{ .cp = 'x' }}; pub const select_line_bounds: []const Chord = &.{.{ .cp = 'X' }}; @@ -1062,13 +478,6 @@ pub const collapse_selection: []const Chord = &.{.{ .cp = ';' }}; pub const flip_selection: []const Chord = &.{.{ .cp = ';', .alt = true }}; pub const select_all: []const Chord = &.{.{ .cp = '%' }}; -// ---- multiple cursors ---- -// -// helix's Selection is a LIST of ranges with a primary index, and these ten -// keys are the ones that act on the list rather than on the text: every other -// key is replayed once per range instead (pardes.zig, replaySels). Alt-C is -// Alt-SHIFT-c and so does not collide with pane_to_new_column's Alt-c — `hit` -// compares the codepoint, and `C` is `C`. pub const copy_sel_below: []const Chord = &.{.{ .cp = 'C' }}; pub const copy_sel_above: []const Chord = &.{.{ .cp = 'C', .alt = true }}; pub const keep_primary_sel: []const Chord = &.{.{ .cp = ',' }}; @@ -1080,22 +489,9 @@ pub const merge_sels: []const Chord = &.{.{ .cp = '-', .alt = true }}; pub const merge_consecutive_sels: []const Chord = &.{.{ .cp = '_', .alt = true }}; pub const trim_sels: []const Chord = &.{.{ .cp = '_' }}; -// The other two list-making keys: a REGEX turns each range into many. Both -// arm the tag input above (select_marker / split_marker) instead of doing -// anything immediately, so `s` and `S` are the only normal-mode keys whose -// effect lands a keystroke later, on Enter — or live, as you type. -// `s` is free here despite `gs` (goto_first_nonws) also being `s`: a pending -// prefix is matched by the stored codepoint, never by these chords. pub const select_regex: []const Chord = &.{.{ .cp = 's' }}; pub const split_regex: []const Chord = &.{.{ .cp = 'S' }}; -// ---- edits ---- -// -// Every one of these reads or writes the DEFAULT register and only that. -// The system clipboard is five separate words on `SPC y Y p P R`, which is -// helix's split and the reason `d` cannot silently eat what you copied out of -// a browser. See leader_path above and builtins.zig's clipboard section. - pub const delete: []const Chord = &.{.{ .cp = 'd' }}; pub const delete_noyank: []const Chord = &.{.{ .cp = 'd', .alt = true }}; pub const change: []const Chord = &.{.{ .cp = 'c' }}; @@ -1109,35 +505,720 @@ pub const to_uppercase: []const Chord = &.{.{ .cp = '`', .alt = true }}; pub const join_lines: []const Chord = &.{.{ .cp = 'J' }}; pub const indent: []const Chord = &.{.{ .cp = '>' }}; pub const unindent: []const Chord = &.{.{ .cp = '<' }}; -/// helix's format_selections — its neighbour on the keyboard and in the keymap pub const format: []const Chord = &.{.{ .cp = '=' }}; pub const increment: []const Chord = &.{.{ .cp = 'a', .ctrl = true }}; pub const decrement: []const Chord = &.{.{ .cp = 'x', .ctrl = true }}; pub const undo: []const Chord = &.{.{ .cp = 'u' }}; pub const redo: []const Chord = &.{.{ .cp = 'U' }}; -/// helix `toggle_comments`: comment or uncomment every line the selection -/// touches, with `comment_tokens` above choosing the token. Ctrl-c reaches a -/// terminal pane only in NORMAL mode — raw tty forwards it to the program, -/// where it is still SIGINT. pub const comment_toggle: []const Chord = &.{.{ .cp = 'c', .ctrl = true }}; -/// In body normal mode, clear modal residue and run Last (the same builtin as -/// `SPC j j`): the pane you were in before this one, whichever it was. Held -/// down it alternates between two panes — two files, or a file and its shell. -/// Elsewhere: leave insert mode; abandon a leader path, tag, armed search or -/// the topbar; raw tty mode forwards it to the program. +// Body-normal Esc runs Last; other modes cancel input or leave insert mode. pub const escape: []const Chord = &.{.{ .cp = Key.escape }}; -// ---- insert mode ---- - -// The three helix aliases: normalized to the base key and re-dispatched, so -// they behave identically to it everywhere downstream. pub const insert_backspace_alias: []const Chord = &.{.{ .cp = 'h', .ctrl = true }}; pub const insert_enter_alias: []const Chord = &.{.{ .cp = 'j', .ctrl = true }}; pub const insert_delete_alias: []const Chord = &.{.{ .cp = 'd', .ctrl = true }}; -/// helix insert-mode kills. Ctrl-w is also the WINDOW prefix in normal/tty — -/// insert mode wins it, which is helix's own arrangement. pub const delete_word_backward: []const Chord = &.{ .{ .cp = 'w', .ctrl = true }, .{ .cp = Key.backspace, .alt = true } }; pub const delete_word_forward: []const Chord = &.{ .{ .cp = 'd', .alt = true }, .{ .cp = Key.delete, .alt = true } }; pub const kill_to_line_start: []const Chord = &.{.{ .cp = 'u', .ctrl = true }}; pub const kill_to_line_end: []const Chord = &.{.{ .cp = 'k', .ctrl = true }}; + +pub const Runtime = struct { + theme: usize = 0, + colors: bool = true, + wrap: bool = true, + tag_bottom: bool = false, + debug: bool = false, + + // Effective values change only after a host acknowledges the request. + shell: struct { + requested: Text(255) = .{}, + effective: Text(limits.host_path_cap) = .{}, + pending: bool = true, + } = .{}, + + font: struct { + requested_path: Text(limits.host_path_cap) = .{}, + requested_name: Text(255) = .{}, + effective_name: Text(255) = .{}, + pending: bool = false, + effective_size_hundredths: u16 = 0, + effective_size_unit: FontSizeUnit = .unknown, + tagline_percent: u8 = 100, + } = .{}, + + panel_transition: layout.Transition = .off, + scene_effects: layout.SceneEffect = .{}, + + pub fn toggleTransition(state: *Runtime, effect: layout.Transition) void { + std.debug.assert(effect != .off); + state.panel_transition = if (state.panel_transition == effect) .off else effect; + } + + pub const tagline_percent_min: u8 = 1; + pub const tagline_percent_max: u8 = 100; + + pub fn Text(comptime capacity: usize) type { + return struct { + bytes: [capacity]u8 = @splat(0), + len: std.math.IntFittingRange(0, capacity) = 0, + + pub fn get(value: *const @This()) []const u8 { + return value.bytes[0..value.len]; + } + + pub fn set(value: *@This(), text: []const u8) bool { + if (text.len > capacity) return false; + @memcpy(value.bytes[0..text.len], text); + value.len = @intCast(text.len); + return true; + } + + pub fn clear(value: *@This()) void { + value.len = 0; + } + }; + } + + pub const FontSizeUnit = enum { unknown, pixels, points }; + + // Validate both strings before changing either member of the request. + pub fn requestFont(state: *Runtime, path: []const u8, name: []const u8) bool { + if (path.len > state.font.requested_path.bytes.len or + name.len > state.font.requested_name.bytes.len) return false; + std.debug.assert(state.font.requested_path.set(path)); + std.debug.assert(state.font.requested_name.set(name)); + state.font.pending = true; + return true; + } + + pub const Capabilities = struct { + font_picker: bool, + panel_transitions: bool, + scene_shaders: bool, + tagline_font_size: bool, + }; + + pub const Capability = std.meta.FieldEnum(Capabilities); + + pub const Toggle = enum { colors, wrap, tag_bottom, debug }; + pub const Scene = std.meta.FieldEnum(layout.SceneEffect); + + pub const Action = union(enum) { + toggle: Toggle, + shell, + theme, + font, + tagline_size, + transition: layout.Transition, + scene: Scene, + }; + + pub const Setting = struct { + word: []const u8, + action: Action, + availability: ?Capability = null, + + pub fn takesArg(setting: Setting) bool { + return switch (setting.action) { + .shell, .theme, .font, .tagline_size => true, + else => false, + }; + } + + pub fn enabled(setting: Setting, capabilities: Capabilities) bool { + const capability = setting.availability orelse return true; + return switch (capability) { + inline else => |field| @field(capabilities, @tagName(field)), + }; + } + }; + + // The builtin registry and Config report share this command table. + pub const settings = [_]Setting{ + .{ .word = "Colors", .action = .{ .toggle = .colors } }, + .{ .word = "Wrap", .action = .{ .toggle = .wrap } }, + .{ .word = "Tagbottom", .action = .{ .toggle = .tag_bottom } }, + .{ .word = "Debug", .action = .{ .toggle = .debug } }, + .{ .word = "Theme", .action = .theme }, + .{ .word = "Shell", .action = .shell }, + .{ .word = "Font", .action = .font, .availability = .font_picker }, + .{ .word = "TaglineSize", .action = .tagline_size, .availability = .font_picker }, + .{ .word = "PanelSlide", .action = .{ .transition = .slide }, .availability = .panel_transitions }, + .{ .word = "PanelZoom", .action = .{ .transition = .zoom }, .availability = .panel_transitions }, + .{ .word = "PanelDissolve", .action = .{ .transition = .dissolve }, .availability = .panel_transitions }, + .{ .word = "PanelAscii", .action = .{ .transition = .ascii }, .availability = .panel_transitions }, + .{ .word = "PanelVertical", .action = .{ .transition = .vertical }, .availability = .panel_transitions }, + .{ .word = "PanelEdges", .action = .{ .transition = .edges }, .availability = .panel_transitions }, + .{ .word = "PanelFall", .action = .{ .transition = .fall }, .availability = .panel_transitions }, + .{ .word = "PanelWave", .action = .{ .transition = .wave }, .availability = .panel_transitions }, + .{ .word = "PanelCurtain", .action = .{ .transition = .curtain }, .availability = .panel_transitions }, + .{ .word = "PanelScramble", .action = .{ .transition = .scramble }, .availability = .panel_transitions }, + .{ .word = "PanelType", .action = .{ .transition = .typewriter }, .availability = .panel_transitions }, + .{ .word = "Crt", .action = .{ .scene = .crt }, .availability = .scene_shaders }, + .{ .word = "Ripple", .action = .{ .scene = .ripple }, .availability = .scene_shaders }, + .{ .word = "Glitch", .action = .{ .scene = .glitch }, .availability = .scene_shaders }, + }; + + pub fn find(name: []const u8) ?Setting { + for (settings) |setting| if (std.mem.eql(u8, setting.word, name)) return setting; + return null; + } + + pub fn findAction(action: Action) ?Setting { + for (settings) |setting| if (std.meta.eql(setting.action, action)) return setting; + return null; + } + + fn actionCount(comptime action: Action) comptime_int { + var count = 0; + for (settings) |setting| count += @intFromBool(std.meta.eql(setting.action, action)); + return count; + } + + comptime { + @setEvalBranchQuota(20_000); + for (settings, 0..) |setting, i| { + if (setting.word.len == 0) @compileError("runtime setting has an empty command word"); + for (settings[i + 1 ..]) |later| if (std.mem.eql(u8, setting.word, later.word)) + @compileError("duplicate runtime setting command word: " ++ setting.word); + switch (setting.action) { + .font, .tagline_size => if (setting.availability != .font_picker) + @compileError("native font settings must use the font-picker capability"), + .transition => if (setting.availability != .panel_transitions) + @compileError("panel effects must use the panel-transition capability"), + .scene => if (setting.availability != .scene_shaders) + @compileError("scene effects must use the scene-shader capability"), + else => if (setting.availability != null) + @compileError("unconditional settings cannot carry a backend capability"), + } + } + for (std.enums.values(Toggle)) |field| if (actionCount(.{ .toggle = field }) != 1) + @compileError("runtime toggle must occur exactly once: " ++ @tagName(field)); + if (actionCount(.shell) != 1 or actionCount(.theme) != 1 or actionCount(.font) != 1 or + actionCount(.tagline_size) != 1) + @compileError("Shell, Theme, Font, and TaglineSize actions must each occur exactly once"); + for (std.enums.values(layout.Transition)) |effect| { + const expected: comptime_int = @intFromBool(effect != .off); + if (actionCount(.{ .transition = effect }) != expected) + @compileError("non-off panel transition must occur exactly once: " ++ @tagName(effect)); + } + for (std.enums.values(Scene)) |effect| { + if (actionCount(.{ .scene = effect }) != 1) + @compileError("scene effect must occur exactly once: " ++ @tagName(effect)); + if (@FieldType(layout.SceneEffect, @tagName(effect)) != bool) + @compileError("scene effect fields must be booleans: " ++ @tagName(effect)); + } + } + + pub fn apply(state: *Runtime, setting: Setting, argument: ?[]const u8) bool { + switch (setting.action) { + .toggle => |field| switch (field) { + .colors => state.colors = !state.colors, + .wrap => state.wrap = !state.wrap, + .tag_bottom => state.tag_bottom = !state.tag_bottom, + .debug => state.debug = !state.debug, + }, + .shell => { + const value = std.mem.trim(u8, argument orelse return false, " \t\r\n"); + if (value.len == 0 or !state.shell.requested.set(value)) return false; + state.shell.pending = true; + }, + .tagline_size => { + const text = std.mem.trim(u8, argument orelse return false, " \t\r\n"); + const percent = std.fmt.parseInt(u16, text, 10) catch return false; + if (percent < tagline_percent_min or percent > tagline_percent_max) return false; + state.font.tagline_percent = @intCast(percent); + }, + .transition => |effect| state.toggleTransition(effect), + .scene => |effect| switch (effect) { + inline else => |field| { + const value = &@field(state.scene_effects, @tagName(field)); + value.* = !value.*; + }, + }, + .theme, .font => return false, + } + return true; + } + + // Slices are borrowed for one writeReport call. + pub const ReportContext = struct { + startup_config_path: ?[]const u8, + platform: []const u8, + theme_name: []const u8, + compiled_default_shell: []const u8, + gui_shader_source_mode: ?[]const u8 = null, + hover_delay_frames: ?u16, + native_images: bool, + capabilities: Capabilities, + state: *const Runtime, + }; + + fn onOff(value: bool) []const u8 { + return if (value) "on" else "off"; + } + + fn shown(text: []const u8) []const u8 { + return if (text.len == 0) "(none)" else text; + } + + fn transitionSettingName(transition: layout.Transition) []const u8 { + if (transition == .off) return "off"; + return findAction(.{ .transition = transition }).?.word; + } + + pub fn writeReport(out: *std.Io.Writer, context: ReportContext) !void { + const state = context.state; + var wrote_transition = false; + for (settings) |setting| switch (setting.action) { + .toggle => |field| { + const value = switch (field) { + .colors => state.colors, + .wrap => state.wrap, + .tag_bottom => state.tag_bottom, + .debug => state.debug, + }; + try out.print("{s}: {s}\n", .{ setting.word, onOff(value) }); + }, + .theme => try out.print("{s}: {s}\n", .{ setting.word, context.theme_name }), + .shell => { + const chosen = state.shell.requested.get(); + try out.print( + "{s} requested (new panes): {s}{s}\n" ++ + "{s} effective (last spawn): {s}\n" ++ + "{s} pending: {s}\n", + .{ + setting.word, + if (chosen.len == 0) context.compiled_default_shell else chosen, + if (chosen.len == 0) " (default)" else "", + setting.word, + shown(state.shell.effective.get()), + setting.word, + onOff(state.shell.pending), + }, + ); + }, + .font => if (!setting.enabled(context.capabilities)) + try out.print("{s}: unsupported\n", .{setting.word}) + else + try out.print( + "{s} requested: {s}\n" ++ + "{s} requested path: {s}\n" ++ + "{s} effective: {s}\n" ++ + "{s} pending: {s}\n" ++ + "{s} effective size: {d}.{d:0>2} {s}\n", + .{ + setting.word, + shown(state.font.requested_name.get()), + setting.word, + shown(state.font.requested_path.get()), + setting.word, + shown(state.font.effective_name.get()), + setting.word, + onOff(state.font.pending), + setting.word, + state.font.effective_size_hundredths / 100, + state.font.effective_size_hundredths % 100, + @tagName(state.font.effective_size_unit), + }, + ), + .tagline_size => if (!context.capabilities.tagline_font_size) + try out.print("{s}: unsupported\n", .{setting.word}) + else if (!setting.enabled(context.capabilities)) + try out.print("{s}: {d}% (build-time only)\n", .{ setting.word, state.font.tagline_percent }) + else + try out.print("{s}: {d}%\n", .{ setting.word, state.font.tagline_percent }), + .transition => { + if (wrote_transition) continue; + wrote_transition = true; + if (!setting.enabled(context.capabilities)) + try out.writeAll("Panel transition: unsupported\n") + else + try out.print("Panel transition: {s}\n", .{transitionSettingName(state.panel_transition)}); + }, + .scene => |effect| { + if (!setting.enabled(context.capabilities)) { + try out.print("{s}: unsupported\n", .{setting.word}); + continue; + } + const enabled = switch (effect) { + inline else => |field| @field(state.scene_effects, @tagName(field)), + }; + try out.print("{s}: {s}\n", .{ setting.word, onOff(enabled) }); + }, + }; + + if (context.startup_config_path) |path| + try out.print("Startup config: {s}\n", .{path}) + else + try out.writeAll("Startup config: no per-user config path\n"); + try out.print( + "Platform: {s}\n" ++ + "Compiled default shell: {s}\n", + .{ context.platform, context.compiled_default_shell }, + ); + if (context.gui_shader_source_mode) |mode| + try out.print("GUI shader source: {s}\n", .{mode}); + if (context.hover_delay_frames) |frames| + try out.print("Look hover delay: {d} frames\n", .{frames}) + else + try out.writeAll("Look hover delay: off\n"); + try out.print("Native images: {s}\n", .{onOff(context.native_images)}); + } + + test "setting names are unique and argument metadata follows actions" { + for (settings, 0..) |setting, i| { + try std.testing.expect(setting.word.len > 0); + for (settings[i + 1 ..]) |later| + try std.testing.expect(!std.mem.eql(u8, setting.word, later.word)); + try std.testing.expectEqual(switch (setting.action) { + .shell, .theme, .font, .tagline_size => true, + else => false, + }, setting.takesArg()); + } + } + + test "simple setting application mutates only its plain field" { + var state: Runtime = .{}; + try std.testing.expect(apply(&state, find("Colors").?, null)); + try std.testing.expect(!state.colors); + try std.testing.expect(apply(&state, find("Shell").?, " fish\n")); + try std.testing.expectEqualStrings("fish", state.shell.requested.get()); + try std.testing.expect(state.shell.pending); + try std.testing.expect(apply(&state, find("PanelAscii").?, null)); + try std.testing.expectEqual(layout.Transition.ascii, state.panel_transition); + try std.testing.expect(apply(&state, find("PanelAscii").?, null)); + try std.testing.expectEqual(layout.Transition.off, state.panel_transition); + try std.testing.expect(apply(&state, find("Crt").?, null)); + try std.testing.expect(state.scene_effects.crt); + } + + test "tagline size validates before mutating live state" { + const setting = find("TaglineSize").?; + var state: Runtime = .{}; + + for ([_][]const u8{ "1", " 82\n", "100" }) |argument| { + try std.testing.expect(apply(&state, setting, argument)); + try std.testing.expectEqual(try std.fmt.parseInt(u8, std.mem.trim(u8, argument, " \t\r\n"), 10), state.font.tagline_percent); + } + + state.font.tagline_percent = 67; + for ([_]?[]const u8{ null, "", "0", "101", "-1", "50%", "999999999999999999999" }) |argument| { + try std.testing.expect(!apply(&state, setting, argument)); + try std.testing.expectEqual(@as(u8, 67), state.font.tagline_percent); + } + } + + test "font request tuple rejects atomically" { + var state: Runtime = .{}; + try std.testing.expect(requestFont(&state, "/fonts/old.ttf", "Old")); + var too_long: [256]u8 = @splat('x'); + try std.testing.expect(!requestFont(&state, "/fonts/new.ttf", &too_long)); + try std.testing.expectEqualStrings("/fonts/old.ttf", state.font.requested_path.get()); + try std.testing.expectEqualStrings("Old", state.font.requested_name.get()); + } + + test "Config report observes every simple setting and all live context" { + var state: Runtime = .{}; + var storage: [4096]u8 = undefined; + const context: ReportContext = .{ + .startup_config_path = "/tmp/pardes/init", + .platform = "gui", + .theme_name = "acme", + .compiled_default_shell = "/bin/sh", + .gui_shader_source_mode = "live GLSL compiled during this build", + .hover_delay_frames = 18, + .native_images = true, + .capabilities = .{ + .font_picker = true, + .panel_transitions = true, + .scene_shaders = true, + .tagline_font_size = true, + }, + .state = &state, + }; + + for (settings) |setting| { + switch (setting.action) { + .theme, .font => continue, + else => {}, + } + const argument: ?[]const u8 = switch (setting.action) { + .shell => "fish", + .tagline_size => "73", + else => null, + }; + try std.testing.expect(apply(&state, setting, argument)); + + var out: std.Io.Writer = .fixed(&storage); + try writeReport(&out, context); + const report = storage[0..out.end]; + const expected = switch (setting.action) { + .toggle => |field| switch (field) { + .colors => "Colors: off\n", + .wrap => "Wrap: off\n", + .tag_bottom => "Tagbottom: on\n", + .debug => "Debug: on\n", + }, + .shell => "Shell requested (new panes): fish\n", + .tagline_size => "TaglineSize: 73%\n", + .transition => |transition| switch (transition) { + .off => unreachable, + .slide => "Panel transition: PanelSlide\n", + .zoom => "Panel transition: PanelZoom\n", + .dissolve => "Panel transition: PanelDissolve\n", + .ascii => "Panel transition: PanelAscii\n", + .vertical => "Panel transition: PanelVertical\n", + .edges => "Panel transition: PanelEdges\n", + .fall => "Panel transition: PanelFall\n", + .wave => "Panel transition: PanelWave\n", + .curtain => "Panel transition: PanelCurtain\n", + .scramble => "Panel transition: PanelScramble\n", + .typewriter => "Panel transition: PanelType\n", + }, + .scene => |effect| switch (effect) { + .crt => "Crt: on\n", + .ripple => "Ripple: on\n", + .glitch => "Glitch: on\n", + }, + .theme, .font => unreachable, + }; + try std.testing.expect(std.mem.indexOf(u8, report, expected) != null); + } + + try std.testing.expect(state.font.requested_name.set("Wanted Mono")); + try std.testing.expect(state.font.requested_path.set("/fonts/wanted.ttf")); + try std.testing.expect(state.font.effective_name.set("Effective Mono")); + state.font.pending = true; + state.font.effective_size_hundredths = 1375; + state.font.effective_size_unit = .points; + state.font.tagline_percent = 82; + + var out: std.Io.Writer = .fixed(&storage); + try writeReport(&out, context); + const report = storage[0..out.end]; + for ([_][]const u8{ + "Theme: acme\n", + "Font requested: Wanted Mono\n", + "Font requested path: /fonts/wanted.ttf\n", + "Font effective: Effective Mono\n", + "Font pending: on\n", + "Font effective size: 13.75 points\n", + "TaglineSize: 82%\n", + "Startup config: /tmp/pardes/init\n", + "Platform: gui\n", + "Compiled default shell: /bin/sh\n", + "GUI shader source: live GLSL compiled during this build\n", + "Look hover delay: 18 frames\n", + "Native images: on\n", + }) |expected| try std.testing.expect(std.mem.indexOf(u8, report, expected) != null); + + var defaults: Runtime = .{}; + var defaults_context = context; + defaults_context.startup_config_path = null; + defaults_context.platform = "tty"; + defaults_context.gui_shader_source_mode = null; + defaults_context.hover_delay_frames = null; + defaults_context.native_images = false; + defaults_context.capabilities = .{ + .font_picker = false, + .panel_transitions = true, + .scene_shaders = false, + .tagline_font_size = false, + }; + defaults_context.state = &defaults; + out = .fixed(&storage); + try writeReport(&out, defaults_context); + const defaults_report = storage[0..out.end]; + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell requested (new panes): /bin/sh (default)\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell effective (last spawn): (none)\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell pending: on\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Startup config: no per-user config path\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "GUI shader source:") == null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font: unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font requested:") == null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Panel transition: off\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Crt: unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Ripple: unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Glitch: unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "TaglineSize: unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Look hover delay: off\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Native images: off\n") != null); + + defaults_context.platform = "web"; + defaults_context.capabilities.panel_transitions = false; + defaults_context.capabilities.tagline_font_size = true; + out = .fixed(&storage); + try writeReport(&out, defaults_context); + const web_report = storage[0..out.end]; + try std.testing.expect(std.mem.indexOf(u8, web_report, "Panel transition: unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, web_report, "TaglineSize: 100% (build-time only)\n") != null); + } +}; + +pub const User = struct { + const max_bytes = 1024 * 1024; + + pub const init_name = "init"; + pub const builtin_themes_subdir = "themes/builtin"; + + // Relative XDG_CONFIG_HOME values are ignored. + pub fn path(gpa: std.mem.Allocator, env: *const std.process.Environ.Map) !?[]u8 { + if (builtin.os.tag == .windows) { + if (env.get("LOCALAPPDATA")) |base| if (base.len != 0) + return try std.fs.path.join(gpa, &.{ base, "pardes" }); + if (env.get("USERPROFILE")) |home| if (home.len != 0) + return try std.fs.path.join(gpa, &.{ home, "AppData", "Local", "pardes" }); + return null; + } + + if (env.get("XDG_CONFIG_HOME")) |base| if (base.len != 0 and std.fs.path.isAbsolute(base)) + return try std.fs.path.join(gpa, &.{ base, "pardes" }); + + const home = env.get("HOME") orelse return null; + if (home.len == 0) return null; + if (builtin.os.tag == .macos) + return try std.fs.path.join(gpa, &.{ home, "Library", "Application Support", "pardes" }); + return try std.fs.path.join(gpa, &.{ home, ".config", "pardes" }); + } + + // The caller's allocator owns these slices, including paths when init is absent. + pub const Found = struct { + dir: ?[]const u8 = null, + path: ?[]const u8 = null, + bytes: ?[]const u8 = null, + }; + + pub fn load( + io: std.Io, + gpa: std.mem.Allocator, + env: *const std.process.Environ.Map, + ) Found { + const config_dir = (path(gpa, env) catch return .{}) orelse return .{}; + const config_path = std.fs.path.join(gpa, &.{ config_dir, init_name }) catch return .{ .dir = config_dir }; + return .{ + .dir = config_dir, + .path = config_path, + .bytes = std.Io.Dir.cwd().readFileAlloc(io, config_path, gpa, .limited(max_bytes)) catch null, + }; + } + + test "config path honors XDG and rejects a relative XDG directory" { + if (builtin.os.tag == .windows) return; + + var env: std.process.Environ.Map = .init(std.testing.allocator); + defer env.deinit(); + try env.put("HOME", "/home/pardes-test"); + try env.put("XDG_CONFIG_HOME", "/var/tmp/pardes-xdg"); + + const xdg = (try path(std.testing.allocator, &env)).?; + defer std.testing.allocator.free(xdg); + try std.testing.expectEqualStrings("/var/tmp/pardes-xdg/pardes", xdg); + + try env.put("XDG_CONFIG_HOME", "relative/config"); + const fallback = (try path(std.testing.allocator, &env)).?; + defer std.testing.allocator.free(fallback); + const expected = if (builtin.os.tag == .macos) + "/home/pardes-test/Library/Application Support/pardes" + else + "/home/pardes-test/.config/pardes"; + try std.testing.expectEqualStrings(expected, fallback); + } + + test "config loader reads init inside the config directory" { + if (builtin.os.tag == .windows) return; + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var base_buf: [std.fs.max_path_bytes]u8 = undefined; + const base_len = try tmp.dir.realPath(std.testing.io, &base_buf); + + var env: std.process.Environ.Map = .init(std.testing.allocator); + defer env.deinit(); + try env.put("XDG_CONFIG_HOME", base_buf[0..base_len]); + + const missing = load(std.testing.io, std.testing.allocator, &env); + defer std.testing.allocator.free(missing.dir.?); + defer std.testing.allocator.free(missing.path.?); + const expected_dir = try std.fs.path.join(std.testing.allocator, &.{ base_buf[0..base_len], "pardes" }); + defer std.testing.allocator.free(expected_dir); + const expected = try std.fs.path.join(std.testing.allocator, &.{ expected_dir, init_name }); + defer std.testing.allocator.free(expected); + try std.testing.expectEqualStrings(expected_dir, missing.dir.?); + try std.testing.expectEqualStrings(expected, missing.path.?); + try std.testing.expect(missing.bytes == null); + const source = "Theme dark\nUnknown command\nTheme acme\n"; + try tmp.dir.createDir(std.testing.io, "pardes", .default_dir); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "pardes/init", .data = source }); + const found = load(std.testing.io, std.testing.allocator, &env); + defer std.testing.allocator.free(found.dir.?); + defer std.testing.allocator.free(found.path.?); + defer std.testing.allocator.free(found.bytes.?); + try std.testing.expectEqualStrings(expected_dir, found.dir.?); + try std.testing.expectEqualStrings(source, found.bytes.?); + } + + // Replace generated theme files; preserve unrelated user files. + pub fn dumpThemes( + io: std.Io, + gpa: std.mem.Allocator, + config_dir: []const u8, + theme_values: anytype, + ) ![]u8 { + const out_dir = try std.fs.path.join(gpa, &.{ config_dir, builtin_themes_subdir }); + errdefer gpa.free(out_dir); + try std.Io.Dir.cwd().createDirPath(io, out_dir); + + for (theme_values) |theme_value| { + var encoded: std.Io.Writer.Allocating = .init(gpa); + defer encoded.deinit(); + try std.zon.stringify.serialize(theme_value, .{ .whitespace = true }, &encoded.writer); + + const filename = try std.fmt.allocPrint(gpa, "{s}.zon", .{theme_value.name}); + defer gpa.free(filename); + const output_path = try std.fs.path.join(gpa, &.{ out_dir, filename }); + defer gpa.free(output_path); + try std.Io.Dir.cwd().writeFile(io, .{ + .sub_path = output_path, + .data = encoded.written(), + }); + } + return out_dir; + } + + test "theme dump creates the builtin subdirectory and ZON files" { + const io = std.testing.io; + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var base_buf: [std.fs.max_path_bytes]u8 = undefined; + const base_len = try tmp.dir.realPath(io, &base_buf); + const Sample = struct { name: []const u8, rgb: [3]u8 }; + const samples = [_]Sample{ + .{ .name = "one", .rgb = .{ 1, 2, 3 } }, + .{ .name = "two", .rgb = .{ 4, 5, 6 } }, + }; + const output = try dumpThemes(io, gpa, base_buf[0..base_len], &samples); + defer gpa.free(output); + const expected = try std.fs.path.join(gpa, &.{ base_buf[0..base_len], builtin_themes_subdir }); + defer gpa.free(expected); + try std.testing.expectEqualStrings(expected, output); + + const one_path = try std.fs.path.join(gpa, &.{ output, "one.zon" }); + defer gpa.free(one_path); + const bytes = try std.Io.Dir.cwd().readFileAlloc(io, one_path, gpa, .limited(4096)); + defer gpa.free(bytes); + const source = try gpa.dupeZ(u8, bytes); + defer gpa.free(source); + const parsed = try std.zon.parse.fromSliceAlloc(Sample, gpa, source, null, .{}); + defer std.zon.parse.free(gpa, parsed); + try std.testing.expectEqualStrings("one", parsed.name); + try std.testing.expectEqual([3]u8{ 1, 2, 3 }, parsed.rgb); + } +}; + +test { + _ = Runtime; + _ = User; +} |
