summaryrefslogtreecommitdiff
path: root/src/config.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-06 18:11:36 -0300
committerGabriel Schneider <[email protected]>2026-09-07 13:59:12 -0300
commit60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch)
tree310fc734173cf771881f4691c71909135fadde97 /src/config.zig
parentfa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff)
downloadpardes-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.zig1435
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;
+}