summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--next-steps.txt2
-rw-r--r--src/builtins.zig17
-rw-r--r--src/config.zig498
-rw-r--r--src/file_pane.zig9
-rw-r--r--src/gui/gui.zig3
-rw-r--r--src/look.zig32
-rw-r--r--src/lsp/lsp.zig9
-rw-r--r--src/output_pane.zig5
-rw-r--r--src/pardes.zig764
-rw-r--r--test/snapshots/rsearch.snap2
-rw-r--r--test/snapshots/tagcmd.snap2
11 files changed, 893 insertions, 450 deletions
diff --git a/next-steps.txt b/next-steps.txt
index 747dfd34..e2211e7b 100644
--- a/next-steps.txt
+++ b/next-steps.txt
@@ -1,4 +1,4 @@
-- All syntatic choices must be centralized in single .zig file: keybindings, syntax for Look, etc; the motivation for this is that those can easilly be changed and tweaked if they're centralized like this and in the process we may cut down some repetition of logic.
++ All syntatic choices must be centralized in single .zig file: keybindings, syntax for Look, etc; the motivation for this is that those can easilly be changed and tweaked if they're centralized like this and in the process we may cut down some repetition of logic.
- Carve out and isolate the Output pane as its own semantic entity, right now we are doing specific stuff on the output pane based on the command that opened it, let's make this part of the data structure that represents it, the heuristics for special handling of the output panes will be done by reading the Command that originated it field from itself. Make this centralized on the code so its clear to see and tweak those heuristics.
- File panes need to split the latest column which a file pane was being used.
- Add multiple cursor support, and Ctrl-c auto comments. Use the same methodology of running helix in a harness as the oracle for differential testing. Also add regex interactive selection like on helix and the more specific selection semantics, use https://github.com/mnemnion/mvzr for regexes, add it using zig fetch.
diff --git a/src/builtins.zig b/src/builtins.zig
index 7143a860..d32e4ed3 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -21,15 +21,16 @@
//! table catches immediately (see leader_path: a missing key path is a
//! compile error).
//!
-//! What is NOT here: the key bindings. `leader_path` stays ONE table in
-//! pardes.zig next to `topbar_str` — the whole remapping surface belongs in
-//! one place a user can read top to bottom, not scattered a line at a time
-//! across thirty-three structs.
+//! What is NOT here: the key bindings. `leader_path` is ONE table in
+//! config.zig next to `topbar_str` and every other syntactic choice — the
+//! whole remapping surface belongs in one file a user can read top to bottom,
+//! not scattered a line at a time across thirty-three structs.
const std = @import("std");
const pardes = @import("pardes.zig");
const Pardes = pardes.Pardes;
const Pane = pardes.Pane;
const output_pane = @import("output_pane.zig");
+const config = @import("config.zig");
/// What a builtin gets to act on. One bundle rather than five parameters
/// because most builtins want two of them and zig rejects the unused rest.
@@ -273,7 +274,7 @@ pub const Find = struct {
pub fn run(c: Ctx) void {
const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
if (pat.len > 0) return c.p.runSearch(c.id, pat, .find);
- c.p.startSearch(c.pane, Pardes.find_marker);
+ c.p.startSearch(c.pane, config.find_marker);
}
};
@@ -283,7 +284,7 @@ pub const Grep = struct {
pub fn run(c: Ctx) void {
const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
if (pat.len > 0) return c.p.runSearch(c.id, pat, .grep);
- c.p.startSearch(c.pane, Pardes.grep_marker);
+ c.p.startSearch(c.pane, config.grep_marker);
}
};
@@ -412,7 +413,7 @@ pub const Rename = struct {
pub fn run(c: Ctx) void {
const a = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
if (a.len > 0) return c.p.lspRequest(c.id, .rename, a);
- c.p.startSearch(c.pane, Pardes.rename_marker);
+ c.p.startSearch(c.pane, config.rename_marker);
}
};
@@ -420,7 +421,7 @@ pub const WsSymbols = struct {
pub fn run(c: Ctx) void {
const a = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
if (a.len > 0) return c.p.lspRequest(c.id, .workspace_symbols, a);
- c.p.startSearch(c.pane, Pardes.symbol_marker);
+ c.p.startSearch(c.pane, config.symbol_marker);
}
};
diff --git a/src/config.zig b/src/config.zig
new file mode 100644
index 00000000..141d8376
--- /dev/null
+++ b/src/config.zig
@@ -0,0 +1,498 @@
+//! 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.
+//!
+//! 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 pardes = @import("pardes.zig");
+const builtins = @import("builtins.zig");
+const Key = pardes.Key;
+const Mouse = pardes.Mouse;
+const Builtin = builtins.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.
+pub const Chord = struct {
+ cp: u21,
+ ctrl: bool = false,
+ alt: bool = false,
+ 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.
+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 = '?';
+
+/// SPC leader: ONE key path per builtin, the whole remapping surface. An
+/// EnumArray, so a new enum field is a compile error until it has a path.
+/// Groups are just shared first letters (f files, h docs, c columns, t
+/// toggles, s session, l language, w windows).
+pub const leader_path = std.EnumArray(Builtin, []const u8).init(.{
+ .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",
+ .SelectRefs = "lh",
+ .Symbols = "ls",
+ .WsSymbols = "lS",
+ .Diagnostics = "ld",
+ .WsDiagnostics = "lD",
+ .Lspinfo = "li",
+ .Lspwhy = "lw",
+ .Del = "d",
+ .Kill = "k",
+ // the `f` file group (spacemacs): Save left vim's `w` to join Find here,
+ // which frees `w` for the window group (SPC w h/j/k/l) to move into.
+ .Save = "fs",
+ .Find = "ff",
+ .Grep = "fg",
+ .Tutor = "ht",
+ .Newcol = "cn",
+ .Delcol = "cd",
+ .Debug = "td",
+ .Colors = "tc",
+ .NextColor = "tn",
+ .Crt = "tr",
+ // 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",
+ .Toggleterm = "wt",
+});
+
+// ---- the acme chords ----
+
+// 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;
+
+// ---- 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.
+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 },
+ .{ .letter = .{ .cp = 'k' }, .arrow = .{ .cp = Key.up }, .cmd = .Up },
+ .{ .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 }};
+
+/// `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' }};
+
+// ---- 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 }};
+
+// ---- 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 Crt are NOT here: both are set-once display switches you flip and
+// forget, and a bar you read every frame should not spend width on them now
+// that `SPC t c` / `SPC t r` press them. NextColor stays — it is the one you
+// cycle repeatedly, so a click beats a three-key path.
+//
+// Help is LAST and is the one word that has to be here. 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: this word works in tty mode, which is the only
+// reason it earns the width. Appended rather than inserted so every existing
+// word keeps its column and no golden's click coordinates move.
+//
+// The two searches go LAST (before the optional `Restore <path>`), and next to
+// each other: they are one pair — Find matches file NAMES, Grep their CONTENTS.
+pub const topbar_str = "Kill Newcol Tutor Debug NextColor Dump Find Grep Help";
+
+/// the default editable tail of a pane's tag, per kind (an output buffer has
+/// no file to Save, so it gets the plain one)
+pub const pane_builtins_str = "Del";
+pub const file_pane_builtins_str = "Save Del";
+
+/// the tag prefix's mode word — live chrome, not text you own
+pub const tag_normal = "NOR";
+pub const tag_insert = "INS";
+pub const tag_tty = "TTY";
+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' }};
+/// 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;
+
+// ---- layout numbers that are taste ----
+
+/// the file pane's line-number gutter, in columns
+pub const PREFIX_W: u16 = 5;
+/// 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
+pub const scroll_off = 3;
+/// the pane's left chrome: scrollbar + the layout box in the tag row
+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.
+pub fn isFileChar(c: u8) bool {
+ return std.ascii.isAlphanumeric(c) or switch (c) {
+ '.', '-', '+', '/', ':', '@', '_', '~' => true,
+ else => false,
+ };
+}
+
+/// 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 = ':';
+
+/// `@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.
+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 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 /";
+
+/// Output-buffer names (acme's +Errors). Load-bearing beyond cosmetics: n/N
+/// finds its results buffer by name, so the writer and the stepper must agree.
+pub const search_buffer = "+Search";
+pub const help_buffer = "+Help";
+pub const hover_buffer = "+Hover";
+pub const lsp_buffer = "+Lsp";
+
+// ============================================================================
+// PART 3 — THE HELIX KEYMAP. READ THIS BEFORE RETARGETING ANYTHING BELOW.
+//
+// These are not free choices. `zig build hxdiff` (360 cases) and
+// `zig build hxparity` (440 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.
+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 } };
+pub const move_up: []const Chord = &.{ .{ .cp = 'k' }, .{ .cp = Key.up } };
+pub const next_word_start: []const Chord = &.{.{ .cp = 'w' }};
+pub const prev_word_start: []const Chord = &.{.{ .cp = 'b' }};
+pub const next_word_end: []const Chord = &.{.{ .cp = 'e' }};
+pub const next_long_word_start: []const Chord = &.{.{ .cp = 'W' }};
+pub const prev_long_word_start: []const Chord = &.{.{ .cp = 'B' }};
+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)
+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' }};
+pub const goto_line_end: []const Chord = &.{.{ .cp = 'l' }};
+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' }};
+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' }};
+pub const shrink_to_line_bounds: []const Chord = &.{.{ .cp = 'x', .alt = true }};
+pub const collapse_selection: []const Chord = &.{.{ .cp = ';' }};
+pub const flip_selection: []const Chord = &.{.{ .cp = ';', .alt = true }};
+pub const select_all: []const Chord = &.{.{ .cp = '%' }};
+
+// ---- edits ----
+
+pub const delete: []const Chord = &.{.{ .cp = 'd' }};
+pub const delete_noyank: []const Chord = &.{.{ .cp = 'd', .alt = true }};
+pub const change: []const Chord = &.{.{ .cp = 'c' }};
+pub const yank: []const Chord = &.{.{ .cp = 'y' }};
+pub const replace_with_yank: []const Chord = &.{.{ .cp = 'R' }};
+/// helix: the DEFAULT register, not the system clipboard — most terminals
+/// refuse the OSC 52 read, so a round trip would never come back
+pub const paste_after: []const Chord = &.{.{ .cp = 'p' }};
+pub const paste_before: []const Chord = &.{.{ .cp = 'P' }};
+pub const switch_case: []const Chord = &.{.{ .cp = '~' }};
+pub const to_lowercase: []const Chord = &.{.{ .cp = '`' }};
+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' }};
+
+/// Exit select mode / drop pending prefixes and the count; leave insert mode;
+/// abandon a tag, an armed search or the topbar. On an OUTPUT buffer there is
+/// nothing worth keeping, so Esc there is the Del builtin instead.
+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 }};
diff --git a/src/file_pane.zig b/src/file_pane.zig
index 4c02e983..68fea40f 100644
--- a/src/file_pane.zig
+++ b/src/file_pane.zig
@@ -5,6 +5,7 @@
//! is the pane machinery in pardes.zig, which does not care what kind it is.
const std = @import("std");
const pardes = @import("pardes.zig");
+const config = @import("config.zig");
const Pardes = pardes.Pardes;
const Pane = pardes.Pane;
const File = pardes.File;
@@ -115,7 +116,7 @@ pub fn bodyText(arena: std.mem.Allocator, pane: *Pane, f: *File) ![]const u8 {
}
// horizontal scroll: cut hscroll columns after the gutter
if (pane.hscroll > 0) {
- const cut_at = row_start + pardes.PREFIX_W;
+ const cut_at = row_start + config.PREFIX_W;
if (out.items.len > cut_at) {
const cut = @min(@as(usize, @intCast(pane.hscroll)), out.items.len - cut_at);
out.replaceRangeAssumeCapacity(cut_at, cut, &.{});
@@ -140,7 +141,7 @@ pub fn drawGutter(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, bod
while (vr < body_h) : (vr += 1) {
const on_cursor = @as(i32, vr) == cur_vr;
var c: u16 = 0;
- while (c < pardes.PREFIX_W and c < tw) : (c += 1) {
+ while (c < config.PREFIX_W and c < tw) : (c += 1) {
const cell = s.at(tx + c, r.y + pardes.BOX_H + vr);
cell.default = false; // paints blank gutter rows too
if (on_cursor) {
@@ -181,13 +182,13 @@ pub fn recolorSyntax(p: *Pardes, pane: *Pane, f: *File, r: pardes.Rect, tx: u16,
const line = flines.next() orelse break;
const base = @intFromPtr(line.ptr) - @intFromPtr(f.content.ptr);
var c: usize = 0;
- while (hs + c < line.len and pardes.PREFIX_W + c < tw) : (c += 1) {
+ while (hs + c < line.len and config.PREFIX_W + c < tw) : (c += 1) {
const idx = base + hs + c;
if (idx < f.highlight_start) continue;
const hidx = idx - f.highlight_start;
if (hidx >= f.highlights.len) continue;
const ss = synStyle(p, @enumFromInt(f.highlights[hidx])) orelse continue;
- const cell = s.at(tx + @as(u16, @intCast(pardes.PREFIX_W + c)), r.y + pardes.BOX_H + vr);
+ const cell = s.at(tx + @as(u16, @intCast(config.PREFIX_W + c)), r.y + pardes.BOX_H + vr);
if (cell.default) continue;
cell.style.fg = .{ .rgb = ss.fg };
cell.style.bold = ss.bold;
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index bca0ebb9..d8086981 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -17,6 +17,7 @@ const libc = std.c;
const vaxis = @import("vaxis"); // test modes only: the stdin escape-seq parser
const ghostty_vt = @import("ghostty-vt"); // 256-color palette for .index cells
const pardes = @import("../pardes.zig");
+const config = @import("../config.zig");
const look = @import("../look.zig");
const deck = @import("deck.zig");
const crt = @import("crt.zig");
@@ -395,7 +396,7 @@ fn webChromeTarget(core: *const pardes.Pardes, col: u16, row: u16) ?WebChromeTar
for (core.panes, 0..) |slot, id| {
if (slot == null) continue;
const rect = core.rects[id];
- if (row == rect.y and col >= rect.x and col < rect.x + rect.w and col < rect.x + pardes.GUTTER)
+ if (row == rect.y and col >= rect.x and col < rect.x + rect.w and col < rect.x + config.GUTTER)
return .{ .col = col, .row = rect.y };
}
diff --git a/src/look.zig b/src/look.zig
index 556b8ca7..c4d04576 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -1,5 +1,9 @@
//! What a click on text MEANS. The acme "look" (right click / Enter): expand
//! the click to a file-ish word, then resolve it against the pane's directory.
+//! How a word is SPELLED — the isfilec set, the `:LINE:COL` suffix, `@pN`, the
+//! URL schemes, the image extensions — is config.zig; this file is only what
+//! the spelling RESOLVES to.
+//!
//! This is the one deliberately platform-divergent file — the divergence is a
//! comptime switch on pardes.platform, used the way the stdlib switches on
//! os.tag, so every platform's behavior sits in the same screenful:
@@ -11,6 +15,7 @@ const std = @import("std");
const builtin = @import("builtin");
const libc = std.c;
const pardes = @import("pardes.zig");
+const config = @import("config.zig");
const embedded_sources = if (pardes.platform == .web) @import("embedded_sources") else struct {
pub const Source = struct { path: []const u8, contents: []const u8 };
pub const all = [_]Source{};
@@ -50,14 +55,6 @@ pub fn openLink(url: []const u8) void {
_ = libc.waitpid(pid, null, 0);
}
-/// file-ish word chars (acme isfilec): alnum + . - + / : @ _ ~
-pub fn isFileChar(c: u8) bool {
- return std.ascii.isAlphanumeric(c) or switch (c) {
- '.', '-', '+', '/', ':', '@', '_', '~' => true,
- else => false,
- };
-}
-
/// peel a trailing :LINE[:COL] suffix (both 1-based, 0 = absent):
/// main.zig:100 -> {main.zig, 100, 0}
/// main.zig:100:7 -> {main.zig, 100, 7}
@@ -65,18 +62,18 @@ pub fn isFileChar(c: u8) bool {
pub fn parsePathLine(tok: []const u8) struct { path: []const u8, line: usize, col: usize } {
var sep: usize = 0;
while (sep < tok.len) : (sep += 1) {
- if (tok[sep] != ':') continue;
+ if (tok[sep] != config.line_col_sep) continue;
var j = sep + 1;
var line: usize = 0;
while (j < tok.len and std.ascii.isDigit(tok[j])) : (j += 1) line = line * 10 + (tok[j] - '0');
if (j == sep + 1) continue; // no digits after ':'
- if (j < tok.len and tok[j] != ':') continue; // junk after the number
+ if (j < tok.len and tok[j] != config.line_col_sep) continue; // junk after the number
var col: usize = 0;
if (j < tok.len) {
var k = j + 1;
while (k < tok.len and std.ascii.isDigit(tok[k])) : (k += 1) col = col * 10 + (tok[k] - '0');
// digits, and nothing but a delimiter after them, or no column
- if (k == j + 1 or (k < tok.len and tok[k] != ':')) col = 0;
+ if (k == j + 1 or (k < tok.len and tok[k] != config.line_col_sep)) col = 0;
}
return .{ .path = tok[0..sep], .line = line, .col = col };
}
@@ -95,10 +92,8 @@ pub const Target = union(enum) {
pane: struct { id: usize, line: usize, col: usize },
};
-const image_exts = [_][]const u8{ ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ppm", ".pgm", ".tga" };
-
pub fn isImagePath(path: []const u8) bool {
- for (image_exts) |ext| {
+ for (config.image_exts) |ext| {
if (std.ascii.endsWithIgnoreCase(path, ext)) return true;
}
return false;
@@ -114,9 +109,9 @@ pub fn resolve(word_raw: []const u8, cwd: []const u8, realbuf: *[4096]u8) Target
if (word.len == 0) return .none;
// `@pN` addresses a pane, not a path: every platform, before the fs.
- if (word.len > 2 and word[0] == '@' and word[1] == 'p') {
+ if (word.len > config.pane_addr.len and std.mem.startsWith(u8, word, config.pane_addr)) {
var id: usize = 0;
- for (word[2..]) |c| {
+ for (word[config.pane_addr.len..]) |c| {
if (!std.ascii.isDigit(c)) break;
id = id * 10 + (c - '0');
} else return .{ .pane = .{ .id = id, .line = pl.line, .col = pl.col } };
@@ -124,8 +119,9 @@ pub fn resolve(word_raw: []const u8, cwd: []const u8, realbuf: *[4096]u8) Target
// a URL is a URL everywhere: no filesystem can answer it, so it leaves the
// app (browser tab on web, xdg-open/open on the desktop).
- if (std.mem.startsWith(u8, trimmed, "http://") or std.mem.startsWith(u8, trimmed, "https://"))
- return .{ .url = trimmed };
+ for (config.url_schemes) |scheme| {
+ if (std.mem.startsWith(u8, trimmed, scheme)) return .{ .url = trimmed };
+ }
if (platform_has_fs) {
var joinbuf: [2048]u8 = undefined;
diff --git a/src/lsp/lsp.zig b/src/lsp/lsp.zig
index 536047a3..610c5604 100644
--- a/src/lsp/lsp.zig
+++ b/src/lsp/lsp.zig
@@ -17,6 +17,7 @@
//! implementations are measured against each other: same core, same harness,
//! same rows, different `query`.
const std = @import("std");
+const config = @import("../config.zig");
/// What the caller wants to know. The helix command each one backs is named
/// alongside, because the keymap is helix's and these are its verbs.
@@ -75,11 +76,11 @@ pub const Kind = enum {
/// content is not a list of locations — n/N over prose is nonsense.
pub fn bufferName(k: Kind) []const u8 {
return switch (k) {
- .hover => "+Hover",
- .code_action, .format, .rename => "+Lsp",
+ .hover => config.hover_buffer,
+ .code_action, .format, .rename => config.lsp_buffer,
// prose about the backend, never a list of locations
- .status, .explain => "+Lsp",
- else => "+Search",
+ .status, .explain => config.lsp_buffer,
+ else => config.search_buffer,
};
}
};
diff --git a/src/output_pane.zig b/src/output_pane.zig
index 4535495c..64bfc630 100644
--- a/src/output_pane.zig
+++ b/src/output_pane.zig
@@ -14,6 +14,7 @@ const pardes = @import("pardes.zig");
const Pardes = pardes.Pardes;
const Pane = pardes.Pane;
const file_pane = @import("file_pane.zig");
+const config = @import("config.zig");
/// `content` is gpa-owned and adopted. The name carries `dir` so looks inside
/// the buffer resolve like anywhere else.
@@ -51,7 +52,7 @@ pub fn openHelp(p: *Pardes, id: usize, prefix: []const u8) void {
for (p.panes, 0..) |slot, i| {
const hp = slot orelse continue;
const hf = if (hp.file) |*f| f else continue;
- if (!hf.output or !std.mem.endsWith(u8, hf.path, "/+Help")) continue;
+ if (!hf.output or !std.mem.endsWith(u8, hf.path, "/" ++ config.help_buffer)) continue;
file_pane.setContent(p, hf, content);
hf.scroll = 0;
hp.cur_row = 0;
@@ -64,7 +65,7 @@ pub fn openHelp(p: *Pardes, id: usize, prefix: []const u8) void {
p.gpa.free(content);
return;
};
- const np = open(p, free, dir, "+Help", content) catch {
+ const np = open(p, free, dir, config.help_buffer, content) catch {
p.gpa.free(content);
return;
};
diff --git a/src/pardes.zig b/src/pardes.zig
index f6168449..5412569b 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -12,6 +12,12 @@
//! src/ layout: the core lies flat at src/, and every SUBDIRECTORY is one
//! backend (tty/ gui/ lsp/) — so a file being in no directory at all is what
//! says it is core, and nothing needs a header to claim it.
+//!
+//! Not one key, button or piece of Look syntax is spelled in this file: every
+//! one of them is a named binding in config.zig, and `hit`/`isPrefix` below are
+//! the only two matchers. That is so retargeting anything is an edit in one
+//! file, and so a later builtin can enumerate the bindings the way Help already
+//! enumerates the leader.
const std = @import("std");
const ghostty_vt = @import("ghostty-vt");
const uucode = @import("uucode");
@@ -23,6 +29,7 @@ const term_pane = @import("term_pane.zig");
const file_pane = @import("file_pane.zig");
const output_pane = @import("output_pane.zig");
const builtins = @import("builtins.zig");
+pub const config = @import("config.zig");
pub const image = @import("image.zig");
pub const dump = @import("dump.zig");
pub const lsp = @import("lsp/lsp.zig");
@@ -30,32 +37,18 @@ pub const lsp = @import("lsp/lsp.zig");
pub const Platform = enum { tty, gui, web };
pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config").platform));
+// The capacities and the two heights that are STRUCTURE, not taste: the
+// fixed-size pane/column arrays, and the fact that the topbar and a tag are
+// one row each (nothing here works at any other value). The layout numbers
+// that ARE taste — the gutter, the line-number prefix, scrolloff, the pane
+// minimums — live in config.zig with everything else a user retargets.
pub const MAX_PANES = 16;
pub const MAX_COLS = 6;
pub const TOPBAR_H: u16 = 1;
-pub const GUTTER: u16 = 2;
pub const BOX_H: u16 = 1;
-pub const MINW: u16 = 10;
-pub const MINH: u16 = 3;
-pub const PREFIX_W: u16 = 5;
-/// vim 'scrolloff': keyboard cursor moves keep this many context rows visible
-/// above/below the cursor (clamped at file boundaries and short panes)
-const scroll_off = 3;
-/// which key runs which acme chord — everywhere it exists: normal mode, the
-/// tag, and the `:` command line. false (the default) is the original binding,
-/// Enter looks / Tab executes (so a command runs with `:w<Tab>`); true swaps
-/// them for the vim reading.
-pub const swap_enter_tab = false;
-// The two searches go LAST (before the optional `Restore <path>`), and next to
-// each other: they are one pair — Find matches file NAMES, Grep their CONTENTS
-// — and every word's start column is what a topbar click dispatches on, so
-// appending is the edit that leaves the bar people already know where it was.
-pub const topbar_str = "Kill Newcol Tutor Debug NextColor Dump Find Grep Help";
-const pane_builtins_str = "Del";
-const file_pane_builtins_str = "Save Del";
-const pane_tail = " " ++ pane_builtins_str;
-const file_pane_tail = " " ++ file_pane_builtins_str;
+const pane_tail = " " ++ config.pane_builtins_str;
+const file_pane_tail = " " ++ config.file_pane_builtins_str;
// Builtins: executing the name (middle-click / Tab) runs it through the ONE
// dispatcher (runBuiltin, reached from actOnSelection), no matter where the
@@ -69,68 +62,6 @@ const file_pane_tail = " " ++ file_pane_builtins_str;
// sits with the other two comptime folds (leader_rows, the topbar check).
const Builtin = builtins.Builtin();
-/// SPC leader: ONE key path per builtin, the whole remapping surface. An
-/// EnumArray, so a new enum field is a compile error until it has a path.
-/// Groups are just shared first letters (f files, h docs, c columns, t
-/// toggles, s session, l language); `?` is Help's path and is honored at ANY
-/// depth — it lists what the prefix typed so far can still reach.
-const leader_path = std.EnumArray(Builtin, []const u8).init(.{
- .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.
- .Hover = "lk",
- .Rename = "lr",
- .CodeAction = "la",
- .SelectRefs = "lh",
- .Symbols = "ls",
- .WsSymbols = "lS",
- .Diagnostics = "ld",
- .WsDiagnostics = "lD",
- .Lspinfo = "li",
- .Lspwhy = "lw",
- .Del = "d",
- .Kill = "k",
- // the `f` file group (spacemacs): Save left vim's `w` to join Find here,
- // which frees `w` for the window group (SPC w h/j/k/l) to move into.
- .Save = "fs",
- .Find = "ff",
- .Grep = "fg",
- .Tutor = "ht",
- .Newcol = "cn",
- .Delcol = "cd",
- .Debug = "td",
- .Colors = "tc",
- .NextColor = "tn",
- .Crt = "tr",
- // 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",
- .Toggleterm = "wt",
-});
-
/// The leader tree, flattened: one row per builtin SORTED BY PATH, so every
/// prefix's subtree is a contiguous run — which is all the two runtime
/// readers need. The matcher asks "exact hit? still a prefix of something?"
@@ -145,9 +76,9 @@ pub const leader_rows: [@typeInfo(Builtin).@"enum".fields.len]Leader = blk: {
var rows: [@typeInfo(Builtin).@"enum".fields.len]Leader = undefined;
// the widest spaced-out path ("h t" = 3), so the names line up
var keyw: usize = 0;
- for (std.enums.values(Builtin)) |b| keyw = @max(keyw, 2 * leader_path.get(b).len - 1);
+ for (std.enums.values(Builtin)) |b| keyw = @max(keyw, 2 * config.leader_path.get(b).len - 1);
for (std.enums.values(Builtin), 0..) |b, i| {
- const path = leader_path.get(b);
+ const path = config.leader_path.get(b);
var keys: []const u8 = "";
for (path, 0..) |c, k| {
if (k > 0) keys = keys ++ " ";
@@ -171,26 +102,12 @@ pub const leader_rows: [@typeInfo(Builtin).@"enum".fields.len]Leader = blk: {
break :blk rows;
};
-// 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 so a rename cannot silently rot it.
-// Colors and Crt are NOT here: both are set-once display switches you flip and
-// forget, and a bar you read every frame should not spend width on them now
-// that `SPC t c` / `SPC t r` press them. NextColor stays — it is the one you
-// cycle repeatedly, so a click beats a three-key path.
-//
-// Help is LAST and is the one word that has to be here. 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: this word works in tty mode, which is the only
-// reason it earns the width. Appended rather than inserted so every existing
-// word keeps its column and no golden's click coordinates move.
+// config.topbar_str is a HAND-PICKED subset of the builtins in a fixed order,
+// not a derivation — see it there for why each word is in or out. This is the
+// check that a rename cannot silently rot it.
comptime {
@setEvalBranchQuota(20000); // the tokenizer walks the string one branch per byte, stringToEnum builds a map per name, and both sit behind the decl walk that folds Builtin
- var it = std.mem.tokenizeScalar(u8, topbar_str, ' ');
+ var it = std.mem.tokenizeScalar(u8, config.topbar_str, ' ');
while (it.next()) |w| {
if (std.meta.stringToEnum(Builtin, w) == null) @compileError("topbar word is not a builtin: " ++ w);
}
@@ -496,8 +413,8 @@ pub const Key = struct {
ctrl: bool = false,
alt: bool = false,
/// Only consulted for keys whose codepoint does NOT already carry the
- /// shift (Escape and friends) — `A` is `A`, not shift-`a`, so `is()`
- /// ignores this field on purpose.
+ /// shift (Escape and friends) — `A` is `A`, not shift-`a`, so `hit()`
+ /// ignores this field unless a binding explicitly asks for it.
shift: bool = false,
pub const enter: u21 = 0x0D;
@@ -516,16 +433,40 @@ pub const Key = struct {
pub const delete: u21 = 0xF0009;
};
-fn is(key: Key, cp: u21) bool {
- return key.cp == cp and !key.ctrl and !key.alt;
+/// Does this press match a binding? Every keymap test in the core goes through
+/// here, so the modifier rules are written once instead of once per modifier
+/// (the three is/isC/isA predicates this replaces each spelled out the same
+/// comparison with a different pair of negations).
+///
+/// A binding is a LIST: most have two spellings that must reach the same arm —
+/// `h` and Left, `Ctrl-f` and PageDown — and the `or` chain that used to do
+/// that at every call site is now one loop here.
+fn hit(key: Key, binding: []const config.Chord) bool {
+ for (binding) |c| {
+ if (key.cp != c.cp or key.ctrl != c.ctrl or key.alt != c.alt) continue;
+ // shift is carried in the codepoint for anything printable (`A` is
+ // `A`, not shift-`a`), so it is consulted ONLY when a binding asks for
+ // it — otherwise every letter binding would newly demand shift be up
+ if (c.shift and !key.shift) continue;
+ return true;
+ }
+ return false;
}
-fn isC(key: Key, cp: u21) bool {
- return key.cp == cp and key.ctrl and !key.alt;
-}
+/// `Pane.sel` is indexed by @intFromEnum(button), so this is the SELECT
+/// button's slot: where a sweep lands and where the left half of every acme
+/// chord reads its selection from. Named rather than the bare 0 it used to be
+/// so that config.select_button is genuinely the only place the choice is
+/// made.
+const sel_slot = @intFromEnum(config.select_button);
-fn isA(key: Key, cp: u21) bool {
- return key.cp == cp and !key.ctrl and key.alt;
+/// The same test for a MODAL PREFIX (`g`, `z`, `m`, `mi`, `]`...). config
+/// spells those as bare codepoints rather than Chords because pardes stores
+/// the codepoint itself in pane.pending until the next key completes the
+/// sequence — so this is the one place the two shapes meet, and it is the same
+/// comparison the stored byte gets a few lines later.
+fn isPrefix(key: Key, cp: u21) bool {
+ return key.cp == cp and !key.ctrl and !key.alt;
}
pub const Event = union(enum) {
@@ -845,7 +786,7 @@ pub const Pane = struct {
pub fn ensureCursorVisible(pane: *Pane) void {
// scrolloff margin, shrunk on short panes so the band stays non-empty
- const margin: i32 = @min(scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2));
+ const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2));
const off = pane.scroll();
const last = off + @as(i32, pane.rows) - 1;
if (pane.cur_row < off + margin) {
@@ -861,8 +802,8 @@ pub const Pane = struct {
// context around the cursor (wheel-driven hscroll is exempt — it
// never moves the cursor, and a cursor move pulls the view back)
if (pane.file != null) {
- const w: i32 = @max(1, @as(i32, pane.cols) - @as(i32, PREFIX_W));
- const hmargin: i32 = @min(scroll_off, @divTrunc(w - 1, 2));
+ const w: i32 = @max(1, @as(i32, pane.cols) - @as(i32, config.PREFIX_W));
+ const hmargin: i32 = @min(config.scroll_off, @divTrunc(w - 1, 2));
if (pane.cur_col < pane.hscroll + hmargin)
pane.hscroll = @max(0, pane.cur_col - hmargin)
else if (pane.cur_col > pane.hscroll + w - 1 - hmargin)
@@ -914,7 +855,7 @@ pub const Options = struct {
/// the left column, a terminal on the launch directory in the right
file: ?[]const u8 = null,
file_line: usize = 0,
- tty_toggle: u21 = 'b',
+ tty_toggle: u21 = config.tty_toggle_default,
/// load a dump of another instance instead of spawning shells (acme -l)
load_path: ?[]const u8 = null,
/// Initial grid. Shell contract: for LIVE sessions leave these at the
@@ -1095,8 +1036,8 @@ pub const Pardes = struct {
/// exists — render and click dispatch must agree on this exact string
fn topbar(p: *Pardes, buf: []u8) []const u8 {
if (p.last_dump) |d|
- return std.fmt.bufPrint(buf, topbar_str ++ " Restore {s}", .{d}) catch topbar_str;
- return topbar_str;
+ return std.fmt.bufPrint(buf, config.topbar_str ++ " Restore {s}", .{d}) catch config.topbar_str;
+ return config.topbar_str;
}
pub fn deinitPane(p: *Pardes, pane: *Pane) void {
@@ -1262,15 +1203,15 @@ pub const Pardes = struct {
/// renderer toggles it used to spell out are builtins now, under SPC t)
fn tagPrefix(p: *Pardes, pane: *Pane) ![]u8 {
const arena = p.scratch.allocator();
- if (pane.image) |iv| return std.fmt.allocPrint(arena, "img {s}", .{iv.path});
+ if (pane.image) |iv| return std.fmt.allocPrint(arena, config.tag_image ++ " {s}", .{iv.path});
if (pane.file) |f| {
- const fmode = if (pane.mode == .insert) "INS" else "NOR";
+ const fmode = if (pane.mode == .insert) config.tag_insert else config.tag_normal;
return std.fmt.allocPrint(arena, "{s} {s}", .{ fmode, f.path });
}
const mode = switch (pane.mode) {
- .normal => "NOR",
- .insert => "INS",
- .tty => "TTY",
+ .normal => config.tag_normal,
+ .insert => config.tag_insert,
+ .tty => config.tag_tty,
};
return std.fmt.allocPrint(arena, "{s} {s}", .{ mode, pane.cwdSlice() });
}
@@ -1341,9 +1282,9 @@ pub const Pardes = struct {
}
const col: usize = @min(@as(usize, pane.tag_col), text.len);
var lo = col;
- while (lo > 0 and look.isFileChar(text[lo - 1])) lo -= 1;
+ while (lo > 0 and config.isFileChar(text[lo - 1])) lo -= 1;
var hi = col;
- while (hi < text.len and look.isFileChar(text[hi])) hi += 1;
+ while (hi < text.len and config.isFileChar(text[hi])) hi += 1;
return if (hi > lo) text[lo..hi] else null;
}
@@ -1351,15 +1292,15 @@ pub const Pardes = struct {
/// sharing the pane's mode — the cursor moves over all of it, edits reach
/// only the tail. Newlines are always dropped.
fn tagInsertKey(p: *Pardes, pane: *Pane, key: Key) void {
- if (is(key, Key.escape)) {
+ if (hit(key, config.escape)) {
exitTagEdit(pane); // the tag is ALWAYS insert; Esc leaves it
return;
}
// the acme chords on the selection or the word at the cursor
- if (is(key, Key.enter) or is(key, Key.tab)) {
+ if (hit(key, config.look_key) or hit(key, config.exec_key)) {
if (p.tagChordText(pane)) |txt| {
pane.tag_sel = false;
- const btn: Mouse.Button = if (is(key, Key.enter) == swap_enter_tab) .middle else .right;
+ const btn: Mouse.Button = if (hit(key, config.look_key)) config.look_button else config.exec_button;
p.actOnSelection(btn, p.active, txt, null);
}
return;
@@ -1414,13 +1355,14 @@ pub const Pardes = struct {
const text = p.tagText(p.scratch.allocator(), pane) catch return;
const cur: usize = @min(@as(usize, pane.tag_col), text.len);
// Esc abandons the command line: back to the body, tail kept as text
- if (is(key, Key.escape)) return exitTagEdit(pane);
+ if (hit(key, config.escape)) return exitTagEdit(pane);
// the chord: run the selection (or the word under the cursor) and drop
- // back into the body — the whole point of `:`. Same keys as everywhere
- // else (swap_enter_tab), so the command line is `:w<Tab>` by default.
+ // back into the body — the whole point of `:`. The same two bindings as
+ // everywhere else (config.look_key / exec_key), so the command line is
+ // `:w<Tab>` by default.
// Nothing under the cursor means nothing ran: the tag keeps focus.
- if (is(key, Key.enter) or is(key, Key.tab)) {
- const btn: Mouse.Button = if (is(key, Key.enter) == swap_enter_tab) .middle else .right;
+ if (hit(key, config.look_key) or hit(key, config.exec_key)) {
+ const btn: Mouse.Button = if (hit(key, config.look_key)) config.look_button else config.exec_button;
const txt = p.tagChordText(pane) orelse return;
const id = p.active;
exitTagEdit(pane); // leave the tag FIRST: Del frees the pane
@@ -1429,21 +1371,25 @@ pub const Pardes = struct {
}
// y — 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 out.
- if (is(key, 'y')) {
+ if (hit(key, config.tag_yank)) {
if (p.tagChordText(pane)) |txt| p.setYank(txt);
return;
}
// insert entry (one line, so I/A are the tail's ends). The prefix is
// read-only, so entering insert inside it parks at the first editable
// column instead — you can never be typing into the path.
- if (is(key, 'i') or is(key, 'a') or is(key, 'I') or is(key, 'A')) {
+ if (hit(key, config.insert) or hit(key, config.append) or
+ hit(key, config.insert_line_start) or hit(key, config.insert_line_end))
+ {
const edit0: usize = (p.tagPrefix(pane) catch return).len;
- pane.tag_col = @intCast(@max(edit0, switch (key.cp) {
- 'a' => modal.nextGrapheme(text, cur),
- 'I' => 0,
- 'A' => text.len,
- else => cur,
- }));
+ pane.tag_col = @intCast(@max(edit0, if (hit(key, config.append))
+ modal.nextGrapheme(text, cur)
+ else if (hit(key, config.insert_line_start))
+ 0
+ else if (hit(key, config.insert_line_end))
+ text.len
+ else
+ cur));
pane.tag_sel = false;
pane.mode = .insert;
return;
@@ -1451,21 +1397,15 @@ pub const Pardes = struct {
// h/j/k/l — a tagline is a place in the LAYOUT, so the four letters walk
// it: focus the neighbour and land on ITS tagline, still in normal mode,
// so tag-to-tag navigation never drops through a body. Runs the SAME
- // Left/Down/Up/Right builtins `SPC w h/j/k/l` and `Ctrl-w` run — one
- // focusDir. The ARROWS below keep the in-tag grapheme motion, so the
- // letters cost nothing. Nothing in that direction = stay put, tag and
- // all: focusDir left `active` alone, so there is nothing to undo —
+ // Left/Down/Up/Right builtins `SPC w h/j/k/l` and `Ctrl-w` run, off the
+ // SAME table (config.window_keys) — one focusDir, one binding. Only the
+ // LETTER column: the arrows keep the in-tag grapheme motion below, so
+ // the letters cost nothing. Nothing in that direction = stay put, tag
+ // and all: focusDir left `active` alone, so there is nothing to undo —
// EXCEPT upwards, where "nothing" is still something (see below).
- const dir: ?Builtin = if (is(key, 'h'))
- .Left
- else if (is(key, 'j'))
- .Down
- else if (is(key, 'k'))
- .Up
- else if (is(key, 'l'))
- .Right
- else
- null;
+ const dir: ?Builtin = for (config.window_keys) |wk| {
+ if (hit(key, &.{wk.letter})) break wk.cmd;
+ } else null;
if (dir) |d| {
const from = p.active;
p.runBuiltin(d, from, "", null);
@@ -1498,30 +1438,30 @@ pub const Pardes = struct {
/// one place; only `h`/`l` differ — a tagline spends them on the layout,
/// the topbar has no layout — and each caller answers those itself.
fn lineMotion(text: []const u8, cur: usize, key: Key) ?modal.HxRange {
- const target: ?usize = if (is(key, Key.left))
+ const target: ?usize = if (hit(key, config.line_move_left))
modal.prevGrapheme(text, cur)
- else if (is(key, Key.right))
+ else if (hit(key, config.line_move_right))
modal.nextGrapheme(text, cur)
- else if (is(key, '0') or is(key, Key.home))
+ else if (hit(key, config.line_start))
0
- else if (is(key, '$') or is(key, Key.end))
+ else if (hit(key, config.line_end))
modal.prevGrapheme(text, text.len)
- else if (is(key, '^'))
+ else if (hit(key, config.line_first_nonws))
modal.firstNonWs(text)
else
null;
if (target) |t| return .{ .anchor = t, .head = t };
- const wt: ?modal.WordTarget = if (is(key, 'w'))
+ const wt: ?modal.WordTarget = if (hit(key, config.next_word_start))
.next_word_start
- else if (is(key, 'b'))
+ else if (hit(key, config.prev_word_start))
.prev_word_start
- else if (is(key, 'e'))
+ else if (hit(key, config.next_word_end))
.next_word_end
- else if (is(key, 'W'))
+ else if (hit(key, config.next_long_word_start))
.next_long_word_start
- else if (is(key, 'B'))
+ else if (hit(key, config.prev_long_word_start))
.prev_long_word_start
- else if (is(key, 'E'))
+ else if (hit(key, config.next_long_word_end))
.next_long_word_end
else
null;
@@ -1542,24 +1482,24 @@ pub const Pardes = struct {
// the line GROWS a `Restore <path>` word the moment a dump lands, so
// the column is clamped against the live line on every use
const cur: usize = @min(@as(usize, p.topbar_col orelse return), bar.len);
- if (is(key, Key.escape)) {
+ if (hit(key, config.escape)) {
p.topbar_col = null; // the active pane still has its body focus
return;
}
// the chord: run the word under the cursor, exactly as a middle click
// on it does. Leave the bar FIRST — `Kill` lives up here and tears the
// session down, the same hazard the pane-tag chord has with `Del`.
- if (is(key, Key.enter) or is(key, Key.tab)) {
+ if (hit(key, config.look_key) or hit(key, config.exec_key)) {
const word = wordAtCol(bar, @intCast(cur));
p.topbar_col = null;
- if (word.len > 0) p.actOnSelection(.middle, p.active, word, null);
+ if (word.len > 0) p.actOnSelection(config.exec_button, p.active, word, null);
return;
}
// j — back down onto a tagline, the mirror of the k that got you here:
// the pane you came from if it still holds the top row, else the
// leftmost pane that does. Recomputed, never remembered, so a pane
// deleted while the bar had focus strands nobody.
- if (is(key, 'j')) {
+ if (hit(key, config.topbar_down)) {
var dest: ?usize = null;
for (p.panes, 0..) |slot, i| {
if (slot == null or p.rects[i].y != TOPBAR_H) continue;
@@ -1577,9 +1517,9 @@ pub const Pardes = struct {
if (pane.tag_edit) pane.mode = .normal;
return;
}
- const col: ?usize = if (is(key, 'h'))
+ const col: ?usize = if (hit(key, config.topbar_left))
modal.prevGrapheme(bar, cur)
- else if (is(key, 'l'))
+ else if (hit(key, config.topbar_right))
modal.nextGrapheme(bar, cur)
else if (lineMotion(bar, cur, key)) |r|
modal.hxCursor(bar, r)
@@ -1631,9 +1571,9 @@ pub const Pardes = struct {
const col: usize = @intCast(@max(0, sl.c0));
if (col > line.len) return;
var hi = col;
- while (hi < line.len and look.isFileChar(line[hi])) hi += 1;
+ while (hi < line.len and config.isFileChar(line[hi])) hi += 1;
var lo = col;
- while (lo > 0 and look.isFileChar(line[lo - 1])) lo -= 1;
+ while (lo > 0 and config.isFileChar(line[lo - 1])) lo -= 1;
if (hi > lo) {
sl.c0 = @intCast(lo);
sl.c1 = @intCast(hi - 1);
@@ -1646,7 +1586,7 @@ pub const Pardes = struct {
/// coords: row 0 is the tag; file panes carry the line-number prefix)
fn cursorWordSel(pane: *Pane) Sel {
const vrow = (pane.cur_row - pane.scroll()) + @as(i32, BOX_H);
- const vcol = if (pane.file != null) pane.cur_col - pane.hscroll + @as(i32, PREFIX_W) else pane.cur_col;
+ const vcol = if (pane.file != null) pane.cur_col - pane.hscroll + @as(i32, config.PREFIX_W) else pane.cur_col;
return .{ .state = .done, .c0 = vcol, .c1 = vcol, .r0 = vrow, .r1 = vrow };
}
@@ -1674,8 +1614,8 @@ pub const Pardes = struct {
};
if (k > 0 and (i == first or (k > 1 and i == p.active))) continue;
const t = p.panes[i] orelse continue;
- if (t.sel[0].state == .done) {
- if (p.selectionText(t, t.sel[0]) catch null) |tx| return tx;
+ if (t.sel[sel_slot].state == .done) {
+ if (p.selectionText(t, t.sel[sel_slot]) catch null) |tx| return tx;
} else if ((t.vsel.active and t.vsel.explicit) or t.msel.active) {
if (p.currentSelText(t)) |tx| return tx;
}
@@ -1693,25 +1633,28 @@ pub const Pardes = struct {
// before Ctrl-w, so a modified key abandons the sequence instead of
// arming a second prefix on top of it
if (p.leader_on) return p.leaderKey(key);
- // Ctrl-w prefix: helix-style directional pane focus (h/j/k/l), running
- // the SAME builtins `SPC w h/j/k/l` runs. It stays despite the leader
+ // Ctrl-w prefix: helix-style directional pane focus (h/j/k/l or the
+ // arrows), running the SAME builtins `SPC w h/j/k/l` runs off the SAME
+ // table a tagline's own h/j/k/l reads. It stays despite the leader
// covering it 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.
if (p.ctrl_w_pending) {
p.ctrl_w_pending = false;
- if (is(key, 'h') or is(key, Key.left)) p.runBuiltin(.Left, p.active, "", null) else if (is(key, 'l') or is(key, Key.right)) p.runBuiltin(.Right, p.active, "", null) else if (is(key, 'k') or is(key, Key.up)) p.runBuiltin(.Up, p.active, "", null) else if (is(key, 'j') or is(key, Key.down)) p.runBuiltin(.Down, p.active, "", null);
+ for (config.window_keys) |wk| {
+ if (hit(key, &.{ wk.letter, wk.arrow })) return p.runBuiltin(wk.cmd, p.active, "", null);
+ }
return;
}
// insert mode owns Ctrl-w (delete word backward, helix); the focus
// prefix keeps normal/tty
- if (isC(key, 'w') and pane.mode != .insert) {
+ if (hit(key, config.window_prefix) and pane.mode != .insert) {
p.ctrl_w_pending = true;
return;
}
// global window ops (any mode): Alt-n new terminal below, Alt-c move
// the active terminal into a fresh column
- if (key.alt and !key.ctrl and key.cp == 'n') {
+ if (hit(key, config.new_shell_below)) {
const free = p.freeSlot() orelse return;
const nt = p.newShell(free, "") catch return;
nt.greet = true;
@@ -1722,7 +1665,7 @@ pub const Pardes = struct {
p.active = free;
return;
}
- if (key.alt and !key.ctrl and key.cp == 'c') {
+ if (hit(key, config.pane_to_new_column)) {
const f = p.layoutFindTerm(p.active).?;
if (p.ncol < MAX_COLS and p.col_n[f.col] > 1) {
p.absorbVWeight(p.active);
@@ -1737,7 +1680,7 @@ pub const Pardes = struct {
// kitty keyboard protocol); where it doesn't it arrives as a plain
// Escape and still means what Escape always means.
const tty_toggle = (key.ctrl and key.cp == p.opts.tty_toggle) or
- (key.shift and !key.ctrl and !key.alt and key.cp == Key.escape);
+ hit(key, config.tty_toggle_alt);
if (pane.isTerminal() and tty_toggle) {
if (pane.mode == .tty) {
pane.mode = .normal;
@@ -1747,8 +1690,8 @@ pub const Pardes = struct {
}
// a search input in flight (`/` or Find): Enter searches, Esc abandons;
// both restore the tag tail and hand focus back to the body.
- if (pane.search_edit and (is(key, Key.enter) or is(key, Key.escape))) {
- if (is(key, Key.enter)) p.submitSearch(p.active);
+ if (pane.search_edit and (hit(key, config.search_submit) or hit(key, config.escape))) {
+ if (hit(key, config.search_submit)) p.submitSearch(p.active);
pane.tag_tail.shrinkRetainingCapacity(@min(pane.search_at, pane.tag_tail.items.len));
exitTagEdit(pane);
pane.mode = .normal;
@@ -1762,12 +1705,12 @@ pub const Pardes = struct {
if (pane.mode == .normal) p.tagNormalKey(pane, key) else p.tagInsertKey(pane, key);
return;
}
- // normal-mode Enter/Tab: the acme chords — look at / execute (which
- // key is which is swap_enter_tab) the EXPLICIT modal selection (v/x/X,
- // terminal n/N, search n/N); implicit motion residue falls back to the
- // file-ish word under the cursor.
- if (pane.mode == .normal and (is(key, Key.enter) or is(key, Key.tab))) {
- const btn: Mouse.Button = if (is(key, Key.enter) == swap_enter_tab) .middle else .right;
+ // the acme chords in the body — look at / execute (config.look_key and
+ // exec_key, Enter and Tab by default) the EXPLICIT modal selection
+ // (v/x/X, terminal n/N, search n/N); implicit motion residue falls back
+ // to the file-ish word under the cursor.
+ if (pane.mode == .normal and (hit(key, config.look_key) or hit(key, config.exec_key))) {
+ const btn: Mouse.Button = if (hit(key, config.look_key)) config.look_button else config.exec_button;
pane.pinCursor();
const explicit = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active;
if (explicit) {
@@ -1790,14 +1733,14 @@ pub const Pardes = struct {
// refuse the OSC 52 read, so a round trip would never come back).
// Skipped while a prefix waits for its char argument (f/r/m and
// friends take `p` literally).
- if (pane.mode == .normal and pane.pending == 0 and (is(key, 'p') or is(key, 'P'))) {
- p.normalPaste(pane, is(key, 'P'));
+ if (pane.mode == .normal and pane.pending == 0 and (hit(key, config.paste_after) or hit(key, config.paste_before))) {
+ p.normalPaste(pane, hit(key, config.paste_before));
return;
}
switch (pane.mode) {
.normal => p.handleNormal(pane, key),
.insert => {
- if (key.cp == Key.escape and !key.ctrl and !key.alt) {
+ if (hit(key, config.escape)) {
pane.mode = .normal;
pane.msel.active = false;
pane.pending = 0;
@@ -1837,7 +1780,7 @@ pub const Pardes = struct {
p.leader_on = false; // only "still a prefix" below re-arms it
if (key.ctrl or key.alt or key.cp < 0x20 or key.cp > 0x7e) return;
const c: u8 = @intCast(key.cp);
- if (c == '?') return output_pane.openHelp(p, p.active, p.leader_keys[0..p.leader_n]);
+ if (c == config.leader_help) return output_pane.openHelp(p, p.active, p.leader_keys[0..p.leader_n]);
if (p.leader_n >= p.leader_keys.len) return;
p.leader_keys[p.leader_n] = c;
p.leader_n += 1;
@@ -1950,7 +1893,7 @@ pub const Pardes = struct {
fn splitRowForExtent(y: u16, h: u16, cur_y: u16) ?u16 {
if (h < 2) return null;
- const min_each: u16 = if (h >= MINH * 2) MINH else 1;
+ const min_each: u16 = if (h >= config.MINH * 2) config.MINH else 1;
const lo = y +| min_each;
const hi = y + h - min_each;
if (lo > hi) return y + h / 2;
@@ -2209,7 +2152,7 @@ pub const Pardes = struct {
/// helix `scroll` without cursor sync (Ctrl-f/b, PgUp/PgDn, zj/zk): shift
/// the view, then snap a fallen-out cursor to the near scrolloff edge, col 0
fn scrollViewMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, delta: i32) void {
- const margin: i32 = @min(scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2));
+ const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2));
pane.scrollBy(delta);
const top = pane.scroll();
const last_row: i32 = @intCast(modal.hxLineCount(text) - 1);
@@ -2230,16 +2173,16 @@ pub const Pardes = struct {
}
/// gt/gc/gb: view-relative rows, col 0, scrolloff clamped (helix goto_window)
- fn gotoWindow(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, which: u21, cnt: usize) void {
- const margin: i32 = @min(scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2));
+ fn gotoWindow(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, which: enum { top, center, bottom }, cnt: usize) void {
+ const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2));
const top = pane.scroll();
const last_row: i32 = @intCast(modal.hxLineCount(text) - 1);
const last_vis: i32 = @min(@as(i32, pane.rows) - 1, last_row - top);
const n: i32 = @intCast(cnt - 1);
var vline: i32 = switch (which) {
- 't' => top + margin + n,
- 'c' => top + @divTrunc(last_vis, 2),
- else => top + last_vis - (margin + n),
+ .top => top + margin + n,
+ .center => top + @divTrunc(last_vis, 2),
+ .bottom => top + last_vis - (margin + n),
};
vline = @max(vline, top + margin);
vline = @min(vline, top + last_vis - margin);
@@ -2312,7 +2255,7 @@ pub const Pardes = struct {
// replace it anyway. On an OUTPUT BUFFER (+Search, +Help) there is no
// file behind the pane and nothing worth keeping, so "never mind"
// means dismiss it: Esc IS Del, the same builtin its tag offers.
- if (is(key, Key.escape)) {
+ if (hit(key, config.escape)) {
if (pane.file) |f| if (f.output) return p.runBuiltin(.Del, p.active, "", null);
pane.select = false;
pane.pending = 0;
@@ -2342,20 +2285,20 @@ pub const Pardes = struct {
};
// prefix continuations
- if (pane.pending == 'g') {
+ if (pane.pending == config.goto_prefix) {
pane.pending = 0;
- if (is(key, 'g')) {
+ if (hit(key, config.goto_file_start)) {
const line = if (has_count) @min(cnt - 1, max_line) else 0;
return pointMove(pane, pl, text, range, modal.lineStartOffset(text, line));
}
- if (is(key, 'e')) return pointMove(pane, pl, text, range, modal.lineStartOffset(text, max_line));
- if (is(key, 'h')) return pointMove(pane, pl, text, range, modal.lineStartOffset(text, modal.hxLineOf(text, cur)));
- if (is(key, 'l')) {
+ if (hit(key, config.goto_last_line)) return pointMove(pane, pl, text, range, modal.lineStartOffset(text, max_line));
+ if (hit(key, config.goto_line_start)) return pointMove(pane, pl, text, range, modal.lineStartOffset(text, modal.hxLineOf(text, cur)));
+ if (hit(key, config.goto_line_end)) {
const line = modal.hxLineOf(text, cur);
const ls = modal.lineStartOffset(text, line);
return pointMove(pane, pl, text, range, @max(ls, modal.prevGrapheme(text, modal.hxLineEndIdx(text, line))));
}
- if (is(key, 's')) {
+ if (hit(key, config.goto_first_nonws)) {
const line = modal.hxLineOf(text, cur);
const ls = modal.lineStartOffset(text, line);
const slice = text[ls..modal.hxLineEndIdx(text, line)];
@@ -2363,70 +2306,77 @@ pub const Pardes = struct {
if (nw == slice.len) return; // all-whitespace line: no move (helix)
return pointMove(pane, pl, text, range, ls + nw);
}
- if (is(key, 'j')) return verticalMove(pane, pl, text, range, true, cnt);
- if (is(key, 'k')) return verticalMove(pane, pl, text, range, false, cnt);
- if (is(key, '|')) {
+ if (hit(key, config.goto_line_down)) return verticalMove(pane, pl, text, range, true, cnt);
+ if (hit(key, config.goto_line_up)) return verticalMove(pane, pl, text, range, false, cnt);
+ if (hit(key, config.goto_column)) {
const line = modal.hxLineOf(text, cur);
const ls = modal.lineStartOffset(text, line);
return pointMove(pane, pl, text, range, @min(ls + (cnt - 1), modal.hxLineEndIdx(text, line)));
}
- if (is(key, 't') or is(key, 'c') or is(key, 'b')) return gotoWindow(pane, pl, text, range, key.cp, cnt);
+ if (hit(key, config.goto_view_top)) return gotoWindow(pane, pl, text, range, .top, cnt);
+ if (hit(key, config.goto_view_center)) return gotoWindow(pane, pl, text, range, .center, cnt);
+ if (hit(key, config.goto_view_bottom)) return gotoWindow(pane, pl, text, range, .bottom, cnt);
// helix's five LSP gotos, all under `g` and nowhere else. They ask
// and return; the answer arrives later as an lsp_resp Event and
// either jumps or opens a list (lspResponse).
- if (is(key, 'd')) return p.lspRequest(p.active, .definition, "");
- if (key.cp == 'D' and !key.ctrl and !key.alt) return p.lspRequest(p.active, .declaration, "");
- if (is(key, 'y')) return p.lspRequest(p.active, .type_definition, "");
- if (is(key, 'i')) return p.lspRequest(p.active, .implementation, "");
- if (is(key, 'r')) return p.lspRequest(p.active, .references, "");
+ if (hit(key, config.goto_definition)) return p.lspRequest(p.active, .definition, "");
+ if (hit(key, config.goto_declaration)) return p.lspRequest(p.active, .declaration, "");
+ if (hit(key, config.goto_type_definition)) return p.lspRequest(p.active, .type_definition, "");
+ if (hit(key, config.goto_implementation)) return p.lspRequest(p.active, .implementation, "");
+ if (hit(key, config.goto_references)) return p.lspRequest(p.active, .references, "");
return;
}
- if (pane.pending == 'z') {
+ if (pane.pending == config.view_prefix) {
pane.pending = 0;
- if (is(key, 't')) {
+ if (hit(key, config.view_top)) {
pane.scrollBy(pane.cur_row - pane.scroll());
pane.ensureCursorVisible();
return;
}
- if (is(key, 'z') or is(key, 'c')) {
+ if (hit(key, config.view_center)) {
pane.scrollBy(pane.cur_row - (pane.scroll() + @divTrunc(@as(i32, pane.rows), 2) - 1));
pane.ensureCursorVisible();
return;
}
- if (is(key, 'b')) {
+ if (hit(key, config.view_bottom)) {
pane.scrollBy(pane.cur_row - (pane.scroll() + @as(i32, pane.rows) - 1));
pane.ensureCursorVisible();
return;
}
- if (is(key, 'j') or is(key, Key.down)) return scrollViewMove(pane, pl, text, range, 1);
- if (is(key, 'k') or is(key, Key.up)) return scrollViewMove(pane, pl, text, range, -1);
- if (isC(key, 'd')) return halfPageMove(pane, pl, text, range, true);
- if (isC(key, 'u')) return halfPageMove(pane, pl, text, range, false);
- if (isC(key, 'f') or is(key, Key.page_down)) return scrollViewMove(pane, pl, text, range, @as(i32, pane.rows));
- if (isC(key, 'b') or is(key, Key.page_up)) return scrollViewMove(pane, pl, text, range, -@as(i32, pane.rows));
+ if (hit(key, config.view_scroll_down)) return scrollViewMove(pane, pl, text, range, 1);
+ if (hit(key, config.view_scroll_up)) return scrollViewMove(pane, pl, text, range, -1);
+ if (hit(key, config.half_page_down)) return halfPageMove(pane, pl, text, range, true);
+ if (hit(key, config.half_page_up)) return halfPageMove(pane, pl, text, range, false);
+ if (hit(key, config.page_down)) return scrollViewMove(pane, pl, text, range, @as(i32, pane.rows));
+ if (hit(key, config.page_up)) return scrollViewMove(pane, pl, text, range, -@as(i32, pane.rows));
return;
}
- if (pane.pending == 'f' or pane.pending == 'F' or pane.pending == 't' or pane.pending == 'T') {
+ if (pane.pending == config.find_char_fwd or pane.pending == config.find_char_back or
+ pane.pending == config.till_char_fwd or pane.pending == config.till_char_back)
+ {
const op: u8 = @intCast(pane.pending);
pane.pending = 0;
if (key.ctrl or key.alt or key.cp >= 0xF0000) return; // not a char
pane.find_op = op;
pane.find_ch = key.cp;
- return findMove(pane, pl, text, range, key.cp, op == 'f' or op == 't', op == 't' or op == 'T', cnt);
+ return findMove(pane, pl, text, range, key.cp, op == config.find_char_fwd or op == config.till_char_fwd, op == config.till_char_fwd or op == config.till_char_back, cnt);
}
- if (pane.pending == 'r') {
+ if (pane.pending == config.replace_prefix) {
pane.pending = 0;
if (key.ctrl or key.alt or key.cp >= 0xF0000 or key.cp > 0x7f) return;
return p.normalReplaceChar(pane, @intCast(key.cp));
}
- if (pane.pending == 'm') {
+ if (pane.pending == config.match_prefix) {
if (pane.pending2 == 0) {
- if (is(key, 'm')) {
+ if (hit(key, config.match_bracket)) {
pane.pending = 0;
const mc = modal.matchBracket(lines, modal.hxPos(text, cur)) orelse return;
return pointMove(pane, pl, text, range, modal.hxOff(text, mc));
}
- if (is(key, 'i') or is(key, 'a') or is(key, 's') or is(key, 'r') or is(key, 'd')) {
+ if (isPrefix(key, config.match_inside) or isPrefix(key, config.match_around) or
+ isPrefix(key, config.surround_add) or isPrefix(key, config.surround_replace) or
+ isPrefix(key, config.surround_delete))
+ {
pane.pending2 = key.cp; // pending stays 'm': the char arg is next
return;
}
@@ -2440,7 +2390,7 @@ pub const Pardes = struct {
pane.pending_ch = 0;
return;
}
- if (sub == 'r' and pane.pending_ch == 0) {
+ if (sub == config.surround_replace and pane.pending_ch == 0) {
pane.pending_ch = key.cp; // mr<from><to>: hold <from>, wait for <to>
return;
}
@@ -2449,30 +2399,30 @@ pub const Pardes = struct {
const from = pane.pending_ch;
pane.pending_ch = 0;
switch (sub) {
- 'i', 'a' => return p.textobjectSelect(pane, pl, key.cp, sub == 'a'),
- 's' => return p.surroundAdd(pane, key.cp),
- 'd' => return p.surroundDelete(pane, pl, key.cp),
- 'r' => return p.surroundReplace(pane, pl, from, key.cp),
+ config.match_inside, config.match_around => return p.textobjectSelect(pane, pl, key.cp, sub == config.match_around),
+ config.surround_add => return p.surroundAdd(pane, key.cp),
+ config.surround_delete => return p.surroundDelete(pane, pl, key.cp),
+ config.surround_replace => return p.surroundReplace(pane, pl, from, key.cp),
else => return,
}
}
- if (pane.pending == ']' or pane.pending == '[') {
- const fwd = pane.pending == ']';
+ if (pane.pending == config.next_prefix or pane.pending == config.prev_prefix) {
+ const fwd = pane.pending == config.next_prefix;
pane.pending = 0;
- if (is(key, 'p')) {
+ if (hit(key, config.goto_paragraph)) {
const r2 = modal.hxParaMove(text, range, cnt, fwd, pane.select);
return setPaneRange(pane, pl, text, r2, false);
}
- if (is(key, ' ')) return p.addNewline(pane, fwd, cnt);
+ if (hit(key, config.add_newline)) return p.addNewline(pane, fwd, cnt);
// ]d / [d: step the diagnostics list, asking for one if it is not
// up yet. The list is an ordinary results buffer, so "step" is the
// n/N stepper — `d` differs from `n` only in what it guarantees is
// showing. ]D / [D jump to the last/first, helix's pair.
- if (is(key, 'd')) {
+ if (hit(key, config.goto_diagnostic)) {
if (pane.search_kind == .diagnostics and p.searchStep(p.active, if (fwd) 1 else -1)) return;
return p.lspRequest(p.active, .diagnostics, "");
}
- if (key.cp == 'D' and !key.ctrl and !key.alt) {
+ if (hit(key, config.goto_diagnostic_end)) {
if (pane.search_kind != .diagnostics) return p.lspRequest(p.active, .diagnostics, "");
if (!fwd) { // [D = first
pane.search_row = null;
@@ -2488,41 +2438,46 @@ pub const Pardes = struct {
return;
}
// prefix setters (the count survives into the continuation)
- if (is(key, 'g') or is(key, 'z') or is(key, 'f') or is(key, 'F') or is(key, 't') or is(key, 'T') or is(key, 'r') or is(key, ']') or is(key, '[')) {
+ if (isPrefix(key, config.goto_prefix) or isPrefix(key, config.view_prefix) or
+ isPrefix(key, config.find_char_fwd) or isPrefix(key, config.find_char_back) or
+ isPrefix(key, config.till_char_fwd) or isPrefix(key, config.till_char_back) or
+ isPrefix(key, config.replace_prefix) or isPrefix(key, config.next_prefix) or
+ isPrefix(key, config.prev_prefix))
+ {
pane.pending = key.cp;
pane.count = saved_count;
return;
}
- if (is(key, 'm')) {
- pane.pending = 'm';
+ if (isPrefix(key, config.match_prefix)) {
+ pane.pending = config.match_prefix;
pane.pending2 = 0;
pane.pending_ch = 0;
return;
}
// movement
- if (is(key, 'h') or is(key, Key.left)) {
+ if (hit(key, config.move_left)) {
var t = cur;
for (0..cnt) |_| t = modal.prevGrapheme(text, t);
return pointMove(pane, pl, text, range, t);
}
- if (is(key, 'l') or is(key, Key.right)) {
+ if (hit(key, config.move_right)) {
var t = cur;
for (0..cnt) |_| t = modal.nextGrapheme(text, t);
return pointMove(pane, pl, text, range, t);
}
- if (is(key, 'j') or is(key, Key.down)) return verticalMove(pane, pl, text, range, true, cnt);
- if (is(key, 'k') or is(key, Key.up)) return verticalMove(pane, pl, text, range, false, cnt);
- if (is(key, 'w')) return wordMove(pane, pl, text, range, cnt, .next_word_start);
- if (is(key, 'b')) return wordMove(pane, pl, text, range, cnt, .prev_word_start);
- if (is(key, 'e')) return wordMove(pane, pl, text, range, cnt, .next_word_end);
- if (is(key, 'W')) return wordMove(pane, pl, text, range, cnt, .next_long_word_start);
- if (is(key, 'B')) return wordMove(pane, pl, text, range, cnt, .prev_long_word_start);
- if (is(key, 'E')) return wordMove(pane, pl, text, range, cnt, .next_long_word_end);
- if (isA(key, '.')) {
+ if (hit(key, config.move_down)) return verticalMove(pane, pl, text, range, true, cnt);
+ if (hit(key, config.move_up)) return verticalMove(pane, pl, text, range, false, cnt);
+ if (hit(key, config.next_word_start)) return wordMove(pane, pl, text, range, cnt, .next_word_start);
+ if (hit(key, config.prev_word_start)) return wordMove(pane, pl, text, range, cnt, .prev_word_start);
+ if (hit(key, config.next_word_end)) return wordMove(pane, pl, text, range, cnt, .next_word_end);
+ if (hit(key, config.next_long_word_start)) return wordMove(pane, pl, text, range, cnt, .next_long_word_start);
+ if (hit(key, config.prev_long_word_start)) return wordMove(pane, pl, text, range, cnt, .prev_long_word_start);
+ if (hit(key, config.next_long_word_end)) return wordMove(pane, pl, text, range, cnt, .next_long_word_end);
+ if (hit(key, config.repeat_find)) {
// repeat the last f/F/t/T; a count repeats the motion N times
if (pane.find_op == 0 or pane.find_ch > 0x7f) return;
- const fwd = pane.find_op == 'f' or pane.find_op == 't';
- const till = pane.find_op == 't' or pane.find_op == 'T';
+ const fwd = pane.find_op == config.find_char_fwd or pane.find_op == config.till_char_fwd;
+ const till = pane.find_op == config.till_char_fwd or pane.find_op == config.till_char_back;
var r = range;
var moved = false;
for (0..cnt) |_| {
@@ -2537,14 +2492,14 @@ pub const Pardes = struct {
if (!moved) return;
return setPaneRange(pane, pl, text, r, false);
}
- if (is(key, '0') or is(key, Key.home))
+ if (hit(key, config.line_start))
return pointMove(pane, pl, text, range, modal.lineStartOffset(text, modal.hxLineOf(text, cur)));
- if (is(key, '$') or is(key, Key.end)) {
+ if (hit(key, config.line_end)) {
const line = modal.hxLineOf(text, cur);
const ls = modal.lineStartOffset(text, line);
return pointMove(pane, pl, text, range, @max(ls, modal.prevGrapheme(text, modal.hxLineEndIdx(text, line))));
}
- if (is(key, '^')) {
+ if (hit(key, config.line_first_nonws)) {
const line = modal.hxLineOf(text, cur);
const ls = modal.lineStartOffset(text, line);
const slice = text[ls..modal.hxLineEndIdx(text, line)];
@@ -2552,26 +2507,26 @@ pub const Pardes = struct {
if (nw == slice.len) return;
return pointMove(pane, pl, text, range, ls + nw);
}
- if (is(key, 'G')) {
+ if (hit(key, config.goto_line)) {
// helix goto_line: only acts WITH a count (bare G is a no-op;
// ge is goto-last-line)
if (!has_count) return;
const line = @min(cnt - 1, max_line);
return pointMove(pane, pl, text, range, modal.lineStartOffset(text, line));
}
- if (isC(key, 'd')) return halfPageMove(pane, pl, text, range, true);
- if (isC(key, 'u')) return halfPageMove(pane, pl, text, range, false);
- if (isC(key, 'f') or is(key, Key.page_down)) return scrollViewMove(pane, pl, text, range, @as(i32, pane.rows));
- if (isC(key, 'b') or is(key, Key.page_up)) return scrollViewMove(pane, pl, text, range, -@as(i32, pane.rows));
+ if (hit(key, config.half_page_down)) return halfPageMove(pane, pl, text, range, true);
+ if (hit(key, config.half_page_up)) return halfPageMove(pane, pl, text, range, false);
+ if (hit(key, config.page_down)) return scrollViewMove(pane, pl, text, range, @as(i32, pane.rows));
+ if (hit(key, config.page_up)) return scrollViewMove(pane, pl, text, range, -@as(i32, pane.rows));
// insert entry
- if (is(key, 'i')) return p.enterInsert(pane, .at, cnt);
- if (is(key, 'a')) return p.enterInsert(pane, .append, cnt);
- if (is(key, 'I')) return p.enterInsert(pane, .line_start, cnt);
- if (is(key, 'A')) return p.enterInsert(pane, .line_end, cnt);
- if (is(key, 'o')) return p.enterInsert(pane, .open_below, cnt);
- if (is(key, 'O')) return p.enterInsert(pane, .open_above, cnt);
+ if (hit(key, config.insert)) return p.enterInsert(pane, .at, cnt);
+ if (hit(key, config.append)) return p.enterInsert(pane, .append, cnt);
+ if (hit(key, config.insert_line_start)) return p.enterInsert(pane, .line_start, cnt);
+ if (hit(key, config.insert_line_end)) return p.enterInsert(pane, .line_end, cnt);
+ if (hit(key, config.open_below)) return p.enterInsert(pane, .open_below, cnt);
+ if (hit(key, config.open_above)) return p.enterInsert(pane, .open_above, cnt);
// selections
- if (is(key, 'v')) {
+ if (hit(key, config.select_mode)) {
if (pane.select) {
pane.select = false; // exit select mode, selection kept (helix)
} else {
@@ -2589,41 +2544,41 @@ pub const Pardes = struct {
pane.pending = 0;
return;
}
- if (is(key, 'x')) return lineSelect(pane, pl, text, range, cnt);
- if (is(key, 'X')) return lineBoundsSelect(pane, pl, text, range);
- if (isA(key, 'x')) return shrinkSelToLineBounds(pane, pl, text, range);
- if (is(key, ';')) return setPaneRange(pane, pl, text, .{ .anchor = cur, .head = cur }, false);
- if (isA(key, ';')) {
+ if (hit(key, config.select_line)) return lineSelect(pane, pl, text, range, cnt);
+ if (hit(key, config.select_line_bounds)) return lineBoundsSelect(pane, pl, text, range);
+ if (hit(key, config.shrink_to_line_bounds)) return shrinkSelToLineBounds(pane, pl, text, range);
+ if (hit(key, config.collapse_selection)) return setPaneRange(pane, pl, text, .{ .anchor = cur, .head = cur }, false);
+ if (hit(key, config.flip_selection)) {
const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active;
return setPaneRange(pane, pl, text, .{ .anchor = range.head, .head = range.anchor }, expl);
}
- if (is(key, '%')) return setPaneRange(pane, pl, text, .{ .anchor = 0, .head = text.len }, false);
+ if (hit(key, config.select_all)) return setPaneRange(pane, pl, text, .{ .anchor = 0, .head = text.len }, false);
// edits
- if (is(key, 'd')) return p.normalDelete(pane, true);
- if (isA(key, 'd')) return p.normalDelete(pane, false);
- if (is(key, 'c')) return p.normalChange(pane);
- if (is(key, 'y')) return p.normalYank(pane);
- if (is(key, 'R')) return p.normalReplaceYank(pane);
- if (is(key, '~')) return p.normalCase(pane, .toggle);
- if (is(key, '`')) return p.normalCase(pane, .lower);
- if (isA(key, '`')) return p.normalCase(pane, .upper);
- if (is(key, 'J')) return p.normalJoin(pane);
- if (is(key, '>')) return p.normalIndent(pane, cnt, true);
- if (is(key, '<')) return p.normalIndent(pane, cnt, false);
+ if (hit(key, config.delete)) return p.normalDelete(pane, true);
+ if (hit(key, config.delete_noyank)) return p.normalDelete(pane, false);
+ if (hit(key, config.change)) return p.normalChange(pane);
+ if (hit(key, config.yank)) return p.normalYank(pane);
+ if (hit(key, config.replace_with_yank)) return p.normalReplaceYank(pane);
+ if (hit(key, config.switch_case)) return p.normalCase(pane, .toggle);
+ if (hit(key, config.to_lowercase)) return p.normalCase(pane, .lower);
+ if (hit(key, config.to_uppercase)) return p.normalCase(pane, .upper);
+ if (hit(key, config.join_lines)) return p.normalJoin(pane);
+ if (hit(key, config.indent)) return p.normalIndent(pane, cnt, true);
+ if (hit(key, config.unindent)) return p.normalIndent(pane, cnt, false);
// helix's `=` (format_selections) — its neighbour on the keyboard and
// in the keymap. Unbound in pardes until now.
- if (is(key, '=')) return p.lspRequest(p.active, .format, "");
- if (isC(key, 'a')) return p.normalAdjustNumber(pane, @intCast(cnt));
- if (isC(key, 'x')) return p.normalAdjustNumber(pane, -@as(i64, @intCast(cnt)));
- if (is(key, 'u')) return p.doUndo(pane);
- if (is(key, 'U')) return p.doRedo(pane);
+ if (hit(key, config.format)) return p.lspRequest(p.active, .format, "");
+ if (hit(key, config.increment)) return p.normalAdjustNumber(pane, @intCast(cnt));
+ if (hit(key, config.decrement)) return p.normalAdjustNumber(pane, -@as(i64, @intCast(cnt)));
+ if (hit(key, config.undo)) return p.doUndo(pane);
+ if (hit(key, config.redo)) return p.doRedo(pane);
// SPC — the leader: a key path from here runs a BUILTIN with no
// arguments, the same builtins the topbar and the tags hold (`?` at
// any depth lists what the prefix can still reach). Helix spends Space
// on pickers, which pardes does not have; acme's builtins are exactly
// what a leader is for. Body normal mode only — a tag is always
// insert, and a tty pane's keys belong to the program.
- if (is(key, ' ')) {
+ if (hit(key, config.leader)) {
p.leader_on = true;
p.leader_n = 0;
return;
@@ -2633,7 +2588,7 @@ pub const Pardes = struct {
// words and the execute chord runs the one under the cursor, then
// hands the body back (`:w<Tab>` = Save). Always the start, not the
// remembered tag column, so `:w` is the same keystrokes every time.
- if (is(key, ':')) {
+ if (hit(key, config.command_line)) {
p.enterTagEdit(pane, -1);
if (pane.tag_edit) pane.mode = .normal;
return;
@@ -2641,9 +2596,9 @@ pub const Pardes = struct {
// search: `/` 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.
- if (is(key, '/')) return p.startSearch(pane, search_marker);
- if (is(key, 'n') or is(key, 'N')) {
- const delta: i32 = if (is(key, 'n')) 1 else -1;
+ if (hit(key, config.search)) return p.startSearch(pane, config.search_marker);
+ if (hit(key, config.search_next) or hit(key, config.search_prev)) {
+ const delta: i32 = if (hit(key, config.search_next)) 1 else -1;
if (p.searchStep(p.active, delta)) return;
if (pane.isTerminal()) return p.lookStep(pane, pl, delta);
return;
@@ -2652,17 +2607,6 @@ pub const Pardes = struct {
// ---- search (`/`, Find, n/N): results into an output buffer, look the rows ----
- /// 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 for two commands that
- /// differ in nothing but where the rows come from.
- const search_marker = " /";
- pub const find_marker = " Find /";
- pub const grep_marker = " Grep /";
- // the two language queries that need a word typed: same tag input, same
- // Enter, and the marker is still what says which one is armed
- pub const rename_marker = " Rename /";
- pub const symbol_marker = " WsSymbols /";
const Search = enum { text, find, grep };
/// `/` (and the Find builtin) on any pane: type the pattern into the tag
@@ -2695,11 +2639,11 @@ pub const Pardes = struct {
const tail = pane.tag_tail.items;
const armed = tail[@min(pane.search_at, tail.len)..];
const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return;
- if (std.mem.startsWith(u8, armed, rename_marker)) return p.lspRequest(id, .rename, armed[slash + 1 ..]);
- if (std.mem.startsWith(u8, armed, symbol_marker)) return p.lspRequest(id, .workspace_symbols, armed[slash + 1 ..]);
- const kind: Search = if (std.mem.startsWith(u8, armed, find_marker))
+ if (std.mem.startsWith(u8, armed, config.rename_marker)) return p.lspRequest(id, .rename, armed[slash + 1 ..]);
+ if (std.mem.startsWith(u8, armed, config.symbol_marker)) return p.lspRequest(id, .workspace_symbols, armed[slash + 1 ..]);
+ const kind: Search = if (std.mem.startsWith(u8, armed, config.find_marker))
.find
- else if (std.mem.startsWith(u8, armed, grep_marker))
+ else if (std.mem.startsWith(u8, armed, config.grep_marker))
.grep
else
.text;
@@ -2769,7 +2713,7 @@ pub const Pardes = struct {
const loc: []const u8 = if (has_path)
std.fs.path.basename(pane.file.?.path)
else
- std.fmt.bufPrint(&idbuf, "@p{d}", .{id}) catch return;
+ std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{id}) catch return;
for (pl.lines, 0..) |ln, i| {
const at = std.ascii.indexOfIgnoreCase(ln, pat) orelse continue;
const row = std.fmt.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{
@@ -2788,7 +2732,7 @@ pub const Pardes = struct {
p.gpa.free(content);
return;
};
- const np = output_pane.open(p, free, dir, "+Search", content) catch {
+ const np = output_pane.open(p, free, dir, config.search_buffer, content) catch {
p.gpa.free(content);
return;
};
@@ -2810,7 +2754,7 @@ pub const Pardes = struct {
const rp = p.panes[rid] orelse return false;
const rf = if (rp.file) |*f| f else return false;
// a freed slot can be reused by an unrelated pane: the name says ours
- if (!rf.output or !std.mem.endsWith(u8, rf.path, "/+Search")) return false;
+ if (!rf.output or !std.mem.endsWith(u8, rf.path, "/" ++ config.search_buffer)) return false;
// fresh results: n starts at the first row, N has nothing behind it
const nrows: i64 = @intCast(std.mem.count(u8, rf.content, "\n"));
const step: i64 = if (pane.search_row) |c| @as(i64, @intCast(c)) + delta else if (delta > 0) 0 else -1;
@@ -2826,8 +2770,8 @@ pub const Pardes = struct {
rp.ensureCursorVisible();
const ln = modal.lineSlice(rf.content, @intCast(step));
var hi: usize = 0;
- while (hi < ln.len and look.isFileChar(ln[hi])) hi += 1;
- p.actOnSelection(.right, rid, ln[0..hi], null);
+ while (hi < ln.len and config.isFileChar(ln[hi])) hi += 1;
+ p.actOnSelection(config.look_button, rid, ln[0..hi], null);
// the look may focus what it opened — a Find row opens a whole new
// file pane every time — so focus comes back to the pane that owns the
// search and the next n keeps stepping. A `/` row looks at the
@@ -2886,13 +2830,13 @@ pub const Pardes = struct {
if (w.kind.jumpsWhenSingle() and nrows == 1) {
const ln = std.mem.trimEnd(u8, rows, "\n");
var hi: usize = 0;
- while (hi < ln.len and look.isFileChar(ln[hi])) hi += 1;
+ while (hi < ln.len and config.isFileChar(ln[hi])) hi += 1;
// exactly what a `/` result row does when n steps onto it: look the
// `path:LINE:COL` token from the pane that asked, so placement,
// dedup-onto-an-open-pane and centering are the ONE look path.
// (helix would also push its jumplist here; pardes has none, so
// there is nothing to push — do not read this as one.)
- return p.actOnSelection(.right, w.pane, ln[0..hi], null);
+ return p.actOnSelection(config.look_button, w.pane, ln[0..hi], null);
}
const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice();
@@ -2909,7 +2853,7 @@ pub const Pardes = struct {
p.active = w.pane;
// prose is not a list of locations: n/N over a hover blurb would step
// to nowhere, so only location buffers arm the stepper.
- if (std.mem.eql(u8, w.kind.bufferName(), "+Search")) {
+ if (std.mem.eql(u8, w.kind.bufferName(), config.search_buffer)) {
pane.search_pane = free;
pane.search_row = null;
pane.search_kind = w.kind;
@@ -3149,9 +3093,9 @@ pub const Pardes = struct {
/// mean exactly the same thing in a shell pane as in a document.
fn handleInsert(p: *Pardes, pane: *Pane, key: Key) void {
// helix aliases: normalize to the base key and re-dispatch
- if (isC(key, 'h')) return p.handleInsert(pane, .{ .cp = Key.backspace });
- if (isC(key, 'j')) return p.handleInsert(pane, .{ .cp = Key.enter });
- if (isC(key, 'd')) return p.handleInsert(pane, .{ .cp = Key.delete });
+ if (hit(key, config.insert_backspace_alias)) return p.handleInsert(pane, .{ .cp = Key.backspace });
+ if (hit(key, config.insert_enter_alias)) return p.handleInsert(pane, .{ .cp = Key.enter });
+ if (hit(key, config.insert_delete_alias)) return p.handleInsert(pane, .{ .cp = Key.delete });
// a selection carried into insert (i/a) survives only until the next
// key: helix maps it through every edit, pardes drops it instead —
// its only pardes use (the acme chords) needs explicit selections
@@ -3220,7 +3164,7 @@ pub const Pardes = struct {
return;
}
// helix insert-mode kills (word/line; deleteSpan is exclusive)
- if (isC(key, 'w') or (key.alt and !key.ctrl and key.cp == Key.backspace)) {
+ if (hit(key, config.delete_word_backward)) {
// helix delete_word_backward: to the previous word start —
// crossing the newline at col 0, which takes the word before it
// too, so on the buffer's first line a terminal grows up one row
@@ -3242,7 +3186,7 @@ pub const Pardes = struct {
pane.ensureCursorVisible();
return;
}
- if (key.alt and !key.ctrl and (key.cp == 'd' or key.cp == Key.delete)) {
+ if (hit(key, config.delete_word_forward)) {
// helix delete_word_forward: to the next word END (trailing
// whitespace survives), crossing newlines at line ends — at the
// buffer's last line a terminal grows down one row to allow it
@@ -3260,7 +3204,7 @@ pub const Pardes = struct {
pane.cur_pinned = true;
return;
}
- if (isC(key, 'u')) {
+ if (hit(key, config.kill_to_line_start)) {
const new = modal.deleteSpan(p.gpa, text, .{ .row = c.row, .col = 0 }, c) catch return;
p.setEditText(pane, new);
pane.cur_col = 0;
@@ -3268,7 +3212,7 @@ pub const Pardes = struct {
pane.ensureCursorVisible();
return;
}
- if (isC(key, 'k')) {
+ if (hit(key, config.kill_to_line_end)) {
const llen = modal.lineSlice(text, c.row).len;
const new = modal.deleteSpan(p.gpa, text, c, .{ .row = c.row, .col = llen }) catch return;
p.setEditText(pane, new);
@@ -4194,10 +4138,10 @@ pub const Pardes = struct {
if (mcol >= r.x and mcol < r.x + r.w and mrow >= r.y and mrow < r.y + r.h) break i;
} else null;
- // any press that is NOT the middle+left chord abandons a pending
+ // any press that is NOT the execute+select chord abandons a pending
// chord argument
if (m.kind == .press and
- !(m.button == .left and p.drag == .select and p.drag.select.button == .middle))
+ !(m.button == config.select_button and p.drag == .select and p.drag.select.button == config.exec_button))
{
if (p.chord_arg) |a| {
p.gpa.free(a);
@@ -4214,7 +4158,7 @@ pub const Pardes = struct {
.wheel_up, .wheel_down => {
if (m.kind != .press) return;
const id = hovered orelse return;
- p.panes[id].?.scrollBy(if (m.button == .wheel_up) -1 else 1);
+ p.panes[id].?.scrollBy(if (m.button == .wheel_up) -config.wheel_rows else config.wheel_rows);
},
.wheel_left, .wheel_right => {
if (m.kind != .press) return;
@@ -4223,35 +4167,35 @@ pub const Pardes = struct {
// ponytail: no right clamp — overscroll shows blank and the
// next cursor move or left wheel pulls it back
if (pane.file != null)
- pane.hscroll = @max(0, pane.hscroll + @as(i32, if (m.button == .wheel_right) 4 else -4));
+ pane.hscroll = @max(0, pane.hscroll + (if (m.button == .wheel_right) config.wheel_cols else -config.wheel_cols));
},
- .left => switch (m.kind) {
+ config.select_button => switch (m.kind) {
.press => {
- // middle+left chord: a left press during a middle select-
- // drag captures a selection (heldSelection) as the
- // execute's argument; the middle drag keeps running to its
- // release, which consumes it.
- if (p.drag == .select and p.drag.select.button == .middle) {
+ // acme 2-1: a select press during an EXECUTE drag captures
+ // a selection (heldSelection) as the execute's argument;
+ // the execute drag keeps running to its release, which
+ // consumes it.
+ if (p.drag == .select and p.drag.select.button == config.exec_button) {
if (p.heldSelection(p.drag.select.id)) |tx| {
if (p.chord_arg) |old| p.gpa.free(old);
p.chord_arg = p.gpa.dupe(u8, tx) catch null;
}
return;
}
- // acme 3-1: a left tap during a right (look) drag cancels
- // the look; the rest of the gesture is inert
- if (p.drag == .select and p.drag.select.button == .right) {
- if (p.panes[p.drag.select.id]) |t| t.sel[@intFromEnum(Mouse.Button.right)].state = .none;
+ // acme 3-1: a select tap during a LOOK drag cancels the
+ // look; the rest of the gesture is inert
+ if (p.drag == .select and p.drag.select.button == config.look_button) {
+ if (p.panes[p.drag.select.id]) |t| t.sel[@intFromEnum(config.look_button)].state = .none;
p.drag = .none;
return;
}
- if (mrow < TOPBAR_H) return; // topbar: left click deliberately inert
+ if (mrow < TOPBAR_H) return; // topbar: a select click is deliberately inert
// the layout box (gutter cells of the tag row) wins over
// the resize handles: a tag-only pane's single row IS its
// pair's h-handle, and the box must stay grabbable to move
if (hovered) |mid| {
const mr = p.rects[mid];
- if (mcol < mr.x + GUTTER and mrow < mr.y + BOX_H) {
+ if (mcol < mr.x + config.GUTTER and mrow < mr.y + BOX_H) {
p.drag = .{ .move = .{ .id = mid, .cur_x = mcol, .cur_y = mrow } };
return;
}
@@ -4277,7 +4221,7 @@ pub const Pardes = struct {
const id = hovered orelse return;
const r = p.rects[id];
const pane = p.panes[id] orelse return;
- if (mcol < r.x + GUTTER) {
+ if (mcol < r.x + config.GUTTER) {
// gutter scrollbar: a left click scrolls UP to that row
p.active = id;
pane.scrollBy(-(@as(i32, mrow) - @as(i32, r.y + BOX_H)));
@@ -4287,35 +4231,35 @@ pub const Pardes = struct {
// agree, no clamp into the tail) and anchor a sweep, so
// dragging selects any span of it, path included
p.active = id;
- p.enterTagEdit(pane, @as(i32, mcol) - @as(i32, r.x + GUTTER));
+ p.enterTagEdit(pane, @as(i32, mcol) - @as(i32, r.x + config.GUTTER));
pane.tag_anchor = pane.tag_col;
p.drag = .{ .tag = .{ .id = id } };
} else {
p.active = id;
exitTagEdit(pane); // clicking the body leaves tag editing
- const sc: i32 = @as(i32, mcol) - @as(i32, r.x + GUTTER);
+ const sc: i32 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER);
const v: i32 = @as(i32, mrow) - @as(i32, r.y);
- pane.sel[0] = .{ .state = .dragging, .c0 = sc, .c1 = sc, .r0 = v, .r1 = v };
+ pane.sel[sel_slot] = .{ .state = .dragging, .c0 = sc, .c1 = sc, .r0 = v, .r1 = v };
// Ctrl rides on the drag rather than firing here: the
// click does not place the modal cursor until RELEASE
// (dragRelease), and a goto asked at press time would
// answer about wherever the cursor happened to be.
- p.drag = .{ .select = .{ .id = id, .button = .left, .ctrl = m.ctrl } };
+ p.drag = .{ .select = .{ .id = id, .button = config.select_button, .ctrl = m.ctrl } };
}
},
.drag => p.dragUpdate(mcol, mrow),
- .release => p.dragRelease(.left),
+ .release => p.dragRelease(config.select_button),
.motion => {},
},
- .middle, .right => switch (m.kind) {
+ config.exec_button, config.look_button => switch (m.kind) {
.press => {
- // acme 1-2 / 1-3: middle (Cut) or right (Paste) tapped
- // while the left button holds a selection drag
- if (p.drag == .select and p.drag.select.button == .left) {
- p.chordCutPaste(m.button == .middle);
+ // acme 1-2 / 1-3: the execute (Cut) or look (Paste) button
+ // tapped while the select button holds a selection drag
+ if (p.drag == .select and p.drag.select.button == config.select_button) {
+ p.chordCutPaste(m.button == config.exec_button);
return;
}
- // acme 2-3 / 3-2: tapping the OTHER of middle/right during
+ // acme 2-3 / 3-2: tapping the OTHER of execute/look during
// an execute or look drag cancels it (textselect23's mask)
if (p.drag == .select) {
if (p.panes[p.drag.select.id]) |t| t.sel[@intFromEnum(p.drag.select.button)].state = .none;
@@ -4323,32 +4267,32 @@ pub const Pardes = struct {
return;
}
if (mrow < TOPBAR_H) {
- // global tagbar: MIDDLE runs the command under the click
- // (a stray left click must never Kill)
- if (m.button == .middle) {
+ // global tagbar: EXECUTE runs the command under the
+ // click (a stray select click must never Kill)
+ if (m.button == config.exec_button) {
var tb_buf: [1200]u8 = undefined;
const word = wordAtCol(p.topbar(&tb_buf), mcol);
// a topbar word runs on the PRESS — there is no
// drag to chord into, so the argument is simply
// whatever is selected right now: select a word,
// then click Grep, and it greps that word.
- if (word.len > 0) p.actOnSelection(.middle, p.active, word, p.heldSelection(p.active));
+ if (word.len > 0) p.actOnSelection(config.exec_button, p.active, word, p.heldSelection(p.active));
}
return;
}
const id = hovered orelse return;
const r = p.rects[id];
const pane = p.panes[id] orelse return;
- if (mcol < r.x + GUTTER and mrow >= r.y + BOX_H) {
+ if (mcol < r.x + config.GUTTER and mrow >= r.y + BOX_H) {
// gutter: right scrolls DOWN to here, mirroring left's up;
// middle matches left. Right focuses (look lands you
// there); middle does not.
const local = @as(i32, mrow) - @as(i32, r.y + BOX_H);
- if (m.button == .right) p.active = id;
- pane.scrollBy(if (m.button == .right) local else -local);
- } else if (mcol >= r.x + GUTTER) {
- if (m.button == .right) p.active = id;
- const c: i32 = @as(i32, mcol) - @as(i32, r.x + GUTTER);
+ if (m.button == config.look_button) p.active = id;
+ pane.scrollBy(if (m.button == config.look_button) local else -local);
+ } else if (mcol >= r.x + config.GUTTER) {
+ if (m.button == config.look_button) p.active = id;
+ const c: i32 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER);
const v: i32 = @as(i32, mrow) - @as(i32, r.y);
pane.sel[@intFromEnum(m.button)] = .{ .state = .dragging, .c0 = c, .c1 = c, .r0 = v, .r1 = v };
p.drag = .{ .select = .{ .id = id, .button = m.button } };
@@ -4366,8 +4310,8 @@ pub const Pardes = struct {
.border_v => |*d| {
const c = d.left_col;
if (c + 1 < p.ncol) {
- const lo = p.col_x[c] + MINW;
- const hi = p.col_x[c] +| p.col_w[c] +| p.col_w[c + 1] -| MINW;
+ const lo = p.col_x[c] + config.MINW;
+ const hi = p.col_x[c] +| p.col_w[c] +| p.col_w[c + 1] -| config.MINW;
d.cur_x = if (lo <= hi) std.math.clamp(mcol, lo, hi) else mcol;
} else d.cur_x = mcol;
},
@@ -4391,7 +4335,7 @@ pub const Pardes = struct {
if (p.panes[s.id]) |pane| {
const r = p.rects[s.id];
const b = @intFromEnum(s.button);
- pane.sel[b].c1 = @as(i32, mcol) - @as(i32, r.x + GUTTER);
+ pane.sel[b].c1 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER);
pane.sel[b].r1 = @as(i32, mrow) - @as(i32, r.y);
}
},
@@ -4401,7 +4345,7 @@ pub const Pardes = struct {
// the press put it, so this is the mouse's `v`
const pane = p.panes[d.id] orelse return;
const end: i32 = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail.items.len);
- const c = @as(i32, mcol) - @as(i32, p.rects[d.id].x + GUTTER);
+ const c = @as(i32, mcol) - @as(i32, p.rects[d.id].x + config.GUTTER);
pane.tag_col = @intCast(std.math.clamp(c, 0, end));
pane.tag_sel = pane.tag_col != pane.tag_anchor;
},
@@ -4422,7 +4366,7 @@ pub const Pardes = struct {
// the handle is the left pane's LAST column (inclusive), so
// the new width includes it — a no-drag click changes nothing
var nl: f32 = @floatFromInt((d.cur_x + 1) -| p.col_x[c]);
- nl = std.math.clamp(nl, @as(f32, MINW), @max(@as(f32, MINW), combined - MINW));
+ nl = std.math.clamp(nl, @as(f32, config.MINW), @max(@as(f32, config.MINW), combined - config.MINW));
const pair = p.col_weight[c] + p.col_weight[c + 1];
p.col_weight[c] = pair * (nl / combined);
p.col_weight[c + 1] = pair - p.col_weight[c];
@@ -4461,18 +4405,18 @@ pub const Pardes = struct {
}
const b = @intFromEnum(s.button);
pane.sel[b].state = .done;
- if (s.button == .left) {
+ if (s.button == config.select_button) {
// keep a dragged selection highlighted; a plain click clears
// it. Either way pin the cursor; files also enter normal
// mode, terminals keep the mode they had (tty keeps the
// mouse usable, insert keeps typing where you clicked).
- const sl = pane.sel[0];
+ const sl = pane.sel[sel_slot];
const dragged = sl.c0 != sl.c1 or sl.r0 != sl.r1;
- if (!dragged) pane.sel[0].state = .none;
+ if (!dragged) pane.sel[sel_slot].state = .none;
const body_vis = sl.r1 - @as(i32, BOX_H);
if (body_vis >= 0 and pane.mode != .tty) {
pane.cur_row = body_vis + pane.scroll();
- pane.cur_col = if (pane.file != null) @max(0, sl.c1 - @as(i32, PREFIX_W)) + pane.hscroll else sl.c1;
+ pane.cur_col = if (pane.file != null) @max(0, sl.c1 - @as(i32, config.PREFIX_W)) + pane.hscroll else sl.c1;
pane.cur_pinned = true;
if (!pane.isTerminal()) pane.mode = .normal;
pane.msel.active = false;
@@ -4527,18 +4471,18 @@ pub const Pardes = struct {
// to \r like any unbracketed paste.
if (!s.chorded) {
s.chorded = true;
- pane.sel[0].state = .none; // drop the sweep highlight
+ pane.sel[sel_slot].state = .none; // drop the sweep highlight
}
if (cut) return;
const y = p.yank orelse return;
if (y.len == 0) return;
const m = &pane.vt.modes;
if (m.get(.mouse_event_normal) or m.get(.mouse_event_button) or m.get(.mouse_event_any)) {
- // dragUpdate keeps sel[0] tracking the held left button, so
+ // dragUpdate keeps sel[sel_slot] tracking the held select button, so
// the click lands where the mouse is at this tap; 1-based,
// body-relative (the tag row is ours, not the app's)
- const col: u16 = @intCast(std.math.clamp(pane.sel[0].c1 + 1, 1, 9999));
- const row: u16 = @intCast(std.math.clamp(pane.sel[0].r1 - @as(i32, BOX_H) + 1, 1, 9999));
+ const col: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].c1 + 1, 1, 9999));
+ const row: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].r1 - @as(i32, BOX_H) + 1, 1, 9999));
var mb: [32]u8 = undefined;
if (m.get(.mouse_format_sgr)) {
p.emitWrite(s.id, std.fmt.bufPrint(&mb, "\x1b[<0;{d};{d}M\x1b[<0;{d};{d}m", .{ col, row, col, row }) catch return);
@@ -4564,9 +4508,9 @@ pub const Pardes = struct {
}
if (!s.chorded) {
s.chorded = true;
- const sl = pane.sel[0];
- pane.sel[0].state = .none;
- const pfx: i32 = if (pane.file != null) PREFIX_W else 0;
+ const sl = pane.sel[sel_slot];
+ pane.sel[sel_slot].state = .none;
+ const pfx: i32 = if (pane.file != null) config.PREFIX_W else 0;
const hs: i32 = if (pane.file != null) pane.hscroll else 0;
const row0 = @max(0, sl.r0 - @as(i32, BOX_H)) + pane.scroll();
const row1 = @max(0, sl.r1 - @as(i32, BOX_H)) + pane.scroll();
@@ -4952,7 +4896,7 @@ pub const Pardes = struct {
fn actOnSelection(p: *Pardes, button: Mouse.Button, id: usize, txt: []const u8, arg: ?[]const u8) void {
const pane = p.panes[id] orelse return;
- if (button == .right) {
+ if (button == config.look_button) {
var realbuf: [4096]u8 = undefined;
// an already-loaded pane wins BEFORE any filesystem resolve: the
// web build has no fs (a look would otherwise be inert even for
@@ -4975,7 +4919,7 @@ pub const Pardes = struct {
// directory to one try; an absolute word — and `@pN`, which reads
// no directory at all — answers the same everywhere and stops
// after the first pass.
- var hit: look.Target = .none;
+ var found: look.Target = .none;
var seen: [MAX_PANES][]const u8 = undefined;
var nseen: usize = 0;
var n: usize = 0;
@@ -4992,10 +4936,10 @@ pub const Pardes = struct {
if (std.fmt.bufPrint(&joinbuf, "{s}/{s}", .{ dir, pl.path }) catch null) |j|
if (p.focusPaneByPath(j, pl.line, pl.col)) return;
}
- hit = look.resolve(txt, dir, &realbuf);
- if (hit != .none or pl.path.len == 0 or pl.path[0] == '/') break;
+ found = look.resolve(txt, dir, &realbuf);
+ if (found != .none or pl.path.len == 0 or pl.path[0] == '/') break;
}
- switch (hit) {
+ switch (found) {
// acme button-3: a word that names no file/dir is a search of
// the pane it was clicked in — exactly what `/` runs (n/N then
// walk the results). Paths (src/a/b.rs:100) still resolve above
@@ -5053,9 +4997,9 @@ pub const Pardes = struct {
}
return;
}
- // execute (middle / Tab): a builtin's name runs the builtin, anything
- // else is a command line for a shell.
- if (button != .middle) return;
+ // execute (the execute button / the execute key): a builtin's name
+ // runs the builtin, anything else is a command line for a shell.
+ if (button != config.exec_button) return;
const cmd = std.mem.trim(u8, txt, " \t\r\n");
if (cmd.len == 0) return;
// three builtins take an ARGUMENT — `Restore <path>`, `Find <pat>`,
@@ -5264,7 +5208,7 @@ pub const Pardes = struct {
const state: dump.State = .{
.screen = .{ .cols = p.screen_w, .rows = p.screen_h },
.active = slot_to_pane[p.active] orelse 0,
- .topbar = topbar_str,
+ .topbar = config.topbar_str,
.theme = p.theme().name,
.columns = columns.items,
.panes = panes.items,
@@ -5437,7 +5381,7 @@ pub const Pardes = struct {
pane.reply_len = 0;
}
const r = p.rects[id];
- const cols = @max(1, r.w -| GUTTER);
+ const cols = @max(1, r.w -| config.GUTTER);
const rows = @max(1, r.h -| BOX_H); // the tag steals the top row
// a pane shrunk to just its tag keeps its last real grid: no
// pty/vt reflow while the body is hidden, so re-enlarging brings
@@ -5648,11 +5592,11 @@ pub const Pardes = struct {
fn renderPane(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, active: bool) !void {
const tz = tracy.zone(@src(), "renderPane");
defer tz.end();
- if (r.w <= GUTTER or r.h == 0) return;
+ if (r.w <= config.GUTTER or r.h == 0) return;
const s = &p.surface;
const th = p.theme();
- const tx = r.x + GUTTER; // text area (tag + body), right of the gutter
- const tw = r.w - GUTTER;
+ const tx = r.x + config.GUTTER; // text area (tag + body), right of the gutter
+ const tw = r.w - config.GUTTER;
// text area resets to terminal-default cells (vaxis clear semantics);
// light themes paint the page over it.
@@ -5706,8 +5650,8 @@ pub const Pardes = struct {
if (pane.image != null) {
p.drawImage(pane, r, tx, tw);
const box_bg2 = if (active) th.box else th.box_dim;
- s.fill(r.x, r.y, GUTTER, BOX_H, .{ .bg = .{ .rgb = box_bg2 } });
- if (r.h > BOX_H) s.fill(r.x, r.y + BOX_H, GUTTER, r.h - BOX_H, .{ .bg = .{ .rgb = th.scroll_track } });
+ s.fill(r.x, r.y, config.GUTTER, BOX_H, .{ .bg = .{ .rgb = box_bg2 } });
+ if (r.h > BOX_H) s.fill(r.x, r.y + BOX_H, config.GUTTER, r.h - BOX_H, .{ .bg = .{ .rgb = th.scroll_track } });
return;
}
@@ -5752,7 +5696,7 @@ pub const Pardes = struct {
var row: u16 = 0;
while (row < r.h) : (row += 1) {
if (@as(i32, row) < r0 or @as(i32, row) > r1) continue;
- var col: i32 = if (pane.file != null) @max(c0, @as(i32, PREFIX_W)) else c0;
+ var col: i32 = if (pane.file != null) @max(c0, @as(i32, config.PREFIX_W)) else c0;
while (col <= c1 and col < tw) : (col += 1) {
const cell = s.at(tx + @as(u16, @intCast(col)), r.y + row);
cell.default = false;
@@ -5768,7 +5712,7 @@ pub const Pardes = struct {
var row: u16 = BOX_H; // never paint the tag row
while (row < r.h) : (row += 1) {
if (@as(i32, row) < lo or @as(i32, row) > hi) continue;
- var col: u16 = if (pane.file != null) PREFIX_W else 0;
+ var col: u16 = if (pane.file != null) config.PREFIX_W else 0;
while (col < tw) : (col += 1) {
const cell = s.at(tx + col, r.y + row);
cell.default = false;
@@ -5780,7 +5724,7 @@ pub const Pardes = struct {
// modal char selection (helix `v`): stream-shaped anchor..head highlight
if (pane.mode == .normal and pane.vsel.active) {
const bnd = vselBounds(pane);
- const vpfx: i32 = if (pane.file != null) PREFIX_W else 0;
+ const vpfx: i32 = if (pane.file != null) config.PREFIX_W else 0;
const vhs: i32 = if (pane.file != null) pane.hscroll else 0;
var ar: i32 = bnd.lo_row;
while (ar <= bnd.hi_row) : (ar += 1) {
@@ -5809,7 +5753,7 @@ pub const Pardes = struct {
const prow = crow - off + @as(i32, BOX_H);
// files: cur_col is content-relative, add the lineno prefix
const cx = if (pane.file != null)
- @as(i32, PREFIX_W) + ccol - pane.hscroll
+ @as(i32, config.PREFIX_W) + ccol - pane.hscroll
else
ccol;
if (prow >= BOX_H and cx >= 0 and prow < r.h and cx < tw)
@@ -5821,9 +5765,9 @@ pub const Pardes = struct {
// gutter: move box on top, scrollbar track + thumb below
const box_bg = if (active) th.box else th.box_dim;
- s.fill(r.x, r.y, GUTTER, BOX_H, .{ .bg = .{ .rgb = box_bg } });
+ s.fill(r.x, r.y, config.GUTTER, BOX_H, .{ .bg = .{ .rgb = box_bg } });
if (r.h > BOX_H) {
- s.fill(r.x, r.y + BOX_H, GUTTER, r.h - BOX_H, .{ .bg = .{ .rgb = th.scroll_track } });
+ s.fill(r.x, r.y + BOX_H, config.GUTTER, r.h - BOX_H, .{ .bg = .{ .rgb = th.scroll_track } });
const sb: struct { total: usize, offset: usize, len: usize } = if (pane.file) |f| .{
.total = file_pane.lineCount(f.content),
.offset = f.scroll,
@@ -5838,7 +5782,7 @@ pub const Pardes = struct {
const pos = (track_h * sb.offset) / total;
var sy: usize = pos;
while (sy < track_h and sy < pos + len) : (sy += 1) {
- s.fill(r.x, r.y + BOX_H + @as(u16, @intCast(sy)), GUTTER, 1, .{ .bg = .{ .rgb = th.scroll_thumb } });
+ s.fill(r.x, r.y + BOX_H + @as(u16, @intCast(sy)), config.GUTTER, 1, .{ .bg = .{ .rgb = th.scroll_thumb } });
}
}
}
diff --git a/test/snapshots/rsearch.snap b/test/snapshots/rsearch.snap
index 669483e9..738afc23 100644
--- a/test/snapshots/rsearch.snap
+++ b/test/snapshots/rsearch.snap
@@ -1,5 +1,5 @@
# acme button-3 in a text file = search: right-click / right-select reuse the
-# `/` machinery. A click expands the file-ish word (look.isFileChar) and, when
+# `/` machinery. A click expands the file-ish word (config.isFileChar) and, when
# it names no file, searches the buffer for it — hits into the +Search output
# buffer, n/N walk them like `/`. A right-select searches the span verbatim.
# A real path (sub/dep.txt:3) still resolves and OPENS — expansion keeps / . :
diff --git a/test/snapshots/tagcmd.snap b/test/snapshots/tagcmd.snap
index d5317b8d..6183af0d 100644
--- a/test/snapshots/tagcmd.snap
+++ b/test/snapshots/tagcmd.snap
@@ -4,7 +4,7 @@
# Motions walk the tag's words (w selects the "Save" span, exactly like a body
# word motion), the execute chord runs the one under the cursor and hands the
# body back in normal mode — same keys as the body, so Tab by default (see
-# swap_enter_tab). Esc backs out instead, leaving the tail as it was.
+# config.look_key/exec_key). Esc backs out instead, leaving the tail as it was.
lines c.txt 6
start 30 100 c.txt
wait 8000 Kill Newcol