From e381c96a08f08e03a340a24acb3fe5fe1fca51be Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 26 Jul 2026 18:36:08 -0300 Subject: adding better search to tty mode --- build.zig | 21 + docs/helix-keys.md | 11 +- next-next-steps.txt | 12 +- next-steps.txt | 20 - src/dump.zig | 2 + src/gui.zig | 4 +- src/look.zig | 37 +- src/pardes.zig | 2278 ++++++++++++++++++++---------------- src/tty.zig | 1 + src/tutor.txt | 67 +- test/hxcases/parity-waivers.jsonl | 10 + test/hxcases/parity.jsonl | 80 ++ test/hxdiff.zig | 136 ++- test/snapshots/chordcut.golden | 4 +- test/snapshots/edit.golden | 2 +- test/snapshots/exec-bigdrag.golden | 2 +- test/snapshots/exec.golden | 2 +- test/snapshots/fsearch.golden | 118 +- test/snapshots/fsearch.snap | 13 +- test/snapshots/images.golden | 2 +- test/snapshots/layout-open.golden | 2 +- test/snapshots/leader.golden | 369 ++++++ test/snapshots/leader.snap | 64 + test/snapshots/modal.golden | 6 +- test/snapshots/psearch.golden | 310 +++++ test/snapshots/psearch.snap | 68 ++ test/snapshots/rsearch.golden | 38 +- test/snapshots/rsearch.snap | 12 +- test/snapshots/shrinktag.golden | 10 +- test/snapshots/tty.golden | 62 + test/snapshots/tty.snap | 11 +- test/snapshots/ttyedit.golden | 155 +++ test/snapshots/ttyedit.snap | 45 + test/snapshots/ttylook.golden | 2 +- 34 files changed, 2729 insertions(+), 1247 deletions(-) delete mode 100644 next-steps.txt create mode 100644 test/hxcases/parity-waivers.jsonl create mode 100644 test/hxcases/parity.jsonl create mode 100644 test/snapshots/leader.golden create mode 100644 test/snapshots/leader.snap create mode 100644 test/snapshots/psearch.golden create mode 100644 test/snapshots/psearch.snap create mode 100644 test/snapshots/ttyedit.golden create mode 100644 test/snapshots/ttyedit.snap diff --git a/build.zig b/build.zig index 2b728ea1..c62ee222 100644 --- a/build.zig +++ b/build.zig @@ -522,6 +522,27 @@ pub fn build(b: *std.Build) void { } b.step("hxdiff", "run the helix differential suite (-- [goldens.jsonl [waivers.jsonl]])").dependOn(&run_hxdiff.step); + // file-vs-pty parity: the SAME harness binary, run in --parity mode. + // Each case runs twice over the same text and keys, once in a file + // pane and once in a pty pane, and the two result lines must be + // identical — the file pane is the oracle, so editing a shell pane + // cannot drift away from editing a document. + const run_hxparity = b.addRunArtifact(hxdiff); + run_hxparity.addArg("--parity"); + if (b.args) |args| run_hxparity.addArgs(args) else { + // the WHOLE helix corpus plus the editing extras: a parity gate + // that only ran the cases its author wrote could not catch the + // next regression. parity-waivers names the divergences that are + // not editing (pardes bindings, pty viewport geometry, a tab the + // emulator expands), each with its reason. + run_hxparity.addArgs(&.{ + "--waivers", "test/hxcases/parity-waivers.jsonl", + "test/hxcases/cases.jsonl", "test/hxcases/parity.jsonl", + }); + run_hxparity.setCwd(b.path(".")); + } + b.step("hxparity", "run the file-vs-pty editing parity suite (-- [--waivers w.jsonl] ...)").dependOn(&run_hxparity.step); + // modal.zig is pure std — its inline unit tests run here const unit = b.addTest(.{ .root_module = b.createModule(.{ .target = target, diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 3a73cf25..444bc822 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -70,7 +70,7 @@ with a reason in `test/hxcases/waivers.jsonl`. | `gh` / `gl` | line start / line end (last char, not the newline) | | helix-verified | | `Ctrl-d` / `Ctrl-u` | half page down/up, cursor follows | matches `page_cursor_half_down/up` | helix-verified | | `Ctrl-f` | full page down | matches `page_down`; file panes only get `Ctrl-b` (see next row) | helix-verified | -| `Ctrl-b` | file panes: full page up. Terminal panes: **raw tty mode toggle** (`opts.tty_toggle`, configurable) | tty toggle is pardes-specific and wins on terminals; harness `pane:"tty"` cases avoid `Ctrl-b` | helix-verified (file) / pardes-specific (tty) | +| `Ctrl-b` | file panes: full page up. Terminal panes: **raw tty mode toggle** (`opts.tty_toggle`, configurable; `Shift-Esc` is a second, fixed binding for the same toggle) | tty toggle is pardes-specific and wins on terminals; harness `pane:"tty"` cases avoid `Ctrl-b` | helix-verified (file) / pardes-specific (tty) | | `PageUp` / `PageDown` | full page | matches helix `page_up`/`page_down` (view scroll + cursor snap to the scrolloff edge) | helix-verified | | `zt` / `zz` / `zb` | scroll current line to top / center / bottom | matches `align_view_top/center/bottom` (helix harness pins scrolloff to pardes' 3) | helix-verified | | `i` `a` | insert at selection start / after selection end | helix semantics: `i` before the selection, `a` selects and appends after it | helix-verified | @@ -90,8 +90,9 @@ with a reason in `test/hxcases/waivers.jsonl`. | `Ctrl-w` + `h/j/k/l`/arrows | directional pane focus prefix — normal/tty modes only | pardes' own window handling (helix window mode skipped, section C). Insert mode owns `Ctrl-w` = delete-word-back; `Ctrl-w` while editing a tag (tags are always insert) is a no-op | pardes-specific | | `Alt-n` | new terminal below (any mode) | shadows helix `Alt-n` TS sibling-select — skipped anyway (tree-sitter) | pardes-specific | | `Alt-c` | move active terminal to a fresh column (any mode) | helix `Alt-c` is change-noyank; the pardes window op wins (do-not-touch contract). `Alt-d` + `i` covers the behavior | waived (`alt-c-window-op`) | -| `/` then `n`/`N` (file panes) | pardes' own grep-into-a-shell-pane search, n/N walk results (EXPLICIT selections) | KEEP, do not touch; not in the corpus (helix `/` is regex search) | pardes-specific | -| `n`/`N` (terminal panes) | lookable-token motion over scrollback (EXPLICIT selections) | KEEP, do not touch | pardes-specific | +| `Space` (normal, body) | the pardes LEADER: a key path from here runs a BUILTIN with no arguments — the same builtins the topbar and the tags hold. `SPC k` Kill, `SPC d` Del, `SPC w` Save, `SPC h t` Tutor, `SPC c n`/`SPC c d` Newcol/Delcol, `SPC t d/c/n/r` Debug/Colors/NextColor/Crt, `SPC s d`/`SPC s r` Dump/Restore. `?` at ANY depth opens the Help builtin listing what the prefix can still reach (`SPC ?` = all, `SPC h ?` = the docs group), into a `+Help` output buffer. The pending path shows at the right edge of the active pane's tag; Esc — or any unmapped key — abandons it | helix spends Space on pickers/LSP (section C); pardes has neither, and acme's builtins are what a leader is for. Enum-derived: the paths, the Help lines and the execute dispatch all fold out of one `Builtin` enum at comptime. Normal mode on a BODY only — tags are always insert, tty keys belong to the program | pardes-specific | +| `/` then `n`/`N` (any pane) | pardes' own plain-substring search into a `+Search` output buffer, n/N walk the result rows and look them (EXPLICIT selections) | KEEP, do not touch; not in the corpus (helix `/` is regex search) | pardes-specific | +| `n`/`N` (terminal panes, no search armed) | lookable-token motion over scrollback (EXPLICIT selections) | KEEP, do not touch | pardes-specific | | insert: printable text | file: real edit; terminal: typed run splice | | helix-verified (file) | | insert: `Enter` | newline (keeps indent) | matches `insert_newline` | helix-verified | | insert: `Backspace` (+ `Shift-Backspace`) | delete prev char, joins lines at col 0 | matches `delete_char_backward`; `Ctrl-h` alias in B | helix-verified | @@ -210,8 +211,8 @@ text. | Key(s) | Helix behavior | Reason | | --- | --- | --- | -| `?`, `*`, `Alt-*` | rsearch / selection-as-pattern | search — pardes has its own `/` n N (grep-based, kept as-is); helix regex search machinery not wanted | -| `Space` mode, all rows (`f F e . b j g G k s S d D r a h ' w c C Alt-c p P y Y R / ?`) | pickers, LSP actions, clipboard menu, global search, palette | pickers/space-mode; clipboard: yank mirrors out via OSC 52, `p` pastes the register | +| `?`, `*`, `Alt-*` | rsearch / selection-as-pattern | search — pardes has its own `/` n N (plain substring into an output buffer, kept as-is); helix regex search machinery not wanted | +| `Space` mode, all rows (`f F e . b j g G k s S d D r a h ' w c C Alt-c p P y Y R / ?`) | pickers, LSP actions, clipboard menu, global search, palette | helix's space mode is pickers + LSP + a clipboard menu, none of which exist here — but the KEY is taken: pardes' own leader runs the acme builtins (section A). Clipboard: yank mirrors out via OSC 52, `p` pastes the register | | Popup `Ctrl-u`/`Ctrl-d`, Completion menu, Signature help tables | LSP popups | LSP | | Picker table (all rows), Prompt table (all rows) | picker / prompt internals | pickers — pardes' tag line is its own one-line editor | | `gn` `gp` `ga` `gm` | next/prev/alternate buffer | buffer nav — pardes panes aren't a buffer list | diff --git a/next-next-steps.txt b/next-next-steps.txt index e109ff38..00b0711f 100644 --- a/next-next-steps.txt +++ b/next-next-steps.txt @@ -1,10 +1,2 @@ -- when clicking in a tty, don't change the mode, keep as it was -- in insert/normal mode in a tty don't scroll for new input, only in tty mode -- implement missing acme mouse chords (you should explore pardes and acme codebase to do a survey) -- fix crash when decreasing font size on tty -- for the web version make LOOK on a file focus it if it's loaded as a pane on the snapshot -- when using a command that needs a selection, if there's none of the current pane, look for selections in other panes. -- panes can be shrinked to just have the tagline visible -- when a new pane is going to open, if the chosen parent would leave it with less than 5 rows (without counting the tagline), then choose a different one, if none is found then just choose the largest one in height on the target column -- parse the argv so that when you run pardes with no arguments: it opens in tty mode, if you pass a file it'll open that file focused (with a terminal split on the directory pardes was opened from) and if you pass a directory it'll set pardes cwd to there -- the gui should have a different binary name, and non linux-x64 should have the os and tag along the name too +- Editing mode on pty pane should behave the same as if it was a regular text file. You should implement tests that will send the same commands to the same text buffer, but one is a pty and one is a file pane and the result after chaning should be the same. Right now I can't create new lines freely nor split current lines; this might require some refactoring because the pty editing was done in a real simple way. +- Add builtins (that work with 0 args by default) that are reachable via SPC, so like SPC+? will open the Help builtin that will list all bultins that start with the prefix command after SPC and before ?; SPC+h+t will trigger the tutor builtin, SPC+d will be the Del, SPC+k will be the kill (the only new one for now will be the Help builtin) diff --git a/next-steps.txt b/next-steps.txt deleted file mode 100644 index 686f21b8..00000000 --- a/next-steps.txt +++ /dev/null @@ -1,20 +0,0 @@ -- clean the workspace directory at .. (we may just leave a single directory (don'tmess with the old change)) -- after pardes exists on the tty, the tty should be clean and not left with any garbage (right now the spawner shell gets kinda broken) -- sometimes when I select something by mistake instead of clicking the editor crashes (this seems to happen only on paths?) -- during layout when opening or closing a pane it should only expand/split a single pane (it's chosen as a parent, child or something, the heuristic is just to be the closest one the most important thing is that it should't change the layout of the whole column) and right now there are some cases where this isn't the case. -- the tty platform renders highlighted text way slower than it should be (this is noticeable when scrolling), but the gui version doesn't seem to have this issue. make tracy tracing work and use the cli version of the tool to understand what's happening (the zig compiler also uses tracy so this might be as simple as adding some flag to build.zig or something). -- only on ghostty the middle mouse click doesn't work -- make pardes work on macos and other posix compliant targets -- mode indicators: three characters, uppercase -- the cursor's line number in the gutter gets a different bg/fg (tagline style) -- opening a file at a specific line centers the line if possible -- keyboard movement implements scroll_offset (vim/helix scrolloff) -- REWORK n/N on tty: n/N only MOVE the selection between lookable whitespace-tokens (multiple per line, e.g. ls output: AAAA.txt -> BBBB.txt), opening nothing; Return looks the selected token (dir -> listed terminal, file -> pane); navigation continues with n/N+Return in what opened -- dumps go to a per-user data dir (zig stdlib app-data path), not /tmp, with timestamped filenames so they never overwrite; new Restore builtin loads a dump file (first argument) -- horizontal scroll: no scrollbar, just horizontal mouse wheel + modal movements (scrolloff applies); minimal, goal is only to read the whole text -- cursor looks different in insert vs normal mode, in all pane types (tty and file); the tagline is always insert mode (normal makes no sense there) -- crash: drainEffects assert(ptys[sp.pane]==null) — spawn reusing a pane id whose old pty hasn't EOF'd yet -- initial layout: one pane, not three -- focusing a file at a line also moves the modal cursor to that line (not just the scroll) -- let's implement file searching, pardes philosophy is that what you do and the texts you read are sacred, so we don't want to implement file search with popups or information that will just dissapear. How I want you to do this is to: when I search something with / (in a text file, leave tty for later), we'll call grep -n on the current file with the search text. so far very simple, the neat thing is when I press n/N pardes will use the semantic tty access we have to select grep's result next line up or down and LOOK it (note we aren't introducing a new search concept or anything, just hooking modal editing, with semantic tty info from ghostty, and look semantics from acme). -- crash: illegal instruction in ts_parser_parse opening agave cpi.rs with full treesitter (0.26 zig bindings + 0.27 C runtime linked together); terminal left broken after non-panic crashes (SIGILL/SIGSEGV need recover in the signal path) diff --git a/src/dump.zig b/src/dump.zig index bff8af21..06b9cec6 100644 --- a/src/dump.zig +++ b/src/dump.zig @@ -63,6 +63,8 @@ pub const File = struct { path: []const u8 = "", content: []const u8 = "", content_b64: []const u8 = "", + /// an output buffer (no file behind the name) — see pardes.File.output + output: bool = false, }; pub const Image = struct { diff --git a/src/gui.zig b/src/gui.zig index 1947cd75..e2c39ac1 100644 --- a/src/gui.zig +++ b/src/gui.zig @@ -1446,6 +1446,7 @@ const StdinFeed = struct { .text = text, .ctrl = key.mods.ctrl, .alt = key.mods.alt, + .shift = key.mods.shift, } }); out.n_events += 1; }, @@ -1830,6 +1831,7 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void { fn keyDown(core: *pardes.Pardes, sym: c.SDL_Keycode, mod: c.SDL_Keymod) void { const ctrl = (mod & c.SDL_KMOD_CTRL) != 0; const alt = (mod & c.SDL_KMOD_ALT) != 0; + const shift = (mod & c.SDL_KMOD_SHIFT) != 0; const gui_mod = (mod & c.SDL_KMOD_GUI) != 0; const cp: u21 = switch (sym) { c.SDLK_RETURN, c.SDLK_KP_ENTER => pardes.Key.enter, @@ -1854,7 +1856,7 @@ fn keyDown(core: *pardes.Pardes, sym: c.SDL_Keycode, mod: c.SDL_Keymod) void { }, }; if (cp == 0) return; - core.update(.{ .key = .{ .cp = cp, .ctrl = ctrl, .alt = alt } }); + core.update(.{ .key = .{ .cp = cp, .ctrl = ctrl, .alt = alt, .shift = shift } }); } fn pixelCell(px: f32, cell: u32) u16 { diff --git a/src/look.zig b/src/look.zig index fb74353f..f34fa367 100644 --- a/src/look.zig +++ b/src/look.zig @@ -26,8 +26,11 @@ pub fn isFileChar(c: u8) bool { }; } -/// peel a trailing :NNN (or :NNN:) line suffix: main.zig:100 -> {main.zig, 100} -pub fn parsePathLine(tok: []const u8) struct { path: []const u8, line: usize } { +/// 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} +/// main.zig:100: -> {main.zig, 100, 0} grep -n's trailing delimiter +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; @@ -36,17 +39,28 @@ pub fn parsePathLine(tok: []const u8) struct { path: []const u8, line: usize } { 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 - return .{ .path = tok[0..sep], .line = line }; + 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; + } + return .{ .path = tok[0..sep], .line = line, .col = col }; } - return .{ .path = tok, .line = 0 }; + return .{ .path = tok, .line = 0, .col = 0 }; } pub const Target = union(enum) { none, dir: []const u8, // resolved absolute path, in caller's buf - file: struct { path: []const u8, line: usize }, + file: struct { path: []const u8, line: usize, col: usize }, image: struct { path: []const u8 }, url: []const u8, + /// `@p7:10:5` — pane 7, line 10, column 5 (0 = unspecified). The one + /// target that names a live pane instead of a path, because terminals and + /// output buffers have no file for a location to point at. + pane: struct { id: usize, line: usize, col: usize }, }; const image_exts = [_][]const u8{ ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ppm", ".pgm", ".tga" }; @@ -67,6 +81,15 @@ pub fn resolve(word_raw: []const u8, cwd: []const u8, realbuf: *[4096]u8) Target const word = pl.path; 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') { + var id: usize = 0; + for (word[2..]) |c| { + if (!std.ascii.isDigit(c)) break; + id = id * 10 + (c - '0'); + } else return .{ .pane = .{ .id = id, .line = pl.line, .col = pl.col } }; + } + if (platform_has_fs) { var joinbuf: [2048]u8 = undefined; const joined: ?[:0]u8 = if (word[0] == '/') @@ -78,14 +101,14 @@ pub fn resolve(word_raw: []const u8, cwd: []const u8, realbuf: *[4096]u8) Target const resolved = std.mem.span(rp); if (isDir(rp)) return .{ .dir = resolved }; if (isImagePath(resolved)) return .{ .image = .{ .path = resolved } }; - return .{ .file = .{ .path = resolved, .line = pl.line } }; + return .{ .file = .{ .path = resolved, .line = pl.line, .col = pl.col } }; } else { // web: URLs leave the app; tracked Zig sources resolve inside the // build-generated, read-only source filesystem. if (std.mem.startsWith(u8, trimmed, "http://") or std.mem.startsWith(u8, trimmed, "https://")) return .{ .url = trimmed }; if (resolveEmbedded(word, cwd, realbuf)) |source| - return .{ .file = .{ .path = source.path, .line = pl.line } }; + return .{ .file = .{ .path = source.path, .line = pl.line, .col = pl.col } }; return .none; } } diff --git a/src/pardes.zig b/src/pardes.zig index e32e2869..c88687d0 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -47,35 +47,99 @@ const file_pane_builtins_str = "Save Del"; const pane_tail = " " ++ pane_builtins_str; const file_pane_tail = " " ++ file_pane_builtins_str; -// Builtins: executing the name (middle-click / Tab) runs the action through the -// ONE dispatcher (onSelect + actOnSelection), no matter where the name appears. -const SelectAction = enum { none, send, newcol, delcol, del, save, tutor, kill, debug, colors, nextcolor, crt, dump, restore }; -const Builtin = struct { name: []const u8, action: SelectAction }; -const builtins = [_]Builtin{ - .{ .name = "Kill", .action = .kill }, - .{ .name = "Newcol", .action = .newcol }, - .{ .name = "Tutor", .action = .tutor }, - .{ .name = "Debug", .action = .debug }, - .{ .name = "Colors", .action = .colors }, - .{ .name = "NextColor", .action = .nextcolor }, - .{ .name = "Crt", .action = .crt }, - .{ .name = "Dump", .action = .dump }, - .{ .name = "Restore", .action = .restore }, - .{ .name = "Save", .action = .save }, - .{ .name = "Del", .action = .del }, - .{ .name = "Delcol", .action = .delcol }, +// Builtins: executing the name (middle-click / Tab) runs it through the ONE +// dispatcher (runBuiltin, reached from actOnSelection), no matter where the +// name appears. The enum FIELD NAME is the user-visible word — the one in the +// topbar, the one sitting in a tag, the one Help prints, the one you execute +// — so `std.meta.stringToEnum` IS the lookup and there is no name table to +// keep in sync. Everything else about a builtin (its leader key path, its +// Help line) is folded out of this enum at comptime, below. A name that is +// not a legal identifier would be spelled `@"..."`; zig fmt strips the +// quotes from the ones that are. +const Builtin = enum { + Kill, + Newcol, + Tutor, + Debug, + Colors, + NextColor, + Crt, + Dump, + Restore, + Save, + Del, + Delcol, + Help, }; -fn onSelect(button: Mouse.Button, text: []const u8) SelectAction { - if (button != .middle) return .none; - const cmd = std.mem.trim(u8, text, " \t\r\n"); - if (cmd.len == 0) return .none; - inline for (builtins) |b| { - if (std.mem.eql(u8, cmd, b.name)) return b.action; +/// 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 (h docs, c columns, t toggles, s +/// session); `?` 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 = "?", + .Del = "d", + .Kill = "k", + .Save = "w", // vim's :w — `s` is the session group + .Tutor = "ht", + .Newcol = "cn", + .Delcol = "cd", + .Debug = "td", + .Colors = "tc", + .NextColor = "tn", + .Crt = "tr", + .Dump = "sd", + .Restore = "sr", +}); + +/// 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?" +/// and Help filters the same rows by the same prefix. A node-and-pointer trie +/// for thirteen two-character paths would be ceremony. `line` is the Help +/// rendering, padded here so Help is a filter over an array of lines. +const Leader = struct { path: []const u8, cmd: Builtin, line: []const u8 }; +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), 0..) |b, i| { + const path = leader_path.get(b); + var keys: []const u8 = ""; + for (path, 0..) |c, k| { + if (k > 0) keys = keys ++ " "; + keys = keys ++ &[_]u8{c}; + } + rows[i] = .{ + .path = path, + .cmd = b, + .line = "SPC " ++ keys ++ (" " ** (keyw - keys.len)) ++ " " ++ @tagName(b), + }; + } + // insertion sort by path: a group sorts right before what extends it + for (1..rows.len) |i| { + var j = i; + while (j > 0 and std.mem.lessThan(u8, rows[j].path, rows[j - 1].path)) : (j -= 1) { + const t = rows[j]; + rows[j] = rows[j - 1]; + rows[j - 1] = t; + } + } + 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. +comptime { + @setEvalBranchQuota(4000); // the tokenizer walks the string one branch per byte + var it = std.mem.tokenizeScalar(u8, topbar_str, ' '); + while (it.next()) |w| { + if (std.meta.stringToEnum(Builtin, w) == null) @compileError("topbar word is not a builtin: " ++ w); } - // Restore takes the dump path as its first argument - if (std.mem.startsWith(u8, cmd, "Restore ") or std.mem.startsWith(u8, cmd, "Restore\t")) return .restore; - return .send; } /// the whitespace-delimited word covering `col` in `str` (topbar dispatch) @@ -113,7 +177,7 @@ const sel_fg = [3]u8{ 0xd2, 0xd2, 0xd2 }; const msel_bg = [3]u8{ 0x4a, 0x3a, 0x1e }; const msel_fg = [3]u8{ 0xd8, 0xc8, 0xa8 }; const UNDO_MAX = 256; -const EDIT_UNDO_MAX = 64; // terminal snapshots are ~2KB each; cap tighter +const EDIT_UNDO_MAX = 64; // terminal snapshots copy the edit buffer; cap tighter const gray = struct { const bar = [3]u8{ 0x2a, 0x2a, 0x2a }; // dim bars (overlay bg) const mid = [3]u8{ 0x52, 0x52, 0x52 }; // move preview @@ -304,6 +368,10 @@ pub const Key = struct { text: []const u8 = "", 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: bool = false, pub const enter: u21 = 0x0D; pub const escape: u21 = 0x1B; @@ -383,17 +451,22 @@ pub const Effect = union(enum) { pub const Mode = enum { normal, insert, tty }; -pub const EDIT_CAP = 128; // max chars in one insertion run -pub const MAX_EDITS = 16; // max concurrent insertion runs per pane - -/// A char-mode insertion run, anchored at an ABSOLUTE body row (rides the -/// scrollback, not the screen). Rendering splices it into the row, shifting -/// the shell's text right — typed text never overwrites output. -pub const Edit = struct { +/// A terminal's edit buffer: real gpa-owned text standing in for the `rows` +/// shell rows starting at ABSOLUTE body row `row`. It is edited by exactly +/// the machinery File.content is — that is the whole point, a terminal in +/// insert mode has to behave like a text file — and it rides the scrollback +/// the way the char-mode insertion runs it replaced did. The vt grid itself +/// is never written, so output keeps streaming underneath. +/// +/// Seeded from the rows it stands in for the first time you type, and grown +/// whenever an edit reaches past either end (absorbing the shell rows it +/// swallows). When it ends up holding MORE lines than the rows it covers — +/// which is what pressing Enter does — the surface below simply slides down +/// by the difference: see Pane.surfRow. +pub const Ovl = struct { row: i32 = 0, - col: i32 = 0, - len: u16 = 0, - text: [EDIT_CAP]u8 = undefined, + rows: i32 = 1, + text: []u8 = &.{}, }; /// One mouse selection (block-shaped), per button. c/r are text-area relative; @@ -425,13 +498,14 @@ pub const CharSel = struct { explicit: bool = false, }; -/// Undo snapshot of a terminal pane's edit state (whole-state, dumb on purpose). +/// Undo snapshot of a terminal pane's edit buffer (whole-state, dumb on +/// purpose). `ovl` is null for a pane nothing has been typed into yet, and +/// owns its text exactly like FileSnap does. pub const EditSnap = struct { - edits: [MAX_EDITS]Edit, - nedits: usize, + ovl: ?Ovl, cur_row: i32, cur_col: i32, - cur_off: u16, + vsel: CharSel, }; /// Undo snapshot of a file pane: content + the selection at commit time — @@ -447,6 +521,12 @@ pub const FileSnap = struct { pub const File = struct { path: []u8, content: []u8, + /// an OUTPUT buffer (acme's +Errors): a file pane with no file behind it, + /// where the core shows text it produced itself (search results). The ONLY + /// difference is that it cannot be saved — every mode, motion, chord and + /// look works because it IS a file pane. `path` still names it (and gives + /// its directory) so looks inside it resolve like anywhere else. + output: bool = false, scroll: usize = 0, /// one syntax.Syn byte per content byte in the highlighted window highlights: []u8 = &.{}, @@ -514,14 +594,15 @@ pub const Pane = struct { /// one pending prefix codepoint (0 = none): `g` `z` `m` `[` `]`, or an /// op waiting for its char argument (`f` `F` `t` `T` `r`) pending: u21 = 0, - /// `/` file search: the pattern is typed into the tag tail (no popup); + /// `/` search: the pattern is typed into the tag tail (no popup); /// search_at is the tail length before " /" was appended (submit/cancel - /// restore it). n/N walk the grep results in pane search_pane, starting - /// just below search_row (the results shell's prompt row at submit). + /// restore it). Enter fills the output buffer in pane search_pane with one + /// result per row; n/N walk it, search_row being the row they last landed + /// on (null = fresh results, nothing stepped to yet). search_edit: bool = false, search_at: u16 = 0, search_pane: ?usize = null, - search_row: i32 = 0, + search_row: ?usize = null, /// the editable tag tail: seeded with the default builtins on first touch; /// edited in place by a modal one-line editor sharing the pane's mode tag_tail: std.ArrayList(u8) = .empty, @@ -539,9 +620,9 @@ pub const Pane = struct { /// the base for look-resolution of relative paths. cwd: [1024]u8 = undefined, cwd_len: u16 = 0, - /// modal cursor, anchored at ABSOLUTE body rows. Tracks the shell cursor - /// until pinned by a click or a key. In normal mode cur_col is a visual - /// column; in insert mode it is the run anchor and cur_off the offset in it. + /// modal cursor, at ABSOLUTE body rows of the pane's SURFACE (file lines, + /// or the terminal's shell rows with its edit buffer standing in). Tracks + /// the shell cursor until pinned by a click or a key. cur_pinned: bool = false, cur_row: i32 = 0, cur_col: i32 = 0, @@ -551,10 +632,9 @@ pub const Pane = struct { /// the goal is just being able to read long lines. Byte columns, like the /// rest of the file-pane code. hscroll: i32 = 0, - cur_off: u16 = 0, sel: [3]Sel = @splat(.{}), - edits: [MAX_EDITS]Edit = @splat(.{}), - nedits: usize = 0, + /// terminals only: the typed-text buffer standing in for shell rows + ovl: ?Ovl = null, /// every raw pty byte, in order — the dump/replay record. tty_stream: std.ArrayList(u8) = .empty, /// query replies ghostty computed (DSR, DA, kitty); the stream handler has @@ -571,10 +651,31 @@ pub const Pane = struct { return pane.file == null and pane.image == null; } + /// Surface row of shell row `g`. The edit buffer's lines stand in for the + /// `rows` shell rows it covers, so everything below it slides by the + /// difference — the identity on files and on terminals nobody has typed + /// a newline into, which is why the rest of the row math can stay naive. + fn surfRow(pane: *const Pane, g: i32) i32 { + const o = pane.ovl orelse return g; + if (g <= o.row) return g; + const lines: i32 = @intCast(modal.lineCount(o.text)); + if (g >= o.row + o.rows) return g + lines - o.rows; + return @min(g, o.row + lines - 1); // inside the buffer: its own rows + } + + /// the inverse; every surface row inside the edit buffer maps to its anchor + fn gridRow(pane: *const Pane, s: i32) i32 { + const o = pane.ovl orelse return s; + if (s <= o.row) return s; + const lines: i32 = @intCast(modal.lineCount(o.text)); + if (s < o.row + lines) return o.row; + return s - lines + o.rows; + } + /// current scroll offset: file top line, or the scrollback offset fn scroll(pane: *Pane) i32 { if (pane.file) |f| return @intCast(f.scroll); - return @intCast(pane.vt.screens.active.pages.scrollbar().offset); + return pane.surfRow(@intCast(pane.vt.screens.active.pages.scrollbar().offset)); } fn scrollBy(pane: *Pane, delta: i32) void { @@ -587,7 +688,9 @@ pub const Pane = struct { f.syntax_dirty = true; } } else { - pane.vt.screens.active.scroll(.{ .delta_row = @intCast(delta) }); + // the vt scrolls in SHELL rows; convert through the edit buffer + const off: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); + pane.vt.screens.active.scroll(.{ .delta_row = pane.gridRow(pane.surfRow(off) + delta) - off }); } } @@ -624,37 +727,18 @@ pub const Pane = struct { return pane.scroll() + @as(i32, pane.rows) - 1; } - /// the insertion run anchored exactly at (row, col), if any - fn runAt(pane: *Pane, row: i32, col: i32) ?usize { - for (pane.edits[0..pane.nedits], 0..) |e, i| { - if (e.row == row and e.col == col) return i; - } - return null; - } - fn pinCursor(pane: *Pane) void { if (pane.cur_pinned) return; - const off = pane.scroll(); if (pane.file != null) { - pane.cur_row = off; + pane.cur_row = pane.scroll(); pane.cur_col = 0; } else { - pane.cur_row = @as(i32, @intCast(pane.vt.screens.active.cursor.y)) + off; + const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); + pane.cur_row = pane.surfRow(@as(i32, @intCast(pane.vt.screens.active.cursor.y)) + goff); pane.cur_col = @intCast(pane.vt.screens.active.cursor.x); } - pane.cur_off = 0; pane.cur_pinned = true; } - - /// rendered column of a position: original col + earlier-on-row insertions - /// + the offset into the run at this col. - fn renderedCol(pane: *Pane, row: i32, col: i32, off: u16) i32 { - var rc: i32 = col + @as(i32, off); - for (pane.edits[0..pane.nedits]) |e| { - if (e.row == row and e.col < col) rc += @as(i32, e.len); - } - return rc; - } }; const Drag = union(enum) { @@ -728,6 +812,12 @@ pub const Pardes = struct { hover_col: u16 = 0, hover_row: u16 = 0, ctrl_w_pending: bool = false, + /// SPC leader in flight, holding the key path typed so far (empty = just + /// SPC). Global like ctrl_w_pending — there is one leader and it acts on + /// the active pane, whose tag shows the pending path while it waits. + leader_on: bool = false, + leader_keys: [4]u8 = undefined, + leader_n: u8 = 0, ov_pinch_scale: f32 = 1.0, ov_touch_scroll_delta: f32 = 0.0, /// middle+left chord argument: a kept left selection captured by a left @@ -860,8 +950,11 @@ pub const Pardes = struct { for (f.redo.items) |s| p.gpa.free(s.content); f.redo.deinit(p.gpa); } + if (pane.ovl) |o| p.gpa.free(o.text); pane.tag_tail.deinit(p.gpa); + for (pane.ed_undo.items) |sn| if (sn.ovl) |o| p.gpa.free(o.text); pane.ed_undo.deinit(p.gpa); + for (pane.ed_redo.items) |sn| if (sn.ovl) |o| p.gpa.free(o.text); pane.ed_redo.deinit(p.gpa); pane.stream.deinit(); pane.vt.deinit(p.gpa); @@ -1019,10 +1112,12 @@ pub const Pardes = struct { } /// the editable tail: the user's edited buffer once touched, else defaults + /// (an output buffer has no file to Save, so it gets the plain tail) fn curTail(pane: *Pane) []const u8 { if (pane.image != null) return ""; if (pane.tag_init) return pane.tag_tail.items; - return if (pane.file != null) file_pane_tail else pane_tail; + if (pane.file) |f| if (!f.output) return file_pane_tail; + return pane_tail; } /// focus the tag for editing, seeding the tail on first touch and placing @@ -1031,7 +1126,7 @@ pub const Pardes = struct { if (pane.image != null) return; // images keep a live toggle tag const pfx = p.tagPrefix(pane) catch return; if (!pane.tag_init) { - pane.tag_tail.appendSlice(p.gpa, if (pane.file != null) file_pane_tail else pane_tail) catch return; + pane.tag_tail.appendSlice(p.gpa, curTail(pane)) catch return; pane.tag_init = true; } if (!pane.tag_edit) pane.tag_mode = pane.mode; @@ -1211,7 +1306,7 @@ pub const Pardes = struct { } /// block-selected text, newline-joined per row; reads the rendered screen - /// so typed runs and tty output select identically. Scratch-owned. + /// so typed text and shell output select identically. Scratch-owned. fn selectionText(p: *Pardes, pane: *Pane, sl: Sel) ![]const u8 { const arena = p.scratch.allocator(); const r0 = @min(sl.r0, sl.r1); @@ -1268,25 +1363,15 @@ pub const Pardes = struct { fn currentSelText(p: *Pardes, pane: *Pane) ?[]const u8 { if (pane.vsel.active) return p.vselText(pane); if (!pane.msel.active) return null; - const r0 = @min(pane.msel.r0, pane.msel.r1); - const r1 = @max(pane.msel.r0, pane.msel.r1); - if (pane.file) |f| { - const arena = p.scratch.allocator(); - var buf: std.ArrayList(u8) = .empty; - const lo: usize = @intCast(@max(0, r0)); - const hi: usize = @intCast(@max(0, r1)); - var r: usize = lo; - while (r <= hi) : (r += 1) { - if (r > lo) buf.append(arena, '\n') catch break; - buf.appendSlice(arena, modal.lineSlice(f.content, r)) catch break; - } - return buf.items; - } - return p.yankRows(pane, r0, r1); + return p.yankRows(pane, @min(pane.msel.r0, pane.msel.r1), @max(pane.msel.r0, pane.msel.r1)); } fn handleKey(p: *Pardes, key: Key) void { const pane = p.panes[p.active] orelse return; + // a SPC leader in flight swallows the next key, whatever it is — + // 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) if (p.ctrl_w_pending) { p.ctrl_w_pending = false; @@ -1322,19 +1407,24 @@ pub const Pardes = struct { } return; } - // the configured Ctrl-key toggles raw tty mode in and out (terminals - // only); tty is deliberately off the normal editing path - if (pane.isTerminal() and key.ctrl and key.cp == p.opts.tty_toggle) { + // the configured Ctrl-key, or Shift-Esc, toggles raw tty mode in and + // out (terminals only); tty is deliberately off the normal editing + // path. Shift-Esc needs a host that reports modifiers on Escape (the + // 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); + if (pane.isTerminal() and tty_toggle) { if (pane.mode == .tty) { pane.mode = .normal; pane.pending = 0; } else p.enterTty(p.active); return; } - // `/` search input in flight: Enter greps, Esc abandons; both restore - // the tag tail and hand focus back to the body. + // `/` search input in flight: 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(pane); + if (is(key, Key.enter)) p.submitSearch(p.active); pane.tag_tail.shrinkRetainingCapacity(@min(pane.search_at, pane.tag_tail.items.len)); exitTagEdit(pane); pane.mode = .normal; @@ -1392,19 +1482,17 @@ pub const Pardes = struct { // implicit selection (helix doc.restore_cursor) if (pane.append_at) |aa| { pane.append_at = null; - if (pane.file != null) { - const pl = p.paneCursorLines(pane) catch return; - const text = p.flatSurface(pane, pl) catch return; - const gap = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }); - const back = modal.prevGrapheme(text, gap); - const bc = modal.hxPos(text, back); - pane.cur_row = @intCast(bc.row); - pane.cur_col = @intCast(bc.col); - const a_off = modal.hxOff(text, .{ .row = @intCast(@max(0, aa.row)), .col = @intCast(@max(0, aa.col)) }); - pane.vsel = .{ .active = a_off != back, .row = aa.row, .col = aa.col, .explicit = false }; - pane.cur_pinned = true; - pane.ensureCursorVisible(); - } + const pl = p.paneCursorLines(pane) catch return; + const text = p.flatSurface(pane, pl) catch return; + const gap = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }); + const back = modal.prevGrapheme(text, gap); + const bc = modal.hxPos(text, back); + pane.cur_row = @intCast(bc.row); + pane.cur_col = @intCast(bc.col); + const a_off = modal.hxOff(text, .{ .row = @intCast(@max(0, aa.row)), .col = @intCast(@max(0, aa.col)) }); + pane.vsel = .{ .active = a_off != back, .row = aa.row, .col = aa.col, .explicit = false }; + pane.cur_pinned = true; + pane.ensureCursorVisible(); } return; } @@ -1414,6 +1502,33 @@ pub const Pardes = struct { } } + /// A key after SPC: walk the leader tree (leader_rows, the comptime table). + /// `?` at any depth opens Help scoped to the path typed so far; an exact + /// path runs its builtin with no arguments; a key that only extends a + /// group keeps waiting. ANYTHING else abandons the sequence — a typo must + /// not leave the next keystroke armed at a builtin that closes panes, and + /// the indicator vanishing is the receipt (vim and helix drop unmapped + /// leader keys the same way). Esc lands here as one of those. + fn leaderKey(p: *Pardes, key: Key) void { + 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 p.openHelp(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; + const pfx = p.leader_keys[0..p.leader_n]; + for (leader_rows) |row| { + if (std.mem.eql(u8, row.path, pfx)) return p.runBuiltin(row.cmd, p.active, ""); + } + for (leader_rows) |row| { + if (std.mem.startsWith(u8, row.path, pfx)) { + p.leader_on = true; + return; + } + } + } + /// Enter raw tty. If the shell is at a prompt and the modal cursor sits on /// the input line, move the shell's REAL cursor there via ghostty's /// promptClickMove (arrow keys the shell understands) — dropping into tty @@ -1422,8 +1537,10 @@ pub const Pardes = struct { const pane = p.panes[id] orelse return; const screen = pane.vt.screens.active; if (pane.vt.cursorIsAtPrompt()) { - const off = pane.scroll(); - const vp_row: i32 = pane.cur_row - off; + // the pin is a GRID position: convert the modal cursor's surface + // row back through the edit buffer + const goff: i32 = @intCast(screen.pages.scrollbar().offset); + const vp_row: i32 = pane.gridRow(pane.cur_row) - goff; if (vp_row >= 0) { if (screen.pages.pin(.{ .viewport = .{ .x = @intCast(@max(0, pane.cur_col)), .y = @intCast(vp_row) } })) |click_pin| { const cursor_pin = screen.cursor.page_pin.*; @@ -1596,9 +1713,36 @@ pub const Pardes = struct { row0: i32, // absolute row of lines[0] }; + /// A terminal's shell rows as the surface sees them: the WHOLE + /// history+active grid, prompt rows blanked (OSC 133), absolute grid rows + /// from 0. The raw material the motion surface is composed from — the + /// edit buffer is NOT applied here, so it is also what seeding the buffer + /// reads. Scratch-arena backed. + /// ghostty's dump trims the grid's trailing blank rows; ONE of them is + /// kept back, the row the cursor sits on below the last line of output. + /// That row is a file's final newline: without it the surface would have + /// one line fewer than the same text in a document, and every motion and + /// linewise edit at the bottom would diverge. + /// ponytail: O(scrollback) dump+scan per keystroke — window it around the + /// viewport if a multi-MB scrollback ever makes navigation lag. + fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 { + const arena = p.scratch.allocator(); + const full = try pane.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} }); + var pit = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); + var ls: std.ArrayList([]const u8) = .empty; + var it = std.mem.splitAny(u8, full, "\n"); + while (it.next()) |raw| { + const is_prompt = if (pit.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false; + try ls.append(arena, if (is_prompt) "" else raw); + } + try ls.append(arena, ""); + return ls.items; + } + /// The lines the cursor moves over, absolute rows. File: all content lines. - /// Terminal: the WHOLE history+active grid (prompt rows hidden, runs - /// spliced) so motions ride the scrollback. Scratch-arena backed. + /// Terminal: the whole history+active grid with the edit buffer's lines + /// standing in for the rows it covers, so motions ride the scrollback and + /// the typed text alike. Scratch-arena backed. /// (pub only for test/hxdiff.zig — the helix differential harness dumps /// this surface as a tty case's final text.) pub fn paneCursorLines(p: *Pardes, pane: *Pane) !PaneLines { @@ -1609,21 +1753,22 @@ pub const Pardes = struct { while (it.next()) |ln| try ls.append(arena, ln); return .{ .lines = ls.items, .row0 = 0 }; } - // ponytail: O(scrollback) dump+scan per keystroke — window it around the - // viewport if a multi-MB scrollback ever makes navigation lag. - const full = try pane.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} }); - var out: std.ArrayList(u8) = .empty; - var it = std.mem.splitAny(u8, full, "\n"); - var pit = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); - var i: usize = 0; - while (it.next()) |raw| : (i += 1) { - if (i > 0) try out.append(arena, '\n'); - const is_prompt = if (pit.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false; - try spliceRow(arena, &out, pane, @intCast(i), if (is_prompt) "" else raw); - } + const rows = try p.shellRows(pane); + // the buffer may sit below the dump's last row (the grid's trailing + // blanks are trimmed off), so run the loop to whichever ends later + var last = rows.len; + if (pane.ovl) |o| last = @max(last, @as(usize, @intCast(@max(0, o.row + o.rows)))); var ls: std.ArrayList([]const u8) = .empty; - var lit = std.mem.splitScalar(u8, out.items, '\n'); - while (lit.next()) |ln| try ls.append(arena, ln); + var g: usize = 0; + while (g < last) : (g += 1) { + if (pane.ovl) |o| if (o.row >= 0 and g == @as(usize, @intCast(o.row))) { + var bit = std.mem.splitScalar(u8, o.text, '\n'); + while (bit.next()) |ln| try ls.append(arena, ln); + g += @intCast(o.rows - 1); + continue; + }; + try ls.append(arena, if (g < rows.len) rows[g] else ""); + } return .{ .lines = ls.items, .row0 = 0 }; } @@ -1635,7 +1780,6 @@ pub const Pardes = struct { fn fromModalCursor(pane: *Pane, pl: PaneLines, c: modal.Cursor) void { pane.cur_row = @as(i32, @intCast(c.row)) + pl.row0; pane.cur_col = @intCast(c.col); - pane.cur_off = 0; pane.cur_pinned = true; } @@ -1660,19 +1804,21 @@ pub const Pardes = struct { return buf.items; } - /// the current selection as a helix gap range over `text` - fn paneRange(pane: *Pane, text: []const u8) modal.HxRange { - const c = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }); + /// the current selection as a helix gap range over `text`, whose first + /// line is absolute row `row0` (0 for the motion surface and for file + /// content; a terminal's edit buffer starts wherever it was anchored) + fn paneRange(pane: *Pane, text: []const u8, row0: i32) modal.HxRange { + const c = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row - row0)), .col = @intCast(@max(0, pane.cur_col)) }); if (pane.msel.active) { // legacy line selection (file-search results highlight): linewise - const r0: usize = @intCast(@max(0, @min(pane.msel.r0, pane.msel.r1))); - const r1: usize = @intCast(@max(0, @max(pane.msel.r0, pane.msel.r1))); + const r0: usize = @intCast(@max(0, @min(pane.msel.r0, pane.msel.r1) - row0)); + const r1: usize = @intCast(@max(0, @max(pane.msel.r0, pane.msel.r1) - row0)); const s = modal.lineStartOffset(text, r0); const e = if (r1 + 1 >= modal.hxLineCount(text)) text.len else modal.lineStartOffset(text, r1 + 1); return .{ .anchor = s, .head = @max(e, modal.nextGrapheme(text, c)) }; } if (pane.vsel.active) { - const a = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row)), .col = @intCast(@max(0, pane.vsel.col)) }); + const a = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row - row0)), .col = @intCast(@max(0, pane.vsel.col)) }); if (a <= c) return .{ .anchor = a, .head = modal.nextGrapheme(text, c) }; return .{ .anchor = modal.nextGrapheme(text, a), .head = c }; } @@ -1708,7 +1854,6 @@ pub const Pardes = struct { .explicit = explicit or pane.select, }; pane.msel.active = false; - pane.cur_off = 0; pane.cur_pinned = true; pane.sticky_col = -1; pane.pending = 0; @@ -1881,7 +2026,7 @@ pub const Pardes = struct { const saved_count = pane.count; pane.count = 0; - const range = paneRange(pane, text); + const range = paneRange(pane, text, pl.row0); const cur = modal.hxCursor(text, range); // block-cursor gap offset // the last line a goto may land on: helix skips the empty trailing line const max_line = blk: { @@ -2133,6 +2278,17 @@ pub const Pardes = struct { 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); + // 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, ' ')) { + p.leader_on = true; + p.leader_n = 0; + return; + } // `:` — vim's command line with acme's vocabulary: focus the pane's own // tag in normal mode, parked at the tail's start. Motions walk the // words and the execute chord runs the one under the cursor, then @@ -2143,23 +2299,26 @@ pub const Pardes = struct { if (pane.tag_edit) pane.mode = .normal; return; } - // file search: `/` types a pattern into the tag, n/N walk the results - if (pane.file != null and is(key, '/')) return p.startSearch(pane); - if (pane.file != null and is(key, 'n')) return p.searchStep(pane, 1); - if (pane.file != null and is(key, 'N')) return p.searchStep(pane, -1); - // terminal n/N: look the next/previous output row that looks somewhere - if (pane.isTerminal() and is(key, 'n')) return p.lookStep(pane, pl, 1); - if (pane.isTerminal() and is(key, 'N')) return p.lookStep(pane, pl, -1); + // 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); + if (is(key, 'n') or is(key, 'N')) { + const delta: i32 = if (is(key, 'n')) 1 else -1; + if (p.searchStep(p.active, delta)) return; + if (pane.isTerminal()) return p.lookStep(pane, pl, delta); + return; + } } - // ---- file search (`/` + n/N): grep into a shell pane, look the results ---- + // ---- search (`/` + n/N): results into an output buffer, look the rows ---- - /// `/` on a file pane: type the pattern into the tag tail after a " /" + /// `/` on any pane: type the pattern into the tag tail after a " /" /// marker — the existing modal one-line editor, visible while typing, /// nothing that disappears. Enter/Esc are intercepted in handleKey. fn startSearch(p: *Pardes, pane: *Pane) void { if (!pane.tag_init) { - pane.tag_tail.appendSlice(p.gpa, file_pane_tail) catch return; + pane.tag_tail.appendSlice(p.gpa, curTail(pane)) catch return; pane.tag_init = true; } pane.search_at = @intCast(pane.tag_tail.items.len); @@ -2174,72 +2333,96 @@ pub const Pardes = struct { /// Enter on a `/` input: peel the typed pattern off the tag tail (past the /// " /" marker) and run it through the shared search. - fn submitSearch(p: *Pardes, pane: *Pane) void { + fn submitSearch(p: *Pardes, id: usize) void { + const pane = p.panes[id] orelse return; const tail = pane.tag_tail.items; - p.runSearch(pane, tail[@min(@as(usize, pane.search_at) + 2, tail.len)..]); - } - - /// grep `pat_raw` (the `/` input, or a right-click word/selection) into a - /// shell in the file's directory and arm n/N to walk the results below the - /// prompt row — the shared body of `/`-submit and file-pane right-click. - fn runSearch(p: *Pardes, pane: *Pane, pat_raw: []const u8) void { - const f = pane.file orelse return; + p.runSearch(id, tail[@min(@as(usize, pane.search_at) + 2, tail.len)..]); + } + + /// Search a pane's own text for `pat_raw` (plain substring, case-insensitive + /// — the same flat view Look and Execute read, so a terminal searches its + /// scrollback exactly as a file searches its content) and write the hits + /// into this pane's output buffer, one `location text` row per hit, the + /// location a bare `LINE:COL` look target ended by a SPACE (a trailing `:` + /// would read as part of it). It is the pane's path when it has a real + /// file, else `@pN` — either way an ordinary look target, so n/N is just + /// "step a row and look it". No matches = an empty buffer. + fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8) void { + const pane = p.panes[id] orelse return; const pat = std.mem.trim(u8, pat_raw, " \t\r\n"); if (pat.len == 0) return; const arena = p.scratch.allocator(); - var cmd: std.ArrayList(u8) = .empty; - cmd.appendSlice(arena, "grep -nH '") catch return; - for (pat) |ch| { - if (ch == '\'') cmd.appendSlice(arena, "'\\''") catch return else cmd.append(arena, ch) catch return; - } - cmd.appendSlice(arena, "' '") catch return; - cmd.appendSlice(arena, f.path) catch return; - cmd.append(arena, '\'') catch return; - const dst = p.ttyForDir(std.fs.path.dirname(f.path) orelse "/") orelse return; - const rp = p.panes[dst] orelse return; - p.padOutputBelowEdits(dst); - p.emitWrite(dst, cmd.items); - p.emitWrite(dst, "\r"); - pane.search_pane = dst; - // absolute row of the shell's cursor (the prompt echoing the grep); - // results land below it, so n scans from there — older output and - // stale results of a previous search stay behind - const sb = rp.vt.screens.active.pages.scrollbar(); - pane.search_row = @intCast(sb.total -| rp.rows + rp.vt.screens.active.cursor.y); - } - - /// n/N: select the next/previous grep result row in the results pane and - /// look it. Result rows read `path:NNN:...` and are found by their path - /// prefix; prompt/echo rows are already blanked by paneCursorLines (the - /// OSC 133 semantic row marks), so only real output is walked. - fn searchStep(p: *Pardes, pane: *Pane, delta: i32) void { - const f = pane.file orelse return; - const rid = pane.search_pane orelse return; - const rp = p.panes[rid] orelse return; - if (!rp.isTerminal()) return; - const pl = p.paneCursorLines(rp) catch return; - const nrows: i32 = @intCast(pl.lines.len); - var r = pane.search_row + delta; - while (r >= 0 and r < nrows) : (r += delta) { - const ln = pl.lines[@intCast(r)]; - if (!std.mem.startsWith(u8, ln, f.path) or ln.len <= f.path.len or ln[f.path.len] != ':') continue; - pane.search_row = r; - // select the result line in the results pane and keep it in view - rp.msel = .{ .active = true, .r0 = r, .r1 = r }; - rp.vsel.active = false; - rp.cur_row = r; - rp.cur_col = 0; - rp.cur_off = 0; - rp.cur_pinned = true; - rp.ensureCursorVisible(); - // look the leading file-ish word (path:NNN:) — the ordinary look - // path scrolls this file pane to the line - var hi: usize = 0; - while (hi < ln.len and look.isFileChar(ln[hi])) hi += 1; - p.actOnSelection(.right, rid, ln[0..hi], null); + const pl = p.paneCursorLines(pane) catch return; + // a real file names itself; a terminal or an output buffer has no path + const has_path = if (pane.file) |f| !f.output else false; + var idbuf: [16]u8 = undefined; + const loc: []const u8 = if (has_path) + pane.file.?.path + else + std.fmt.bufPrint(&idbuf, "@p{d}", .{id}) catch return; + var out: std.ArrayList(u8) = .empty; + 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", .{ + loc, i + 1, at + 1, std.mem.trimEnd(u8, ln, " \t"), + }) catch return; + out.appendSlice(arena, row) catch return; + } + const content = p.gpa.dupe(u8, out.items) catch return; + // reuse this pane's own results buffer while it lives, else open one + // next to the docs; focus stays here so n/N keeps stepping. + if (pane.search_pane) |sid| if (p.panes[sid]) |sp| if (sp.file) |*sf| if (sf.output) { + p.setFileContent(sf, content); + sf.scroll = 0; + sp.cur_row = 0; + sp.msel.active = false; + pane.search_row = null; return; - } - // no result that way (or none at all): nowhere to go, stay put + }; + const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); + const free = p.freeSlot() orelse { + p.gpa.free(content); + return; + }; + const np = p.openOutputView(free, dir, "+Search", content) catch { + p.gpa.free(content); + return; + }; + p.placeDoc(id, free, np); + p.active = id; + pane.search_pane = free; + pane.search_row = null; + } + + /// n/N: step to the next/previous row of this pane's results buffer and + /// look it — every row there IS a result, so the leading file-ish word + /// (`path:LINE:COL` or `@pN:LINE:COL`) does the rest through the ordinary + /// look path. False = no live search: a terminal's n/N falls back to + /// lookStep, anything else stays put. + fn searchStep(p: *Pardes, id: usize, delta: i32) bool { + const pane = p.panes[id] orelse return false; + const rid = pane.search_pane orelse return false; + const rp = p.panes[rid] orelse return false; + const rf = if (rp.file) |*f| f else return false; + if (!rf.output) 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; + if (step < 0 or step >= nrows) return true; // armed, nowhere left to go + const r: i32 = @intCast(step); + pane.search_row = @intCast(step); + // select the result row in the results pane and keep it in view + rp.msel = .{ .active = true, .r0 = r, .r1 = r }; + rp.vsel.active = false; + rp.cur_row = r; + rp.cur_col = 0; + rp.cur_pinned = true; + 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); + return true; } /// n/N on a terminal pane (normal mode): select the next/previous output @@ -2292,7 +2475,6 @@ pub const Pardes = struct { pane.msel.active = false; pane.cur_row = r; pane.cur_col = @intCast(hit_t0); - pane.cur_off = 0; pane.cur_pinned = true; pane.ensureCursorVisible(); return; @@ -2300,6 +2482,87 @@ pub const Pardes = struct { // nothing lookable anywhere: stay put } + const EditText = struct { text: []u8, row0: i32 }; + + /// The pane's editable text and the absolute surface row its first line + /// sits at: a file's whole content at row 0, or a terminal's edit buffer + /// at its anchor. Terminals materialize the buffer here on first use and + /// grow it whenever an edit reaches past an end, seeding from — and from + /// then on standing in for — the shell rows it swallows. + /// `col` is the caller's cursor column: row `lo` is padded out to it with + /// spaces, because a terminal cursor may sit past the end of a row (acme: + /// you click anywhere and type there, which is what the insertion runs + /// did by padding at render time). Insert-mode callers pass their column; + /// everything else passes -1 and leaves the text alone. + /// Null if the pane holds no editable text (an image). + fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?EditText { + if (pane.file) |f| return .{ .text = f.content, .row0 = 0 }; + if (pane.image != null) return null; + const want_lo = @max(0, @min(lo, hi)); + const want_hi = @max(want_lo, @max(lo, hi)); + const fresh = pane.ovl == null; + const old: Ovl = pane.ovl orelse .{ .row = want_lo, .rows = 1, .text = &.{} }; + const lines: i32 = if (fresh) 1 else @intCast(modal.lineCount(old.text)); + const up = old.row - want_lo; // shell rows to prepend + const down = want_hi - (old.row + lines - 1); // surface rows past its end + if (fresh or up > 0 or down > 0) { + const rows = p.shellRows(pane) catch return null; + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(p.gpa); + const row0 = old.row - @max(0, up); + var g: i32 = row0; + while (g < old.row) : (g += 1) { + buf.appendSlice(p.gpa, if (@as(usize, @intCast(g)) < rows.len) rows[@intCast(g)] else "") catch return null; + buf.append(p.gpa, '\n') catch return null; + } + if (fresh) + buf.appendSlice(p.gpa, if (@as(usize, @intCast(old.row)) < rows.len) rows[@intCast(old.row)] else "") catch return null + else + buf.appendSlice(p.gpa, old.text) catch return null; + var k: i32 = 0; + while (k < down) : (k += 1) { + const src = old.row + old.rows + k; + buf.append(p.gpa, '\n') catch return null; + buf.appendSlice(p.gpa, if (@as(usize, @intCast(src)) < rows.len) rows[@intCast(src)] else "") catch return null; + } + const text = buf.toOwnedSlice(p.gpa) catch return null; + if (pane.ovl) |o| p.gpa.free(o.text); + pane.ovl = .{ .row = row0, .rows = old.rows + @max(0, up) + @max(0, down), .text = text }; + } + const o = &pane.ovl.?; + const r: usize = @intCast(@max(0, want_lo - o.row)); + const llen: i32 = @intCast(modal.lineSlice(o.text, r).len); + if (col > llen) { + const spaces = p.gpa.alloc(u8, @intCast(col - llen)) catch return null; + defer p.gpa.free(spaces); + @memset(spaces, ' '); + const text = modal.insertAt(p.gpa, o.text, .{ .row = r, .col = @intCast(llen) }, spaces) catch return null; + p.gpa.free(o.text); + o.text = text; + } + return .{ .text = o.text, .row0 = o.row }; + } + + /// editText for an op whose selection can END on a line's newline cell: + /// eating that newline joins with the line BELOW, so a terminal's buffer + /// has to cover that row too (a file's content always already does). + fn editTextEol(p: *Pardes, pane: *Pane, b: Bounds) ?EditText { + const eb = p.editText(pane, b.lo_row, b.hi_row, -1) orelse return null; + const r: usize = @intCast(@max(0, b.hi_row - eb.row0)); + if (r + 1 < modal.lineCount(eb.text)) return eb; + if (b.hi_col < @as(i32, @intCast(modal.lineSlice(eb.text, r).len))) return eb; + return p.editText(pane, b.lo_row, b.hi_row + 1, -1); + } + + /// install a rewritten editable text (frees the old one) + fn setEditText(p: *Pardes, pane: *Pane, new: []u8) void { + if (pane.file) |*f| return p.setFileContent(f, new); + if (pane.ovl) |*o| { + p.gpa.free(o.text); + o.text = new; + } else p.gpa.free(new); + } + const InsertAt = enum { at, append, line_start, line_end, open_below, open_above }; fn enterInsert(p: *Pardes, pane: *Pane, where: InsertAt, cnt: usize) void { @@ -2358,57 +2621,44 @@ pub const Pardes = struct { .open_below, .open_above => { pane.vsel.active = false; const below = where == .open_below; - if (pane.file) |*f| { - // helix `open`: the new line copies the current line's - // full indent LEVELS; o opens n lines, cursor on the - // first - const row: usize = if (b) |bb| - @intCast(@max(0, if (below) bb.hi_row else bb.lo_row)) - else - cur.row; - const ind = modal.hxIndentString(modal.lineSlice(f.content, row)); - const arena = p.scratch.allocator(); - var block: std.ArrayList(u8) = .empty; - for (0..cnt) |_| { - if (below) { - block.append(arena, '\n') catch return; - block.appendSlice(arena, ind) catch return; - } else { - block.appendSlice(arena, ind) catch return; - block.append(arena, '\n') catch return; - } + // helix `open`: the new line copies the current line's full + // indent LEVELS; o opens n lines, cursor on the first + const abs: i32 = if (b) |bb| (if (below) bb.hi_row else bb.lo_row) else pane.cur_row; + const eb = p.editText(pane, abs, abs, -1) orelse return; + const row: usize = @intCast(@max(0, abs - eb.row0)); + const ind = modal.hxIndentString(modal.lineSlice(eb.text, row)); + const arena = p.scratch.allocator(); + var block: std.ArrayList(u8) = .empty; + for (0..cnt) |_| { + if (below) { + block.append(arena, '\n') catch return; + block.appendSlice(arena, ind) catch return; + } else { + block.appendSlice(arena, ind) catch return; + block.append(arena, '\n') catch return; } - const at: modal.Cursor = if (below) - .{ .row = row, .col = modal.lineSlice(f.content, row).len } - else - .{ .row = row, .col = 0 }; - const new = modal.insertAt(p.gpa, f.content, at, block.items) catch return; - p.setFileContent(f, new); - pane.cur_row = @as(i32, @intCast(if (below) row + 1 else row)) + pl.row0; - pane.cur_col = @intCast(ind.len); - } else if (below) { - const bottom = pane.scroll() + @as(i32, pane.rows) - 1; - pane.cur_row = @min(bottom, pane.cur_row + 1); - pane.cur_col = 0; - } else { - const top = pane.scroll(); - pane.cur_row = @max(top, pane.cur_row - 1); - pane.cur_col = 0; } - pane.cur_off = 0; + const at: modal.Cursor = if (below) + .{ .row = row, .col = modal.lineSlice(eb.text, row).len } + else + .{ .row = row, .col = 0 }; + const new = modal.insertAt(p.gpa, eb.text, at, block.items) catch return; + p.setEditText(pane, new); + pane.cur_row = @as(i32, @intCast(if (below) row + 1 else row)) + eb.row0; + pane.cur_col = @intCast(ind.len); pane.cur_pinned = true; }, } - // `i` inserts before the run at the cursor; the others append after it - pane.cur_off = if (where == .at) 0 else if (pane.runAt(pane.cur_row, pane.cur_col)) |i| pane.edits[i].len else 0; pane.mode = .insert; pane.msel.active = false; pane.pending = 0; pane.ensureCursorVisible(); } - /// insert mode: file panes get real content edits; terminals get run - /// splices — edits only ever touch YOUR runs, shell output is immutable. + /// insert mode. ONE path for both pane kinds: a file edits its content, a + /// terminal edits the buffer standing in for its shell rows (editText + /// materializes and grows it), so typing, Enter, joins and the kill runs + /// 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 }); @@ -2419,247 +2669,192 @@ pub const Pardes = struct { // its only pardes use (the acme chords) needs explicit selections // anyway, and those never enter insert mode pane.vsel.active = false; - if (pane.file) |*f| { - const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }; - if (key.text.len > 0 and !key.ctrl and !key.alt) { - const new = modal.insertAt(p.gpa, f.content, c, key.text) catch return; - p.setFileContent(f, new); - for (key.text) |ch| { - if (ch == '\n') { - pane.cur_row += 1; - pane.cur_col = 0; - } else pane.cur_col += 1; - } - pane.cur_pinned = true; - pane.ensureCursorVisible(); - 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)) { - // helix delete_word_backward: to the previous word start — - // crossing the newline at col 0 (the word before it goes too) - const g = modal.hxOff(f.content, c); - if (g == 0) return; - const wr = modal.hxWordMove(f.content, .{ .anchor = g, .head = g }, 1, .prev_word_start); - const from = @min(wr.anchor, wr.head); - const fc = modal.hxPos(f.content, from); - const new = modal.deleteSpan(p.gpa, f.content, fc, c) catch return; - p.setFileContent(f, new); - pane.cur_row = @intCast(fc.row); - pane.cur_col = @intCast(fc.col); - pane.cur_pinned = true; + if (!pane.cur_pinned) pane.pinCursor(); + // arrows and paging are pure motion over the WHOLE surface, so they + // run before editText — a terminal must not freeze shell rows into an + // edit buffer just because you walked across them + switch (key.cp) { + Key.left, Key.right, Key.up, Key.down => { + const pl = p.paneCursorLines(pane) catch return; + const cur0 = toModalCursor(pane, pl); + const nc = switch (key.cp) { + Key.left => modal.charLeft(cur0), + Key.right => modal.charRight(pl.lines, cur0), + Key.up => modal.lineUp(pl.lines, cur0), + Key.down => modal.lineDown(pl.lines, cur0), + else => cur0, + }; + fromModalCursor(pane, pl, nc); pane.ensureCursorVisible(); return; - } - if (key.alt and !key.ctrl and (key.cp == 'd' or key.cp == Key.delete)) { - // helix delete_word_forward: to the next word END (trailing - // whitespace survives), crossing newlines at line ends - const g = modal.hxOff(f.content, c); - const wr = modal.hxWordMove(f.content, .{ .anchor = g, .head = g }, 1, .next_word_end); - const to = @max(wr.anchor, wr.head); - if (to <= g) return; - const new = modal.deleteSpan(p.gpa, f.content, c, modal.hxPos(f.content, to)) catch return; - p.setFileContent(f, new); - pane.cur_pinned = true; + }, + Key.page_up, Key.page_down => { + // helix binds insert pageup/pagedown to the same view + // scroll + cursor snap as normal mode + const pl = p.paneCursorLines(pane) catch return; + const flat = p.flatSurface(pane, pl) catch return; + const range = paneRange(pane, flat, pl.row0); + const step: i32 = @intCast(@max(1, pane.rows)); + scrollViewMove(pane, pl, flat, range, if (key.cp == Key.page_down) step else -step); return; - } - if (isC(key, 'u')) { - const new = modal.deleteSpan(p.gpa, f.content, .{ .row = c.row, .col = 0 }, c) catch return; - p.setFileContent(f, new); + }, + Key.home => { pane.cur_col = 0; pane.cur_pinned = true; pane.ensureCursorVisible(); return; - } - if (isC(key, 'k')) { - const llen = modal.lineSlice(f.content, c.row).len; - const new = modal.deleteSpan(p.gpa, f.content, c, .{ .row = c.row, .col = llen }) catch return; - p.setFileContent(f, new); + }, + Key.end => { + // helix insert End: past the last char (goto_line_end_newline) + const pl = p.paneCursorLines(pane) catch return; + const cur0 = toModalCursor(pane, pl); + pane.cur_col = @intCast(if (cur0.row < pl.lines.len) pl.lines[cur0.row].len else 0); pane.cur_pinned = true; + pane.ensureCursorVisible(); return; - } - switch (key.cp) { - Key.enter => { - // helix insert_newline: the new line copies the current - // line's full indent LEVELS (plain-text heuristic) - const ind = modal.hxIndentString(modal.lineSlice(f.content, c.row)); - const arena = p.scratch.allocator(); - const block = std.fmt.allocPrint(arena, "\n{s}", .{ind}) catch return; - const new = modal.insertAt(p.gpa, f.content, c, block) catch return; - p.setFileContent(f, new); + }, + else => {}, + } + const eb = p.editText(pane, pane.cur_row, pane.cur_row, pane.cur_col) orelse return; + const text = eb.text; + const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) }; + if (key.text.len > 0 and !key.ctrl and !key.alt) { + const new = modal.insertAt(p.gpa, text, c, key.text) catch return; + p.setEditText(pane, new); + for (key.text) |ch| { + if (ch == '\n') { pane.cur_row += 1; - pane.cur_col = @intCast(ind.len); - pane.cur_pinned = true; - pane.ensureCursorVisible(); - }, - Key.backspace => { - if (pane.cur_col > 0) { - const new = modal.deleteChar(p.gpa, f.content, .{ .row = c.row, .col = c.col - 1 }) catch return; - p.setFileContent(f, new); - pane.cur_col -= 1; - } else if (pane.cur_row > 0) { - // at col 0: eat the previous newline (join up) - const prevlen = modal.lineSlice(f.content, c.row - 1).len; - const new = modal.deleteSpan(p.gpa, f.content, .{ .row = c.row - 1, .col = prevlen }, .{ .row = c.row, .col = 0 }) catch return; - p.setFileContent(f, new); - pane.cur_row -= 1; - pane.cur_col = @intCast(prevlen); - } - pane.cur_pinned = true; - pane.ensureCursorVisible(); - }, - Key.left, Key.right, Key.up, Key.down => { - const pl = p.paneCursorLines(pane) catch return; - const cur0 = toModalCursor(pane, pl); - const nc = switch (key.cp) { - Key.left => modal.charLeft(cur0), - Key.right => modal.charRight(pl.lines, cur0), - Key.up => modal.lineUp(pl.lines, cur0), - Key.down => modal.lineDown(pl.lines, cur0), - else => cur0, - }; - fromModalCursor(pane, pl, nc); - pane.ensureCursorVisible(); - }, - Key.delete => { - // delete forward; at line end eat the newline (join) - const line = modal.lineSlice(f.content, c.row); - if (c.col < line.len) { - const new = modal.deleteChar(p.gpa, f.content, c) catch return; - p.setFileContent(f, new); - } else if (c.row + 1 < modal.lineCount(f.content)) { - const new = modal.deleteSpan(p.gpa, f.content, c, .{ .row = c.row + 1, .col = 0 }) catch return; - p.setFileContent(f, new); - } - pane.cur_pinned = true; - }, - Key.tab => { - // helix insert_tab with a Spaces indent style: spaces to - // the next tab stop (smart-tab machinery skipped) - const pad = modal.INDENT_W - (c.col % modal.INDENT_W); - const spaces = " "; - const new = modal.insertAt(p.gpa, f.content, c, spaces[0..pad]) catch return; - p.setFileContent(f, new); - pane.cur_col += @intCast(pad); - pane.cur_pinned = true; - pane.ensureCursorVisible(); - }, - Key.home => { pane.cur_col = 0; - pane.cur_pinned = true; - pane.ensureCursorVisible(); - }, - Key.end => { - // helix insert End: past the last char (goto_line_end_newline) - pane.cur_col = @intCast(modal.lineSlice(f.content, c.row).len); - pane.cur_pinned = true; - pane.ensureCursorVisible(); - }, - Key.page_up, Key.page_down => { - // helix binds insert pageup/pagedown to the same view - // scroll + cursor snap as normal mode - const pl = p.paneCursorLines(pane) catch return; - const text = p.flatSurface(pane, pl) catch return; - const range = paneRange(pane, text); - const step: i32 = @intCast(@max(1, pane.rows)); - scrollViewMove(pane, pl, text, range, if (key.cp == Key.page_down) step else -step); - }, - else => {}, + } else pane.cur_col += 1; } + pane.cur_pinned = true; + pane.ensureCursorVisible(); return; } - - if (!pane.cur_pinned) pane.pinCursor(); - if (key.text.len > 0 and !key.ctrl and !key.alt) { - insertRunText(pane, key.text); - return; - } - // kills confined to the typed run at the cursor (output is immutable) + // helix insert-mode kills (word/line; deleteSpan is exclusive) if (isC(key, 'w') or (key.alt and !key.ctrl and key.cp == Key.backspace)) { - const ei = pane.runAt(pane.cur_row, pane.cur_col) orelse return; - const e = &pane.edits[ei]; - var t: usize = @min(pane.cur_off, e.len); - while (t > 0 and e.text[t - 1] == ' ') t -= 1; - while (t > 0 and e.text[t - 1] != ' ') t -= 1; - const cut = @min(pane.cur_off, e.len) - t; - var k: usize = t; - while (k + cut < e.len) : (k += 1) e.text[k] = e.text[k + cut]; - e.len -= @intCast(cut); - pane.cur_off = @intCast(t); + // 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 + const e2 = if (c.row == 0 and c.col == 0) + (p.editText(pane, pane.cur_row - 1, pane.cur_row, 0) orelse return) + else + eb; + const c2 = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - e2.row0)), .col = c.col }; + const g = modal.hxOff(e2.text, c2); + if (g == 0) return; + const wr = modal.hxWordMove(e2.text, .{ .anchor = g, .head = g }, 1, .prev_word_start); + const from = @min(wr.anchor, wr.head); + const fc = modal.hxPos(e2.text, from); + const new = modal.deleteSpan(p.gpa, e2.text, fc, c2) catch return; + p.setEditText(pane, new); + pane.cur_row = @as(i32, @intCast(fc.row)) + e2.row0; + pane.cur_col = @intCast(fc.col); + pane.cur_pinned = true; + pane.ensureCursorVisible(); return; } if (key.alt and !key.ctrl and (key.cp == 'd' or key.cp == Key.delete)) { - const ei = pane.runAt(pane.cur_row, pane.cur_col) orelse return; - const e = &pane.edits[ei]; - var t: usize = @min(pane.cur_off, e.len); - while (t < e.len and e.text[t] != ' ') t += 1; - while (t < e.len and e.text[t] == ' ') t += 1; - const cut = t - @min(pane.cur_off, e.len); - var k: usize = @min(pane.cur_off, e.len); - while (k + cut < e.len) : (k += 1) e.text[k] = e.text[k + cut]; - e.len -= @intCast(cut); + // 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 + const e2 = if (c.col >= modal.lineSlice(text, c.row).len and c.row + 1 >= modal.lineCount(text)) + (p.editText(pane, pane.cur_row, pane.cur_row + 1, pane.cur_col) orelse return) + else + eb; + const c2 = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - e2.row0)), .col = c.col }; + const g = modal.hxOff(e2.text, c2); + const wr = modal.hxWordMove(e2.text, .{ .anchor = g, .head = g }, 1, .next_word_end); + const to = @max(wr.anchor, wr.head); + if (to <= g) return; + const new = modal.deleteSpan(p.gpa, e2.text, c2, modal.hxPos(e2.text, to)) catch return; + p.setEditText(pane, new); + pane.cur_pinned = true; return; } if (isC(key, 'u')) { - const ei = pane.runAt(pane.cur_row, pane.cur_col) orelse return; - const e = &pane.edits[ei]; - const cut = @min(pane.cur_off, e.len); - var k: usize = 0; - while (k + cut < e.len) : (k += 1) e.text[k] = e.text[k + cut]; - e.len -= @intCast(cut); - pane.cur_off = 0; + const new = modal.deleteSpan(p.gpa, text, .{ .row = c.row, .col = 0 }, c) catch return; + p.setEditText(pane, new); + pane.cur_col = 0; + pane.cur_pinned = true; + pane.ensureCursorVisible(); return; } if (isC(key, 'k')) { - const ei = pane.runAt(pane.cur_row, pane.cur_col) orelse return; - const e = &pane.edits[ei]; - e.len = @min(pane.cur_off, e.len); + 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); + pane.cur_pinned = true; return; } switch (key.cp) { + Key.enter => { + // helix insert_newline: the new line copies the current + // line's full indent LEVELS (plain-text heuristic) + const ind = modal.hxIndentString(modal.lineSlice(text, c.row)); + const arena = p.scratch.allocator(); + const block = std.fmt.allocPrint(arena, "\n{s}", .{ind}) catch return; + const new = modal.insertAt(p.gpa, text, c, block) catch return; + p.setEditText(pane, new); + pane.cur_row += 1; + pane.cur_col = @intCast(ind.len); + pane.cur_pinned = true; + pane.ensureCursorVisible(); + }, Key.backspace => { - const ei = pane.runAt(pane.cur_row, pane.cur_col) orelse return; - const e = &pane.edits[ei]; - if (pane.cur_off > 0 and e.len > 0) { - var k: usize = pane.cur_off - 1; - while (k + 1 < e.len) : (k += 1) e.text[k] = e.text[k + 1]; - e.len -= 1; - pane.cur_off -= 1; + if (pane.cur_col > 0) { + const new = modal.deleteChar(p.gpa, text, .{ .row = c.row, .col = c.col - 1 }) catch return; + p.setEditText(pane, new); + pane.cur_col -= 1; + pane.cur_pinned = true; + pane.ensureCursorVisible(); + return; } + // at col 0: eat the newline above (join up) — a terminal grows + // its buffer over the row it is joining with + if (pane.cur_row <= 0) return; + const e2 = p.editText(pane, pane.cur_row - 1, pane.cur_row, 0) orelse return; + const row: usize = @intCast(@max(0, pane.cur_row - e2.row0)); + if (row == 0) return; + const prevlen = modal.lineSlice(e2.text, row - 1).len; + const new = modal.deleteSpan(p.gpa, e2.text, .{ .row = row - 1, .col = prevlen }, .{ .row = row, .col = 0 }) catch return; + p.setEditText(pane, new); + pane.cur_row -= 1; + pane.cur_col = @intCast(prevlen); + pane.cur_pinned = true; + pane.ensureCursorVisible(); }, Key.delete => { - const ei = pane.runAt(pane.cur_row, pane.cur_col) orelse return; - const e = &pane.edits[ei]; - if (pane.cur_off < e.len) { - var k: usize = pane.cur_off; - while (k + 1 < e.len) : (k += 1) e.text[k] = e.text[k + 1]; - e.len -= 1; + // delete forward; at line end eat the newline (join), which on + // a terminal means the buffer swallowing the row below + const line = modal.lineSlice(text, c.row); + if (c.col < line.len) { + const new = modal.deleteChar(p.gpa, text, c) catch return; + p.setEditText(pane, new); + } else { + const e2 = p.editText(pane, pane.cur_row, pane.cur_row + 1, pane.cur_col) orelse return; + const row: usize = @intCast(@max(0, pane.cur_row - e2.row0)); + if (row + 1 >= modal.lineCount(e2.text)) return; + const new = modal.deleteSpan(p.gpa, e2.text, .{ .row = row, .col = c.col }, .{ .row = row + 1, .col = 0 }) catch return; + p.setEditText(pane, new); } + pane.cur_pinned = true; + }, + Key.tab => { + // helix insert_tab with a Spaces indent style: spaces to + // the next tab stop (smart-tab machinery skipped) + const pad = modal.INDENT_W - (c.col % modal.INDENT_W); + const spaces = " "; + const new = modal.insertAt(p.gpa, text, c, spaces[0..pad]) catch return; + p.setEditText(pane, new); + pane.cur_col += @intCast(pad); + pane.cur_pinned = true; + pane.ensureCursorVisible(); }, else => {}, } } - fn insertRunText(pane: *Pane, text: []const u8) void { - var idx: ?usize = pane.runAt(pane.cur_row, pane.cur_col); - if (idx == null and pane.nedits < MAX_EDITS) { - pane.edits[pane.nedits] = .{ .row = pane.cur_row, .col = pane.cur_col }; - pane.nedits += 1; - idx = pane.nedits - 1; - } - const ei = idx orelse return; - const e = &pane.edits[ei]; - for (text) |ch| { - if (e.len >= EDIT_CAP) break; - if (pane.cur_off > e.len) pane.cur_off = e.len; - var k: usize = e.len; - while (k > pane.cur_off) : (k -= 1) e.text[k] = e.text[k - 1]; - e.text[pane.cur_off] = ch; - e.len += 1; - pane.cur_off += 1; - } - } - const Bounds = struct { lo_row: i32, lo_col: i32, hi_row: i32, hi_col: i32 }; /// the char selection [anchor, cursor] normalized to document order @@ -2679,54 +2874,37 @@ pub const Pardes = struct { return .{ .lo_row = ar, .lo_col = ac, .hi_row = br, .hi_col = bc }; } + /// the char selection as text. Read off the pane's SURFACE (file content / + /// terminal shell rows + edit buffer), not the rendered body: a yank of a + /// whole line has to carry its newline, the way a file's does, or p/P + /// paste it charwise. Scratch-owned. fn vselText(p: *Pardes, pane: *Pane) []const u8 { const arena = p.scratch.allocator(); const b = vselBounds(pane); - if (pane.file) |f| { - return modal.rangeText(arena, f.content, .{ .row = @intCast(@max(0, b.lo_row)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row)), .col = @intCast(@max(0, b.hi_col)) }) catch ""; - } - const body = p.bodyText(arena, pane) catch return ""; - const off = pane.scroll(); - var out: std.ArrayList(u8) = .empty; - var it = std.mem.splitAny(u8, body, "\n"); - var i: i32 = 0; - while (it.next()) |ln| : (i += 1) { - const abs = i + off; - if (abs < b.lo_row or abs > b.hi_row) continue; - const lo: usize = if (abs == b.lo_row) @intCast(@max(0, b.lo_col)) else 0; - const hi_excl: usize = if (abs == b.hi_row) @as(usize, @intCast(@max(0, b.hi_col))) + 1 else ln.len; - if (abs > b.lo_row) out.append(arena, '\n') catch return ""; - out.appendSlice(arena, ln[@min(lo, ln.len)..@min(hi_excl, ln.len)]) catch return ""; - } - return out.items; + const text = p.surfaceText(pane) orelse return ""; + return modal.rangeText(arena, text, .{ .row = @intCast(@max(0, b.lo_row)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row)), .col = @intCast(@max(0, b.hi_col)) }) catch ""; } - /// join the rendered body rows [r0, r1] (absolute) with '\n'; scratch-owned + /// join surface rows [r0, r1] (absolute) with '\n'; scratch-owned fn yankRows(p: *Pardes, pane: *Pane, r0: i32, r1: i32) []const u8 { const arena = p.scratch.allocator(); - const body = p.bodyText(arena, pane) catch return ""; - const off = pane.scroll(); + const pl = p.paneCursorLines(pane) catch return ""; var out: std.ArrayList(u8) = .empty; - var it = std.mem.splitAny(u8, body, "\n"); - var i: i32 = 0; - while (it.next()) |ln| : (i += 1) { - const abs = i + off; - if (abs < r0 or abs > r1) continue; - if (abs > r0) out.append(arena, '\n') catch return ""; - out.appendSlice(arena, ln) catch return ""; + var i = r0; + while (i <= r1) : (i += 1) { + if (i > r0) out.append(arena, '\n') catch return ""; + if (i >= 0 and @as(usize, @intCast(i)) < pl.lines.len) + out.appendSlice(arena, pl.lines[@intCast(i)]) catch return ""; } return out.items; } - /// drop all insertion runs whose row is in [r0, r1] - fn clearEditsInRange(pane: *Pane, r0: i32, r1: i32) void { - var w: usize = 0; - for (pane.edits[0..pane.nedits]) |e| { - if (e.row >= r0 and e.row <= r1) continue; - pane.edits[w] = e; - w += 1; - } - pane.nedits = w; + /// the whole pane surface as flat text, absolute rows from 0 (read-only: + /// unlike editText it never materializes a terminal's edit buffer) + fn surfaceText(p: *Pardes, pane: *Pane) ?[]const u8 { + if (pane.file) |f| return f.content; + const pl = p.paneCursorLines(pane) catch return null; + return p.flatSurface(pane, pl) catch null; } // ---- edit operations (d / c / y / p) ---- @@ -2738,82 +2916,58 @@ pub const Pardes = struct { const b = vselBounds(pane); if (yank) p.setYank(p.vselText(pane)); // read the range before the move p.pushUndo(pane); - if (pane.file) |*f| { - const d = modal.deleteRange(p.gpa, f.content, .{ .row = @intCast(@max(0, b.lo_row)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row)), .col = @intCast(@max(0, b.hi_col)) }) catch { - pane.vsel.active = false; - return; - }; - p.setFileContent(f, d.content); - p.gpa.free(d.deleted); - pane.cur_row = b.lo_row; - pane.cur_col = b.lo_col; - clampCursorToFile(pane, f); - } else { - // terminal: can't delete shell output — only drop typed runs - clearEditsInRange(pane, b.lo_row, b.hi_row); - pane.cur_row = b.lo_row; - pane.cur_col = b.lo_col; - pane.cur_off = 0; - pane.cur_pinned = true; + const eb = p.editTextEol(pane, b) orelse { pane.vsel.active = false; - pane.ensureCursorVisible(); - } + return; + }; + const d = modal.deleteRange(p.gpa, eb.text, .{ .row = @intCast(@max(0, b.lo_row - eb.row0)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row - eb.row0)), .col = @intCast(@max(0, b.hi_col)) }) catch { + pane.vsel.active = false; + return; + }; + p.setEditText(pane, d.content); + p.gpa.free(d.deleted); + pane.cur_row = b.lo_row; + pane.cur_col = b.lo_col; + clampCursor(pane, d.content, eb.row0); return; } if (pane.msel.active) { const r0 = @min(pane.msel.r0, pane.msel.r1); const r1 = @max(pane.msel.r0, pane.msel.r1); p.pushUndo(pane); - if (pane.file) |*f| { - const d = modal.deleteLines(p.gpa, f.content, @intCast(@max(0, r0)), @intCast(@max(0, r1))) catch { - pane.msel.active = false; - return; - }; - p.setFileContent(f, d.content); - if (yank) p.setYank(d.deleted); - p.gpa.free(d.deleted); - const n = modal.lineCount(f.content); - pane.cur_row = @intCast(@min(@as(usize, @intCast(r0)), if (n == 0) 0 else n - 1)); - pane.cur_col = 0; - } else { - clearEditsInRange(pane, r0, r1); - if (yank) p.setYank(p.yankRows(pane, r0, r1)); - } + // linewise: a line's newline lives on the row below it + const eb = p.editText(pane, r0, r1 + 1, -1) orelse { + pane.msel.active = false; + return; + }; + const d = modal.deleteLines(p.gpa, eb.text, @intCast(@max(0, r0 - eb.row0)), @intCast(@max(0, r1 - eb.row0))) catch { + pane.msel.active = false; + return; + }; + p.setEditText(pane, d.content); + if (yank) p.setYank(d.deleted); + p.gpa.free(d.deleted); + const n = modal.lineCount(d.content); + pane.cur_row = @as(i32, @intCast(@min(@as(usize, @intCast(@max(0, r0 - eb.row0))), if (n == 0) 0 else n - 1))) + eb.row0; + pane.cur_col = 0; pane.msel.active = false; - pane.cur_off = 0; pane.cur_pinned = true; pane.ensureCursorVisible(); - } else if (pane.file) |*f| { + } else { p.pushUndo(pane); - const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }; + const eb = p.editTextEol(pane, .{ .lo_row = pane.cur_row, .lo_col = pane.cur_col, .hi_row = pane.cur_row, .hi_col = pane.cur_col }) orelse return; + const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) }; if (yank) { const arena = p.scratch.allocator(); - p.setYank(modal.rangeText(arena, f.content, c, c) catch return); + p.setYank(modal.rangeText(arena, eb.text, c, c) catch return); } // deleteRange (not deleteChar): the cursor may sit ON a '\n' // cell, where a delete joins the lines (helix 1-wide selection) - const d = modal.deleteRange(p.gpa, f.content, c, c) catch return; - p.setFileContent(f, d.content); + const d = modal.deleteRange(p.gpa, eb.text, c, c) catch return; + p.setEditText(pane, d.content); p.gpa.free(d.deleted); - const llen = modal.lineSlice(f.content, c.row).len; + const llen = modal.lineSlice(d.content, c.row).len; pane.cur_col = @min(pane.cur_col, @as(i32, @intCast(llen))); - } else { - p.deleteRunCharAtCursor(pane); - } - } - - /// delete the inserted char under the (visual) cursor from its run, if any - fn deleteRunCharAtCursor(p: *Pardes, pane: *Pane) void { - for (pane.edits[0..pane.nedits]) |*e| { - if (e.row != pane.cur_row or e.len == 0) continue; - const vstart = pane.renderedCol(e.row, e.col, 0); - if (pane.cur_col < vstart or pane.cur_col >= vstart + @as(i32, e.len)) continue; - p.pushUndo(pane); - var k: usize = @intCast(pane.cur_col - vstart); - while (k + 1 < e.len) : (k += 1) e.text[k] = e.text[k + 1]; - e.len -= 1; - pane.cur_pinned = true; - return; } } @@ -2825,22 +2979,7 @@ pub const Pardes = struct { return; } if (pane.msel.active) { - const r0 = @min(pane.msel.r0, pane.msel.r1); - const r1 = @max(pane.msel.r0, pane.msel.r1); - if (pane.file) |f| { - var buf: std.ArrayList(u8) = .empty; - const arena = p.scratch.allocator(); - var r: usize = @intCast(@max(0, r0)); - const hi: usize = @intCast(@max(0, r1)); - const lo: usize = @intCast(@max(0, r0)); - while (r <= hi) : (r += 1) { - if (r > lo) buf.append(arena, '\n') catch break; - buf.appendSlice(arena, modal.lineSlice(f.content, r)) catch break; - } - p.setYank(buf.items); - } else { - p.setYank(p.yankRows(pane, r0, r1)); - } + p.setYank(p.yankRows(pane, @min(pane.msel.r0, pane.msel.r1), @max(pane.msel.r0, pane.msel.r1))); } else { // helix: a bare cursor IS a 1-wide selection — yank the char // under it, file and terminal alike (the line-yank vim-ism died @@ -2856,8 +2995,7 @@ pub const Pardes = struct { /// the SELECTION's line span; anything else splices inline at the /// selection's outer edge. The paste (repeated times) becomes /// the implicit selection, cursor on its last char (linewise: ON the - /// last pasted line's newline). Terminals ignore `before` (runs splice - /// at the cursor either way). + /// last pasted line's newline). fn normalPaste(p: *Pardes, pane: *Pane, before: bool) void { const y0 = p.yank orelse return; if (y0.len == 0) return; @@ -2873,59 +3011,53 @@ pub const Pardes = struct { for (0..cnt) |_| buf.appendSlice(arena, y0) catch return; y = buf.items; } - if (pane.file) |*f| { - const b: Bounds = if (pane.vsel.active) - vselBounds(pane) - else - .{ .lo_row = pane.cur_row, .lo_col = pane.cur_col, .hi_row = pane.cur_row, .hi_col = pane.cur_col }; - if (y[y.len - 1] == '\n') { - const block_text = y[0 .. y.len - 1]; - const n = modal.lineCount(block_text); - if (before) { - const row: usize = @intCast(@max(0, b.lo_row)); - const block = std.fmt.allocPrint(arena, "{s}\n", .{block_text}) catch return; - const new = modal.insertAt(p.gpa, f.content, .{ .row = row, .col = 0 }, block) catch return; - p.setFileContent(f, new); - pane.cur_row = @intCast(row + n - 1); - } else { - const row: usize = @intCast(@max(0, b.hi_row)); - const new = modal.pasteLineBelow(p.gpa, f.content, row, block_text) catch return; - p.setFileContent(f, new); - pane.cur_row = @intCast(row + n); - } - pane.vsel = .{ .active = true, .row = pane.cur_row - @as(i32, @intCast(n - 1)), .col = 0, .explicit = false }; - // cursor ON the last pasted line's '\n' (helix) - const llen = modal.lineSlice(f.content, @intCast(pane.cur_row)).len; - pane.cur_col = @intCast(llen); + const b: Bounds = if (pane.vsel.active) + vselBounds(pane) + else + .{ .lo_row = pane.cur_row, .lo_col = pane.cur_col, .hi_row = pane.cur_row, .hi_col = pane.cur_col }; + const eb = p.editText(pane, b.lo_row, b.hi_row, -1) orelse return; + const row0 = eb.row0; + if (y[y.len - 1] == '\n') { + const block_text = y[0 .. y.len - 1]; + const n = modal.lineCount(block_text); + var out: []u8 = undefined; + if (before) { + const row: usize = @intCast(@max(0, b.lo_row - row0)); + const block = std.fmt.allocPrint(arena, "{s}\n", .{block_text}) catch return; + out = modal.insertAt(p.gpa, eb.text, .{ .row = row, .col = 0 }, block) catch return; + pane.cur_row = @as(i32, @intCast(row + n - 1)) + row0; } else { - const at: modal.Cursor = if (before) - .{ .row = @intCast(@max(0, b.lo_row)), .col = @intCast(@max(0, b.lo_col)) } - else blk: { - const hrow: usize = @intCast(@max(0, b.hi_row)); - const gcol = @as(usize, @intCast(@max(0, b.hi_col))) + 1; - if (gcol > modal.lineSlice(f.content, hrow).len) break :blk .{ .row = hrow + 1, .col = 0 }; - break :blk .{ .row = hrow, .col = gcol }; - }; - const new = modal.insertAt(p.gpa, f.content, at, y) catch return; - p.setFileContent(f, new); - pane.vsel = .{ .active = y.len > 1, .row = @intCast(at.row), .col = @intCast(at.col), .explicit = false }; - const end = modal.advanceBy(at, y); - if (end.col > 0) { - pane.cur_row = @intCast(end.row); - pane.cur_col = @intCast(end.col - 1); - } else { - pane.cur_row = @intCast(end.row -| 1); - pane.cur_col = @intCast(modal.lineSlice(f.content, end.row -| 1).len); - } + const row: usize = @intCast(@max(0, b.hi_row - row0)); + out = modal.pasteLineBelow(p.gpa, eb.text, row, block_text) catch return; + pane.cur_row = @as(i32, @intCast(row + n)) + row0; + } + p.setEditText(pane, out); + pane.vsel = .{ .active = true, .row = pane.cur_row - @as(i32, @intCast(n - 1)), .col = 0, .explicit = false }; + // cursor ON the last pasted line's '\n' (helix) + const llen = modal.lineSlice(out, @intCast(@max(0, pane.cur_row - row0))).len; + pane.cur_col = @intCast(llen); + } else { + const at: modal.Cursor = if (before) + .{ .row = @intCast(@max(0, b.lo_row - row0)), .col = @intCast(@max(0, b.lo_col)) } + else blk: { + const hrow: usize = @intCast(@max(0, b.hi_row - row0)); + const gcol = @as(usize, @intCast(@max(0, b.hi_col))) + 1; + if (gcol > modal.lineSlice(eb.text, hrow).len) break :blk .{ .row = hrow + 1, .col = 0 }; + break :blk .{ .row = hrow, .col = gcol }; + }; + const out = modal.insertAt(p.gpa, eb.text, at, y) catch return; + p.setEditText(pane, out); + pane.vsel = .{ .active = y.len > 1, .row = @as(i32, @intCast(at.row)) + row0, .col = @intCast(at.col), .explicit = false }; + const end = modal.advanceBy(at, y); + if (end.col > 0) { + pane.cur_row = @as(i32, @intCast(end.row)) + row0; + pane.cur_col = @intCast(end.col - 1); + } else { + pane.cur_row = @as(i32, @intCast(end.row -| 1)) + row0; + pane.cur_col = @intCast(modal.lineSlice(out, end.row -| 1).len); } - pane.msel.active = false; - pane.cur_off = 0; - pane.cur_pinned = true; - pane.ensureCursorVisible(); - return; } - insertRunText(pane, y); - pane.cur_off = if (pane.runAt(pane.cur_row, pane.cur_col)) |i| pane.edits[i].len else 0; + pane.msel.active = false; pane.cur_pinned = true; pane.ensureCursorVisible(); } @@ -2935,58 +3067,45 @@ pub const Pardes = struct { pane.sticky_col = -1; if (pane.vsel.active) { const b = vselBounds(pane); + const eb = p.editTextEol(pane, b) orelse return; // helix: a LINEWISE selection (whole lines incl. their final // newline) deletes the lines and opens a fresh one above, // copying the indent of the line that moves up - if (pane.file) |*f| { - const hi_len = modal.lineSlice(f.content, @intCast(@max(0, b.hi_row))).len; - const linewise = b.lo_col == 0 and @as(usize, @intCast(@max(0, b.hi_col))) >= hi_len; - if (linewise) { - p.setYank(p.vselText(pane)); - p.pushUndo(pane); - const d = modal.deleteLines(p.gpa, f.content, @intCast(@max(0, b.lo_row)), @intCast(@max(0, b.hi_row))) catch return; - p.setFileContent(f, d.content); - p.gpa.free(d.deleted); - const n = modal.lineCount(f.content); - const row: usize = @min(@as(usize, @intCast(@max(0, b.lo_row))), if (n == 0) 0 else n - 1); - const ind = modal.hxIndentString(modal.lineSlice(f.content, row)); - const arena = p.scratch.allocator(); - const block = std.fmt.allocPrint(arena, "{s}\n", .{ind}) catch return; - const new = modal.insertAt(p.gpa, f.content, .{ .row = row, .col = 0 }, block) catch return; - p.setFileContent(f, new); - pane.cur_row = @intCast(row); - pane.cur_col = @intCast(ind.len); - pane.vsel.active = false; - pane.msel.active = false; - pane.cur_off = 0; - pane.cur_pinned = true; - pane.mode = .insert; - pane.pending = 0; - pane.ensureCursorVisible(); - return; - } - } - p.setYank(p.vselText(pane)); - p.pushUndo(pane); - if (pane.file) |*f| { - const d = modal.deleteRange(p.gpa, f.content, .{ .row = @intCast(@max(0, b.lo_row)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row)), .col = @intCast(@max(0, b.hi_col)) }) catch { - pane.vsel.active = false; - return; - }; - p.setFileContent(f, d.content); + const hi_len = modal.lineSlice(eb.text, @intCast(@max(0, b.hi_row - eb.row0))).len; + if (b.lo_col == 0 and @as(usize, @intCast(@max(0, b.hi_col))) >= hi_len) { + p.setYank(p.vselText(pane)); + p.pushUndo(pane); + const d = modal.deleteLines(p.gpa, eb.text, @intCast(@max(0, b.lo_row - eb.row0)), @intCast(@max(0, b.hi_row - eb.row0))) catch return; + p.setEditText(pane, d.content); p.gpa.free(d.deleted); - pane.cur_row = b.lo_row; - pane.cur_col = b.lo_col; - clampCursorToFile(pane, f); - } else { - clearEditsInRange(pane, b.lo_row, b.hi_row); - pane.cur_row = b.lo_row; - pane.cur_col = b.lo_col; - pane.cur_off = 0; - pane.cur_pinned = true; + const n = modal.lineCount(d.content); + const row: usize = @min(@as(usize, @intCast(@max(0, b.lo_row - eb.row0))), if (n == 0) 0 else n - 1); + const ind = modal.hxIndentString(modal.lineSlice(d.content, row)); + const arena = p.scratch.allocator(); + const block = std.fmt.allocPrint(arena, "{s}\n", .{ind}) catch return; + const new = modal.insertAt(p.gpa, d.content, .{ .row = row, .col = 0 }, block) catch return; + p.setEditText(pane, new); + pane.cur_row = @as(i32, @intCast(row)) + eb.row0; + pane.cur_col = @intCast(ind.len); pane.vsel.active = false; + pane.msel.active = false; + pane.cur_pinned = true; + pane.mode = .insert; + pane.pending = 0; pane.ensureCursorVisible(); + return; } + p.setYank(p.vselText(pane)); + p.pushUndo(pane); + const d = modal.deleteRange(p.gpa, eb.text, .{ .row = @intCast(@max(0, b.lo_row - eb.row0)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row - eb.row0)), .col = @intCast(@max(0, b.hi_col)) }) catch { + pane.vsel.active = false; + return; + }; + p.setEditText(pane, d.content); + p.gpa.free(d.deleted); + pane.cur_row = b.lo_row; + pane.cur_col = b.lo_col; + clampCursor(pane, d.content, eb.row0); pane.mode = .insert; pane.pending = 0; return; @@ -2995,114 +3114,103 @@ pub const Pardes = struct { const r0 = @min(pane.msel.r0, pane.msel.r1); const r1 = @max(pane.msel.r0, pane.msel.r1); p.pushUndo(pane); - if (pane.file) |*f| { - // keep one empty line at r0, drop the rest; yank the removed - var buf: std.ArrayList(u8) = .empty; - const arena = p.scratch.allocator(); - var r: usize = @intCast(@max(0, r0)); - const hi: usize = @intCast(@max(0, r1)); - const lo: usize = @intCast(@max(0, r0)); - while (r <= hi) : (r += 1) { - if (r > lo) buf.append(arena, '\n') catch break; - buf.appendSlice(arena, modal.lineSlice(f.content, r)) catch break; - } - p.setYank(buf.items); - if (r1 > r0) { - const d = modal.deleteLines(p.gpa, f.content, @intCast(@max(0, r0 + 1)), @intCast(@max(0, r1))) catch { - pane.msel.active = false; - return; - }; - p.setFileContent(f, d.content); - p.gpa.free(d.deleted); - } - const cl = modal.clearLine(p.gpa, f.content, @intCast(@max(0, r0))) catch { + const eb = p.editText(pane, r0, r1 + 1, -1) orelse return; + // keep one empty line at r0, drop the rest; yank the removed + var buf: std.ArrayList(u8) = .empty; + const arena = p.scratch.allocator(); + const lo: usize = @intCast(@max(0, r0 - eb.row0)); + const hi: usize = @intCast(@max(0, r1 - eb.row0)); + var r: usize = lo; + while (r <= hi) : (r += 1) { + if (r > lo) buf.append(arena, '\n') catch break; + buf.appendSlice(arena, modal.lineSlice(eb.text, r)) catch break; + } + p.setYank(buf.items); + var body: []u8 = eb.text; + if (hi > lo) { + const d = modal.deleteLines(p.gpa, body, lo + 1, hi) catch { pane.msel.active = false; return; }; - p.setFileContent(f, cl); - pane.cur_row = r0; - pane.cur_col = 0; - } else { - clearEditsInRange(pane, r0, r1); - p.setYank(p.yankRows(pane, r0, r1)); - pane.cur_row = r0; - pane.cur_col = 0; + p.setEditText(pane, d.content); + p.gpa.free(d.deleted); + body = d.content; } + const cl = modal.clearLine(p.gpa, body, lo) catch { + pane.msel.active = false; + return; + }; + p.setEditText(pane, cl); + pane.cur_row = r0; + pane.cur_col = 0; pane.msel.active = false; - pane.cur_off = 0; pane.cur_pinned = true; pane.mode = .insert; pane.pending = 0; pane.ensureCursorVisible(); - } else if (pane.file) |*f| { + } else { p.pushUndo(pane); - const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }; + const eb = p.editTextEol(pane, .{ .lo_row = pane.cur_row, .lo_col = pane.cur_col, .hi_row = pane.cur_row, .hi_col = pane.cur_col }) orelse return; + const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) }; const arena = p.scratch.allocator(); - p.setYank(modal.rangeText(arena, f.content, c, c) catch return); - const d = modal.deleteRange(p.gpa, f.content, c, c) catch return; - p.setFileContent(f, d.content); + p.setYank(modal.rangeText(arena, eb.text, c, c) catch return); + const d = modal.deleteRange(p.gpa, eb.text, c, c) catch return; + p.setEditText(pane, d.content); p.gpa.free(d.deleted); - const llen = modal.lineSlice(f.content, c.row).len; + const llen = modal.lineSlice(d.content, c.row).len; pane.cur_col = @min(pane.cur_col, @as(i32, @intCast(llen))); pane.mode = .insert; - pane.cur_off = 0; - pane.pending = 0; - } else { - p.pushUndo(pane); - pane.mode = .insert; - pane.cur_off = 0; pane.pending = 0; } } // ---- helix change ops (r R ~ ` J > < Ctrl-a m-mode ]space) ---- - // Terminals follow the immutable-output rule: r/~/`/Alt-` touch the typed - // run byte under the cursor if there is one; the rest are clean no-ops. - - /// the typed-run byte under the (visual) cursor, if any - fn runByteAt(pane: *Pane, row: i32, col: i32) ?*u8 { - for (pane.edits[0..pane.nedits]) |*e| { - if (e.row != row or e.len == 0) continue; - const vstart = pane.renderedCol(e.row, e.col, 0); - if (col < vstart or col >= vstart + @as(i32, e.len)) continue; - return &e.text[@intCast(col - vstart)]; - } - return null; - } - - /// the selection as an inclusive cursor range: vsel span, msel line span, - /// else the char under the cursor (helix's implicit 1-wide selection) - fn selRange(pane: *Pane, content: []const u8) modal.Range { + // Terminals go through the same edit buffer as files: since editText + // materializes one over whatever rows the op names, every one of these + // works the same in a shell pane as in a document. + + /// the selection as an inclusive cursor range in `content`, whose first + /// line is absolute row `row0`: vsel span, msel line span, else the char + /// under the cursor (helix's implicit 1-wide selection) + fn selRange(pane: *Pane, content: []const u8, row0: i32) modal.Range { if (pane.vsel.active) { const b = vselBounds(pane); return .{ - .a = .{ .row = @intCast(@max(0, b.lo_row)), .col = @intCast(@max(0, b.lo_col)) }, - .b = .{ .row = @intCast(@max(0, b.hi_row)), .col = @intCast(@max(0, b.hi_col)) }, + .a = .{ .row = @intCast(@max(0, b.lo_row - row0)), .col = @intCast(@max(0, b.lo_col)) }, + .b = .{ .row = @intCast(@max(0, b.hi_row - row0)), .col = @intCast(@max(0, b.hi_col)) }, }; } if (pane.msel.active) { - const r0: usize = @intCast(@max(0, @min(pane.msel.r0, pane.msel.r1))); - const r1: usize = @intCast(@max(0, @max(pane.msel.r0, pane.msel.r1))); + const r0: usize = @intCast(@max(0, @min(pane.msel.r0, pane.msel.r1) - row0)); + const r1: usize = @intCast(@max(0, @max(pane.msel.r0, pane.msel.r1) - row0)); const llen = modal.lineSlice(content, r1).len; return .{ .a = .{ .row = r0, .col = 0 }, .b = .{ .row = r1, .col = llen -| 1 } }; } - const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }; + const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - row0)), .col = @intCast(@max(0, pane.cur_col)) }; return .{ .a = c, .b = c }; } + /// the rows an op's selection spans, absolute (what editText must cover) + fn selRows(pane: *Pane) Bounds { + if (pane.vsel.active) return vselBounds(pane); + if (pane.msel.active) return .{ + .lo_row = @min(pane.msel.r0, pane.msel.r1), + .lo_col = 0, + .hi_row = @max(pane.msel.r0, pane.msel.r1), + .hi_col = 0, + }; + return .{ .lo_row = pane.cur_row, .lo_col = pane.cur_col, .hi_row = pane.cur_row, .hi_col = pane.cur_col }; + } + /// `r`: overwrite the selection (or the cursor char) with ch — /// newlines included (helix), so `xrz` joins the selected lines fn normalReplaceChar(p: *Pardes, pane: *Pane, ch: u8) void { pane.select = false; - if (pane.file) |*f| { - const r = selRange(pane, f.content); - p.pushUndo(pane); - const new = modal.replaceChars(p.gpa, f.content, r.a, r.b, ch) catch return; - p.setFileContent(f, new); - } else if (runByteAt(pane, pane.cur_row, pane.cur_col)) |b| { - p.pushUndo(pane); - b.* = ch; - } + const eb = p.editTextEol(pane, selRows(pane)) orelse return; + const r = selRange(pane, eb.text, eb.row0); + p.pushUndo(pane); + const new = modal.replaceChars(p.gpa, eb.text, r.a, r.b, ch) catch return; + p.setEditText(pane, new); } /// `R`: replace the selection (or the cursor char) with the yank register; @@ -3111,25 +3219,23 @@ pub const Pardes = struct { fn normalReplaceYank(p: *Pardes, pane: *Pane) void { const y = p.yank orelse return; if (y.len == 0) return; - if (pane.file == null) return; // shell output is immutable pane.select = false; - const f = &pane.file.?; - const r = selRange(pane, f.content); + const eb = p.editTextEol(pane, selRows(pane)) orelse return; + const r = selRange(pane, eb.text, eb.row0); p.pushUndo(pane); - const new = modal.replaceRange(p.gpa, f.content, r.a, r.b, y) catch return; - p.setFileContent(f, new); + const new = modal.replaceRange(p.gpa, eb.text, r.a, r.b, y) catch return; + p.setEditText(pane, new); pane.msel.active = false; - pane.vsel = .{ .active = y.len > 1, .row = @intCast(r.a.row), .col = @intCast(r.a.col), .explicit = false }; + pane.vsel = .{ .active = y.len > 1, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false }; const end = modal.advanceBy(r.a, y); if (end.col > 0) { - pane.cur_row = @intCast(end.row); + pane.cur_row = @as(i32, @intCast(end.row)) + eb.row0; pane.cur_col = @intCast(end.col - 1); } else { // the yank ended in '\n': the cursor lands ON that newline - pane.cur_row = @intCast(end.row -| 1); - pane.cur_col = @intCast(modal.lineSlice(f.content, end.row -| 1).len); + pane.cur_row = @as(i32, @intCast(end.row -| 1)) + eb.row0; + pane.cur_col = @intCast(modal.lineSlice(new, end.row -| 1).len); } - pane.cur_off = 0; pane.cur_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); @@ -3139,19 +3245,11 @@ pub const Pardes = struct { /// keeping the selection (helix) fn normalCase(p: *Pardes, pane: *Pane, op: modal.CaseOp) void { pane.select = false; - if (pane.file) |*f| { - const r = selRange(pane, f.content); - p.pushUndo(pane); - const new = modal.changeCase(p.gpa, f.content, r.a, r.b, op) catch return; - p.setFileContent(f, new); - } else if (runByteAt(pane, pane.cur_row, pane.cur_col)) |b| { - p.pushUndo(pane); - b.* = switch (op) { - .toggle => if (std.ascii.isUpper(b.*)) std.ascii.toLower(b.*) else std.ascii.toUpper(b.*), - .lower => std.ascii.toLower(b.*), - .upper => std.ascii.toUpper(b.*), - }; - } + const eb = p.editTextEol(pane, selRows(pane)) orelse return; + const r = selRange(pane, eb.text, eb.row0); + p.pushUndo(pane); + const new = modal.changeCase(p.gpa, eb.text, r.a, r.b, op) catch return; + p.setEditText(pane, new); } /// a change to apply to file content: delete [from, to), insert `ins` @@ -3197,10 +3295,12 @@ pub const Pardes = struct { /// buffer's last line the trailing newline is deleted. The selection and /// cursor map through the edit; the count is ignored (helix). fn normalJoin(p: *Pardes, pane: *Pane) void { - if (pane.file == null) return; // joining shell output would edit it - const f = &pane.file.?; - const text = f.content; - const range = paneRange(pane, text); + // a join always eats the newline of its last line, so the buffer has + // to reach one row PAST the selection + const sr = selRows(pane); + const eb = p.editText(pane, sr.lo_row, sr.hi_row + 1, -1) orelse return; + const text = eb.text; + const range = paneRange(pane, text, eb.row0); const span = rangeLineSpan(text, range); const nlines = modal.hxLineCount(text); var end = span.end; @@ -3218,25 +3318,24 @@ pub const Pardes = struct { } if (chs.items.len == 0) return; p.pushUndo(pane); - const cur_off = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }); + const cur_off = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) }); const anc_off = if (pane.vsel.active) - modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row)), .col = @intCast(@max(0, pane.vsel.col)) }) + modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row - eb.row0)), .col = @intCast(@max(0, pane.vsel.col)) }) else cur_off; const new = p.applyChanges(text, chs.items) catch return; const nc = mapThroughChanges(chs.items, cur_off); const na = mapThroughChanges(chs.items, anc_off); - p.setFileContent(f, new); - const cc = modal.hxPos(f.content, nc); - const ac = modal.hxPos(f.content, na); - pane.cur_row = @intCast(cc.row); + p.setEditText(pane, new); + const cc = modal.hxPos(new, nc); + const ac = modal.hxPos(new, na); + pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; pane.cur_col = @intCast(cc.col); if (pane.vsel.active) { - pane.vsel.row = @intCast(ac.row); + pane.vsel.row = @as(i32, @intCast(ac.row)) + eb.row0; pane.vsel.col = @intCast(ac.col); } pane.msel.active = false; - pane.cur_off = 0; pane.cur_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); @@ -3248,11 +3347,11 @@ pub const Pardes = struct { /// leading whitespace (a tab advances to the next stop). Cursor and /// selection map through the edit. fn normalIndent(p: *Pardes, pane: *Pane, cnt: usize, add: bool) void { - if (pane.file == null) return; // shell output is immutable pane.select = false; - const f = &pane.file.?; - const text = f.content; - const range = paneRange(pane, text); + const sr = selRows(pane); + const eb = p.editText(pane, sr.lo_row, sr.hi_row, -1) orelse return; + const text = eb.text; + const range = paneRange(pane, text, eb.row0); const span = rangeLineSpan(text, range); const arena = p.scratch.allocator(); var chs: std.ArrayList(TextChange) = .empty; @@ -3284,25 +3383,24 @@ pub const Pardes = struct { } if (chs.items.len == 0) return; p.pushUndo(pane); - const cur_off = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }); + const cur_off = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) }); const anc_off = if (pane.vsel.active) - modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row)), .col = @intCast(@max(0, pane.vsel.col)) }) + modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row - eb.row0)), .col = @intCast(@max(0, pane.vsel.col)) }) else cur_off; const new = p.applyChanges(text, chs.items) catch return; const nc = mapThroughChanges(chs.items, cur_off); const na = mapThroughChanges(chs.items, anc_off); - p.setFileContent(f, new); - const cc = modal.hxPos(f.content, nc); - const ac = modal.hxPos(f.content, na); - pane.cur_row = @intCast(cc.row); + p.setEditText(pane, new); + const cc = modal.hxPos(new, nc); + const ac = modal.hxPos(new, na); + pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; pane.cur_col = @intCast(cc.col); if (pane.vsel.active) { - pane.vsel.row = @intCast(ac.row); + pane.vsel.row = @as(i32, @intCast(ac.row)) + eb.row0; pane.vsel.col = @intCast(ac.col); } pane.msel.active = false; - pane.cur_off = 0; pane.cur_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); @@ -3313,23 +3411,21 @@ pub const Pardes = struct { /// the cursor); a fragment that isn't an integer is a no-op. The new /// number becomes the selection, cursor on its last char. fn normalAdjustNumber(p: *Pardes, pane: *Pane, delta: i64) void { - if (pane.file == null) return; // shell output is immutable - const f = &pane.file.?; - const r = selRange(pane, f.content); + const eb = p.editTextEol(pane, selRows(pane)) orelse return; + const r = selRange(pane, eb.text, eb.row0); const arena = p.scratch.allocator(); - const frag = modal.rangeText(arena, f.content, r.a, r.b) catch return; + const frag = modal.rangeText(arena, eb.text, r.a, r.b) catch return; const rep = (modal.hxIncrement(arena, frag, delta) catch null) orelse return; p.pushUndo(pane); pane.select = false; - const new = modal.replaceRange(p.gpa, f.content, r.a, r.b, rep) catch return; - p.setFileContent(f, new); - const start = modal.lineStartOffset(f.content, r.a.row) + r.a.col; - const cc = modal.hxPos(f.content, start + rep.len - 1); - pane.vsel = .{ .active = rep.len > 1, .row = @intCast(r.a.row), .col = @intCast(r.a.col), .explicit = false }; + const new = modal.replaceRange(p.gpa, eb.text, r.a, r.b, rep) catch return; + p.setEditText(pane, new); + const start = modal.lineStartOffset(new, r.a.row) + r.a.col; + const cc = modal.hxPos(new, start + rep.len - 1); + pane.vsel = .{ .active = rep.len > 1, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false }; pane.msel.active = false; - pane.cur_row = @intCast(cc.row); + pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; pane.cur_col = @intCast(cc.col); - pane.cur_off = 0; pane.cur_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); @@ -3338,16 +3434,15 @@ pub const Pardes = struct { /// `]Space` / `[Space`: add blank lines below/above, the cursor staying /// on its text line (the selection rides along) fn addNewline(p: *Pardes, pane: *Pane, below: bool, cnt: usize) void { - if (pane.file == null) return; // shell output is immutable - const f = &pane.file.?; + const eb = p.editText(pane, pane.cur_row, pane.cur_row, -1) orelse return; p.pushUndo(pane); - const row: usize = @intCast(@max(0, pane.cur_row)); + const row: usize = @intCast(@max(0, pane.cur_row - eb.row0)); const arena = p.scratch.allocator(); const nl = arena.alloc(u8, cnt) catch return; @memset(nl, '\n'); - const llen = modal.lineSlice(f.content, row).len; - const new = modal.insertAt(p.gpa, f.content, .{ .row = row, .col = if (below) llen else 0 }, nl) catch return; - p.setFileContent(f, new); + const llen = modal.lineSlice(eb.text, row).len; + const new = modal.insertAt(p.gpa, eb.text, .{ .row = row, .col = if (below) llen else 0 }, nl) catch return; + p.setEditText(pane, new); if (!below) { pane.cur_row += @intCast(cnt); // the line moved down; stay on it if (pane.vsel.active and pane.vsel.row >= @as(i32, @intCast(row))) pane.vsel.row += @intCast(cnt); @@ -3376,7 +3471,7 @@ pub const Pardes = struct { /// plain-text scans (quotes line-scoped — ponytail). fn textobjectSelect(p: *Pardes, pane: *Pane, pl: PaneLines, obj: u21, around: bool) void { const text = p.flatSurface(pane, pl) catch return; - const range = paneRange(pane, text); + const range = paneRange(pane, text, pl.row0); switch (obj) { 'w', 'W' => { const r = modal.hxTextobjectWord(text, range, around, obj == 'W'); @@ -3412,21 +3507,19 @@ pub const Pardes = struct { /// `ms`: wrap the selection (or the cursor char) in a pair; the wrap /// including the pair becomes the selection fn surroundAdd(p: *Pardes, pane: *Pane, ch: u21) void { - if (pane.file == null) return; // shell output is immutable const pr = pairFor(ch) orelse return; pane.select = false; - const f = &pane.file.?; - const r = selRange(pane, f.content); + const eb = p.editTextEol(pane, selRows(pane)) orelse return; + const r = selRange(pane, eb.text, eb.row0); p.pushUndo(pane); - var new = modal.insertAt(p.gpa, f.content, .{ .row = r.b.row, .col = r.b.col + 1 }, &[1]u8{pr.c}) catch return; - p.setFileContent(f, new); - new = modal.insertAt(p.gpa, f.content, .{ .row = r.a.row, .col = r.a.col }, &[1]u8{pr.o}) catch return; - p.setFileContent(f, new); + var new = modal.insertAt(p.gpa, eb.text, .{ .row = r.b.row, .col = r.b.col + 1 }, &[1]u8{pr.c}) catch return; + p.setEditText(pane, new); + new = modal.insertAt(p.gpa, new, .{ .row = r.a.row, .col = r.a.col }, &[1]u8{pr.o}) catch return; + p.setEditText(pane, new); pane.msel.active = false; - pane.vsel = .{ .active = true, .row = @intCast(r.a.row), .col = @intCast(r.a.col), .explicit = false }; - pane.cur_row = @intCast(r.b.row); + pane.vsel = .{ .active = true, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false }; + pane.cur_row = @as(i32, @intCast(r.b.row)) + eb.row0; pane.cur_col = @intCast(r.b.col + 1 + @as(usize, if (r.a.row == r.b.row) 1 else 0)); - pane.cur_off = 0; pane.cur_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); @@ -3434,53 +3527,61 @@ pub const Pardes = struct { /// `md`: delete the enclosing pair's chars; the cursor maps through fn surroundDelete(p: *Pardes, pane: *Pane, pl: PaneLines, ch: u21) void { - if (pane.file == null) return; // shell output is immutable const pr = pairFor(ch) orelse return; - const f = &pane.file.?; - const text = p.flatSurface(pane, pl) catch return; + // the pair is found over the motion surface, whose rows are absolute; + // the edit runs in the buffer covering those rows, and every offset + // below is taken in THAT text so the cursor maps through it const cur = toModalCursor(pane, pl); const r = (if (pr.o == pr.c) modal.enclosingQuote(pl.lines, cur, pr.o) else modal.enclosingPair(pl.lines, cur, pr.o, pr.c)) orelse return; + const lo: i32 = @intCast(@min(r.a.row, cur.row)); + const hi: i32 = @intCast(@max(r.b.row, cur.row)); + const eb = p.editText(pane, lo + pl.row0, hi + pl.row0, -1) orelse return; + const text = eb.text; + const drow = pl.row0 - eb.row0; // surface row -> buffer row + const ra: modal.Cursor = .{ .row = @intCast(@as(i32, @intCast(r.a.row)) + drow), .col = r.a.col }; + const rb: modal.Cursor = .{ .row = @intCast(@as(i32, @intCast(r.b.row)) + drow), .col = r.b.col }; p.pushUndo(pane); - const a_off = modal.hxOff(text, r.a); - const b_off = modal.hxOff(text, r.b); - const cur_off = modal.hxOff(text, cur); + const a_off = modal.hxOff(text, ra); + const b_off = modal.hxOff(text, rb); + const cur_off = modal.hxOff(text, .{ .row = @intCast(@as(i32, @intCast(cur.row)) + drow), .col = cur.col }); // the close first, so the open's position stays valid - var new = modal.deleteChar(p.gpa, f.content, r.b) catch return; - p.setFileContent(f, new); - new = modal.deleteChar(p.gpa, f.content, r.a) catch return; - p.setFileContent(f, new); + var new = modal.deleteChar(p.gpa, text, rb) catch return; + p.setEditText(pane, new); + new = modal.deleteChar(p.gpa, new, ra) catch return; + p.setEditText(pane, new); var nc = cur_off; if (nc > b_off) nc -= 1; if (nc > a_off) nc -= 1; - const cc = modal.hxPos(f.content, nc); - pane.cur_row = @intCast(cc.row); + const cc = modal.hxPos(new, nc); + pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; pane.cur_col = @intCast(cc.col); pane.vsel.active = false; pane.msel.active = false; - pane.cur_off = 0; pane.cur_pinned = true; pane.ensureCursorVisible(); } /// `mr`: swap the enclosing pair's chars for 's fn surroundReplace(p: *Pardes, pane: *Pane, pl: PaneLines, from: u21, to: u21) void { - if (pane.file == null) return; // shell output is immutable const fp = pairFor(from) orelse return; const tp = pairFor(to) orelse return; - const f = &pane.file.?; const cur = toModalCursor(pane, pl); const r = (if (fp.o == fp.c) modal.enclosingQuote(pl.lines, cur, fp.o) else modal.enclosingPair(pl.lines, cur, fp.o, fp.c)) orelse return; + const eb = p.editText(pane, @as(i32, @intCast(r.a.row)) + pl.row0, @as(i32, @intCast(r.b.row)) + pl.row0, -1) orelse return; + const drow = pl.row0 - eb.row0; // surface row -> buffer row + const ar: usize = @intCast(@as(i32, @intCast(r.a.row)) + drow); + const br: usize = @intCast(@as(i32, @intCast(r.b.row)) + drow); p.pushUndo(pane); - const out = p.gpa.dupe(u8, f.content) catch return; - out[modal.lineStartOffset(out, r.a.row) + r.a.col] = tp.o; - out[modal.lineStartOffset(out, r.b.row) + r.b.col] = tp.c; - p.setFileContent(f, out); + const out = p.gpa.dupe(u8, eb.text) catch return; + out[modal.lineStartOffset(out, ar) + r.a.col] = tp.o; + out[modal.lineStartOffset(out, br) + r.b.col] = tp.c; + p.setEditText(pane, out); } // ---- dumb undo/redo: whole-state snapshots, one per edit op ---- @@ -3491,32 +3592,20 @@ pub const Pardes = struct { f.syntax_dirty = true; } - fn clampCursorToFile(pane: *Pane, f: *File) void { - const n = modal.lineCount(f.content); - const row: usize = @min(@as(usize, @intCast(@max(0, pane.cur_row))), if (n == 0) 0 else n - 1); - const llen = modal.lineSlice(f.content, row).len; - pane.cur_row = @intCast(row); + /// pull the cursor back inside `text` after a rewrite; `row0` is the + /// absolute surface row of its first line (0 for a file) + fn clampCursor(pane: *Pane, text: []const u8, row0: i32) void { + const n = modal.lineCount(text); + const row: usize = @min(@as(usize, @intCast(@max(0, pane.cur_row - row0))), if (n == 0) 0 else n - 1); + const llen = modal.lineSlice(text, row).len; + pane.cur_row = @as(i32, @intCast(row)) + row0; pane.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, pane.cur_col))), llen)); - pane.cur_off = 0; pane.cur_pinned = true; pane.vsel.active = false; pane.msel.active = false; pane.ensureCursorVisible(); } - fn snapTerm(pane: *Pane) EditSnap { - return .{ .edits = pane.edits, .nedits = pane.nedits, .cur_row = pane.cur_row, .cur_col = pane.cur_col, .cur_off = pane.cur_off }; - } - - fn editSnapEq(a: EditSnap, b: EditSnap) bool { - if (a.nedits != b.nedits) return false; - for (a.edits[0..a.nedits], b.edits[0..b.nedits]) |x, y| { - if (x.row != y.row or x.col != y.col or x.len != y.len) return false; - if (!std.mem.eql(u8, x.text[0..x.len], y.text[0..y.len])) return false; - } - return true; - } - fn pushUndo(p: *Pardes, pane: *Pane) void { if (pane.file) |*f| { if (f.undo.items.len > 0 and std.mem.eql(u8, f.undo.items[f.undo.items.len - 1].content, f.content)) return; @@ -3534,10 +3623,22 @@ pub const Pardes = struct { for (f.redo.items) |s| p.gpa.free(s.content); f.redo.clearRetainingCapacity(); } else { - const snap = snapTerm(pane); - if (pane.ed_undo.items.len > 0 and editSnapEq(pane.ed_undo.items[pane.ed_undo.items.len - 1], snap)) return; - pane.ed_undo.append(p.gpa, snap) catch return; - if (pane.ed_undo.items.len > EDIT_UNDO_MAX) _ = pane.ed_undo.orderedRemove(0); + const cur = pane.ovl orelse Ovl{ .rows = 0 }; + if (pane.ed_undo.getLastOrNull()) |top| { + const same = if (top.ovl) |o| pane.ovl != null and o.row == cur.row and + o.rows == cur.rows and std.mem.eql(u8, o.text, cur.text) else pane.ovl == null; + if (same) return; + } + const snap = p.snapTerm(pane) orelse return; + pane.ed_undo.append(p.gpa, snap) catch { + if (snap.ovl) |o| p.gpa.free(o.text); + return; + }; + if (pane.ed_undo.items.len > EDIT_UNDO_MAX) { + const drop = pane.ed_undo.orderedRemove(0); + if (drop.ovl) |o| p.gpa.free(o.text); + } + for (pane.ed_redo.items) |sn| if (sn.ovl) |o| p.gpa.free(o.text); pane.ed_redo.clearRetainingCapacity(); } } @@ -3561,8 +3662,12 @@ pub const Pardes = struct { return; } if (pane.ed_undo.items.len == 0) return; - pane.ed_redo.append(p.gpa, snapTerm(pane)) catch return; - restoreTerm(pane, pane.ed_undo.pop() orelse return); + const cur = p.snapTerm(pane) orelse return; + pane.ed_redo.append(p.gpa, cur) catch { + if (cur.ovl) |o| p.gpa.free(o.text); + return; + }; + p.restoreTerm(pane, pane.ed_undo.pop() orelse return); } fn doRedo(p: *Pardes, pane: *Pane) void { @@ -3584,8 +3689,12 @@ pub const Pardes = struct { return; } if (pane.ed_redo.items.len == 0) return; - pane.ed_undo.append(p.gpa, snapTerm(pane)) catch return; - restoreTerm(pane, pane.ed_redo.pop() orelse return); + const cur = p.snapTerm(pane) orelse return; + pane.ed_undo.append(p.gpa, cur) catch { + if (cur.ovl) |o| p.gpa.free(o.text); + return; + }; + p.restoreTerm(pane, pane.ed_redo.pop() orelse return); } /// undo/redo restores the selection recorded with the snapshot (helix @@ -3598,20 +3707,25 @@ pub const Pardes = struct { pane.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, snap.cur_col))), llen)); pane.vsel = snap.vsel; pane.msel.active = false; - pane.cur_off = 0; pane.cur_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); } - fn restoreTerm(pane: *Pane, snap: EditSnap) void { - pane.edits = snap.edits; - pane.nedits = snap.nedits; + /// the snapshot takes ownership of a COPY of the edit buffer's text + fn snapTerm(p: *Pardes, pane: *Pane) ?EditSnap { + var ovl: ?Ovl = null; + if (pane.ovl) |o| ovl = .{ .row = o.row, .rows = o.rows, .text = p.gpa.dupe(u8, o.text) catch return null }; + return .{ .ovl = ovl, .cur_row = pane.cur_row, .cur_col = pane.cur_col, .vsel = pane.vsel }; + } + + fn restoreTerm(p: *Pardes, pane: *Pane, snap: EditSnap) void { + if (pane.ovl) |o| p.gpa.free(o.text); + pane.ovl = snap.ovl; pane.cur_row = snap.cur_row; pane.cur_col = snap.cur_col; - pane.cur_off = snap.cur_off; pane.cur_pinned = true; - pane.vsel.active = false; + pane.vsel = snap.vsel; pane.msel.active = false; pane.ensureCursorVisible(); } @@ -3901,7 +4015,6 @@ pub const Pardes = struct { 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_off = if (pane.runAt(pane.cur_row, pane.cur_col)) |i| pane.edits[i].len else 0; pane.cur_pinned = true; if (!pane.isTerminal()) pane.mode = .normal; pane.msel.active = false; @@ -3996,7 +4109,6 @@ pub const Pardes = struct { const col1 = @max(0, sl.c1 - pfx) + hs; pane.cur_row = row1; pane.cur_col = col1; - pane.cur_off = 0; pane.cur_pinned = true; pane.msel.active = false; pane.pending = 0; @@ -4026,7 +4138,6 @@ pub const Pardes = struct { pane.cur_row = @intCast(end.row -| 1); pane.cur_col = @intCast(modal.lineSlice(f.content, end.row -| 1).len); } - pane.cur_off = 0; pane.cur_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); @@ -4200,7 +4311,7 @@ pub const Pardes = struct { /// no scrollback — a throwaway placeholder a file may replace fn isPristineTty(pane: *Pane) bool { if (!pane.isTerminal()) return false; - if (pane.nedits != 0) return false; + if (pane.ovl != null) return false; if (pane.vt.screens.active.cursor.y != 0) return false; return pane.vt.screens.active.pages.scrollbar().total <= pane.rows; } @@ -4249,18 +4360,18 @@ pub const Pardes = struct { return free; } - /// executing at a prompt with typed runs below the prompt: pad the output - /// area with newlines so the command's output doesn't overwrite the runs + /// executing at a prompt with typed text below it: pad the output area + /// with newlines so the command's output doesn't overwrite the buffer fn padOutputBelowEdits(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; - if (!pane.isTerminal() or pane.nedits == 0) return; + const o = pane.ovl orelse return; + if (!pane.isTerminal()) return; if (!pane.vt.cursorIsAtPrompt()) return; - var max_row: i32 = std.math.minInt(i32); - for (pane.edits[0..pane.nedits]) |e| { - if (e.len > 0 and e.row > max_row) max_row = e.row; - } - if (max_row == std.math.minInt(i32)) return; - const cursor_abs: i32 = pane.scroll() + @as(i32, @intCast(pane.vt.screens.active.cursor.y)); + // the buffer's LAST surface row: its lines may outnumber the shell + // rows it covers, and it is the bottom one output must clear + const max_row = o.row + @as(i32, @intCast(modal.lineCount(o.text))) - 1; + const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); + const cursor_abs = pane.surfRow(goff + @as(i32, @intCast(pane.vt.screens.active.cursor.y))); const pad = std.math.clamp(max_row - cursor_abs, 0, @as(i32, pane.rows)); var i: i32 = 0; while (i < pad) : (i += 1) p.emitWrite(id, "\r"); @@ -4282,6 +4393,18 @@ pub const Pardes = struct { return pane; } + /// An output buffer (acme's +Errors): a file pane holding text the core + /// produced, with no file behind it. `content` is gpa-owned and adopted. + /// The name carries `dir` so looks inside it resolve like anywhere else. + fn openOutputView(p: *Pardes, id: usize, dir: []const u8, name: []const u8, content: []u8) !*Pane { + const path = try std.fmt.allocPrint(p.gpa, "{s}/{s}", .{ std.mem.trimEnd(u8, dir, "/"), name }); + errdefer p.gpa.free(path); + const pane = try p.newDocPane(id); + pane.file = .{ .path = path, .content = content, .output = true }; + pane.cur_pinned = true; + return pane; + } + fn openTutorView(p: *Pardes, id: usize) !*Pane { const content = try p.gpa.dupe(u8, tutor_text); errdefer p.gpa.free(content); @@ -4341,10 +4464,36 @@ pub const Pardes = struct { // ---- the ONE dispatcher: look (right/Enter) and execute (middle/Tab) ---- + /// Focus pane `id` and, for a nonzero 1-based `line`, put its modal cursor + /// there (`col` likewise, 0 = line start): files recenter the view on it, + /// terminals ride their scrollback to it. Both look targets that name a + /// live pane land here — a path a pane already holds, and `@pN:LINE:COL`. + fn focusPaneLine(p: *Pardes, id: usize, line: usize, col: usize) void { + if (id >= MAX_PANES) return; + const pane = p.panes[id] orelse return; + p.active = id; + if (line == 0) return; + if (pane.file) |*f| { + if (line > fileLineCount(f.content)) return; + const next = (line - 1) -| pane.rows / 2; // center, clamp at top + if (next != f.scroll) { + f.scroll = next; + f.syntax_dirty = true; + } + } + // land the modal cursor on the target line (and keep + // ensureCursorVisible agreeing with the recenter — a stale cursor + // would yank the view right back) + pane.cur_row = @intCast(line - 1); + pane.cur_col = if (col > 0) @intCast(col - 1) else 0; + pane.cur_pinned = true; + pane.ensureCursorVisible(); + } + /// focus the pane already loaded on `path` (exact match), if any: file /// panes recenter on a :NN line like the look dedup always has, image /// panes just focus. Returns false when no pane holds that path. - fn focusPaneByPath(p: *Pardes, path: []const u8, line: usize) bool { + fn focusPaneByPath(p: *Pardes, path: []const u8, line: usize, col: usize) bool { for (p.panes, 0..) |slot, i| { const tt = slot orelse continue; if (tt.image) |iv| if (std.mem.eql(u8, iv.path, path)) { @@ -4353,20 +4502,7 @@ pub const Pardes = struct { }; const ff = if (tt.file) |*f| f else continue; if (!std.mem.eql(u8, ff.path, path)) continue; - p.active = i; - if (line > 0 and line <= fileLineCount(ff.content)) { - const next = (line - 1) -| tt.rows / 2; // center, clamp at top - if (next != ff.scroll) { - ff.scroll = next; - ff.syntax_dirty = true; - } - // land the modal cursor on the target line (and keep - // ensureCursorVisible agreeing with the recenter — - // a stale cursor would yank the view right back) - tt.cur_row = @intCast(line - 1); - tt.cur_col = 0; - tt.cur_pinned = true; - } + p.focusPaneLine(i, line, col); return true; } return false; @@ -4409,21 +4545,30 @@ pub const Pardes = struct { { const pl = look.parsePathLine(std.mem.trim(u8, txt, " \t\r\n")); if (pl.path.len > 0) { - if (p.focusPaneByPath(pl.path, pl.line)) return; + if (p.focusPaneByPath(pl.path, pl.line, pl.col)) return; if (pl.path[0] != '/') { var joinbuf: [2048]u8 = undefined; if (std.fmt.bufPrint(&joinbuf, "{s}/{s}", .{ cwd, pl.path }) catch null) |j| - if (p.focusPaneByPath(j, pl.line)) return; + if (p.focusPaneByPath(j, pl.line, pl.col)) return; } } } switch (look.resolve(txt, cwd, &realbuf)) { - // acme button-3: a word that names no file/dir, in a text file, - // is a search — grep it into a shell exactly like `/` (n/N then + // 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 - // and open; only the non-file case falls through here. On a - // terminal a dead look stays inert. - .none => if (pane.file != null) p.runSearch(pane, txt), + // and open; only the non-file case falls through here. A shell + // searches its scrollback like anything else, EXCEPT in tty + // mode, where the click belongs to the program on the other + // end; an image pane has no text to search either way. + .none => { + const bmode = if (pane.tag_edit) pane.tag_mode else pane.mode; + if (pane.image == null and bmode != .tty) p.runSearch(id, txt); + }, + // `@p7:10:5`: pane 7, line 10, column 5 — how a search result + // points at a terminal or an output buffer, neither of which + // has a path. + .pane => |t| p.focusPaneLine(t.id, t.line, t.col), .url => |u| if (u.len <= 256) p.emit(.{ .open_link = .from(u) }), .dir => |dir| { // focus an existing terminal on this dir, else fork one below @@ -4445,9 +4590,10 @@ pub const Pardes = struct { }, .file => |target| { // focus an existing pane on this path (rescrolled), else open - if (p.focusPaneByPath(target.path, target.line)) return; + if (p.focusPaneByPath(target.path, target.line, target.col)) return; const free = p.freeSlot() orelse return; const nt = p.openFileView(free, target.path, target.line) catch return; + if (target.col > 0) nt.cur_col = @intCast(target.col - 1); p.placeDoc(id, free, nt); // center the target line: the pane's real body height only // exists after placement, so lay out now and pull the @@ -4456,7 +4602,7 @@ pub const Pardes = struct { nt.file.?.scroll -|= @max(1, p.rects[free].h -| BOX_H) / 2; }, .image => |target| { - if (p.focusPaneByPath(target.path, 0)) return; + if (p.focusPaneByPath(target.path, 0, 0)) return; const free = p.freeSlot() orelse return; // petscii by default when the host has no kitty graphics const nt = p.openImageView(free, target.path, !p.kitty_ok) catch return; @@ -4465,52 +4611,69 @@ pub const Pardes = struct { } return; } - switch (onSelect(button, txt)) { - .none => {}, - .debug => p.show_debug = !p.show_debug, - .colors => p.colors_on = !p.colors_on, - .nextcolor => p.theme_idx = (p.theme_idx + 1) % themes.len, - .crt => p.crt_on = !p.crt_on, - .dump => p.dumpState() catch {}, - .restore => { - var it = std.mem.tokenizeAny(u8, std.mem.trim(u8, txt, " \t\r\n"), " \t"); + // execute (middle / Tab): a builtin's name runs the builtin, anything + // else is a command line for a shell. + if (button != .middle) return; + const cmd = std.mem.trim(u8, txt, " \t\r\n"); + if (cmd.len == 0) return; + const bi: ?Builtin = std.meta.stringToEnum(Builtin, cmd) orelse + // Restore is the ONE builtin that takes an argument (`Restore + // `); every other name must match whole, so `Kill foo` is a + // shell command and not the Kill builtin. + if (std.mem.startsWith(u8, cmd, "Restore ") or std.mem.startsWith(u8, cmd, "Restore\t")) .Restore else null; + if (bi) |b| return p.runBuiltin(b, id, cmd); + // terminal: run in itself. file pane: run in a shell in the file's + // directory (found by cwd, else freshly forked). + const dst: ?usize = if (pane.file) |f| + p.ttyForDir(std.fs.path.dirname(f.path) orelse "/") + else + id; + if (dst) |di| { + p.padOutputBelowEdits(di); + p.emitWrite(di, cmd); + // a chord argument (kept left selection) rides as a trailing CLI + // argument + if (arg) |a| { + const at = std.mem.trim(u8, a, " \t\r\n"); + if (at.len > 0) { + p.emitWrite(di, " "); + p.emitWrite(di, at); + } + } + p.emitWrite(di, "\r"); + } + } + + /// Run a builtin on pane `id`. `txt` is the executed text — only Restore + /// reads it (for its optional path argument), so the leader passes "" and + /// every builtin runs with zero arguments. The topbar ones are global; the + /// pane-scoped ones (Save, Del, Delcol) act on `id`. + fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8) void { + const pane = p.panes[id] orelse return; + switch (b) { + .Debug => p.show_debug = !p.show_debug, + .Colors => p.colors_on = !p.colors_on, + .NextColor => p.theme_idx = (p.theme_idx + 1) % themes.len, + .Crt => p.crt_on = !p.crt_on, + .Dump => p.dumpState() catch {}, + .Help => p.openHelp(id, ""), + .Restore => { + var it = std.mem.tokenizeAny(u8, txt, " \t"); _ = it.next(); // the word "Restore" const path = it.next() orelse (p.last_dump orelse return); if (path.len > p.restore_buf.len) return; @memcpy(p.restore_buf[0..path.len], path); p.restore_req = p.restore_buf[0..path.len]; }, - .kill => { + .Kill => { p.quit = true; p.emit(.quit); }, - .save => { - if (pane.file != null) p.emit(.{ .save_file = .{ .pane = @intCast(id) } }); + .Save => { + // an output buffer has no file behind it — nothing to write + if (pane.file) |f| if (!f.output) p.emit(.{ .save_file = .{ .pane = @intCast(id) } }); }, - .send => { - // terminal: run in itself. file pane: run in a shell in the - // file's directory (found by cwd, else freshly forked). - const cmd = std.mem.trim(u8, txt, " \t\r\n"); - const dst: ?usize = if (pane.file) |f| - p.ttyForDir(std.fs.path.dirname(f.path) orelse "/") - else - id; - if (dst) |di| { - p.padOutputBelowEdits(di); - p.emitWrite(di, cmd); - // a chord argument (kept left selection) rides as a - // trailing CLI argument - if (arg) |a| { - const at = std.mem.trim(u8, a, " \t\r\n"); - if (at.len > 0) { - p.emitWrite(di, " "); - p.emitWrite(di, at); - } - } - p.emitWrite(di, "\r"); - } - }, - .newcol => { + .Newcol => { const free = p.freeSlot() orelse return; if (p.ncol >= MAX_COLS) return; const nt = p.newShell(free, "") catch return; @@ -4518,7 +4681,7 @@ pub const Pardes = struct { p.layoutAppendColumn(free); p.active = free; }, - .del => { + .Del => { p.absorbVWeight(id); p.layoutRemove(id); p.deinitPane(pane); @@ -4529,7 +4692,7 @@ pub const Pardes = struct { return; }; }, - .delcol => { + .Delcol => { const f = p.layoutFindTerm(id) orelse return; var ids: [MAX_PANES]usize = undefined; const nids = p.col_n[f.col]; @@ -4547,7 +4710,7 @@ pub const Pardes = struct { return; }; }, - .tutor => { + .Tutor => { const free = p.freeSlot() orelse return; const nt = p.openTutorView(free) catch return; p.placeDoc(id, free, nt); // a doc like any other @@ -4555,6 +4718,51 @@ pub const Pardes = struct { } } + /// The Help builtin: every builtin reachable from `prefix` (empty = all + /// of them) with the keys that run it, written into an output buffer + /// (acme's +Errors) — ordinary text, so the names in it are LIVE: middle- + /// click `Tutor` there and the tutor opens. Reuses the open +Help buffer + /// instead of piling panes up, and focus follows: you asked to read it. + fn openHelp(p: *Pardes, id: usize, prefix: []const u8) void { + const pane = p.panes[id] orelse return; + const arena = p.scratch.allocator(); + var out: std.ArrayList(u8) = .empty; + if (prefix.len == 0) { + out.appendSlice(arena, "pardes builtins - SPC then the keys,\nor execute the name anywhere\n\n") catch return; + } else { + out.appendSlice(arena, "pardes builtins under SPC") catch return; + for (prefix) |c| out.appendSlice(arena, &[_]u8{ ' ', c }) catch return; + out.appendSlice(arena, "\n\n") catch return; + } + for (leader_rows) |row| { + if (!std.mem.startsWith(u8, row.path, prefix)) continue; + out.appendSlice(arena, row.line) catch return; + out.append(arena, '\n') catch return; + } + const content = p.gpa.dupe(u8, out.items) catch return; + 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; + p.setFileContent(hf, content); + hf.scroll = 0; + hp.cur_row = 0; + hp.msel.active = false; + p.active = i; + return; + } + const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); + const free = p.freeSlot() orelse { + p.gpa.free(content); + return; + }; + const np = p.openOutputView(free, dir, "+Help", content) catch { + p.gpa.free(content); + return; + }; + p.placeDoc(id, free, np); + } + /// place a fresh doc pane. Any doc already open is the split parent (the /// source if it IS one, else the first found) and the newcomer lands right /// below it, so all docs share one column. The FIRST doc of the session @@ -4613,6 +4821,7 @@ pub const Pardes = struct { .path = f.path, .content = f.content, .content_b64 = try dump.encodeBytes(arena, f.content), + .output = f.output, }, } else if (pane.image) |iv| blk: { const bytes = look.readFile(arena, iv.path) catch ""; @@ -4630,24 +4839,30 @@ pub const Pardes = struct { }, }; } else blk: { - // the stream text: whole history, prompts hidden, runs spliced + // the stream text: whole history, prompts hidden outside tty + // mode, the edit buffer standing in for the rows it covers const full = try pane.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} }); var stream_text: std.ArrayList(u8) = .empty; var lines = std.mem.splitAny(u8, full, "\n"); var prompts = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); - var row: usize = 0; + var row: i32 = 0; + var skip: i32 = 0; while (lines.next()) |raw| : (row += 1) { - if (row > 0) try stream_text.append(arena, '\n'); const is_prompt = if (pane.mode != .tty) if (prompts.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false else false; - const line = if (is_prompt) "" else raw; - if (pane.mode == .tty) { - try stream_text.appendSlice(arena, line); - } else { - try spliceRow(arena, &stream_text, pane, @intCast(row), line); + if (skip > 0) { + skip -= 1; + continue; } + if (row > 0) try stream_text.append(arena, '\n'); + if (pane.mode != .tty) if (pane.ovl) |o| if (row == o.row) { + try stream_text.appendSlice(arena, o.text); + skip = o.rows - 1; + continue; + }; + try stream_text.appendSlice(arena, if (is_prompt) "" else raw); } break :blk .{ .kind = .terminal, @@ -4752,7 +4967,7 @@ pub const Pardes = struct { const path = try gpa.dupe(u8, f.path); errdefer gpa.free(path); const pane = try p.newDocPane(i); - pane.file = .{ .path = path, .content = content, .scroll = src.scroll }; + pane.file = .{ .path = path, .content = content, .output = f.output, .scroll = src.scroll }; pane.cur_pinned = true; pane.cur_row = @intCast(src.scroll); pane.cols = @max(1, src.cols); @@ -4792,8 +5007,7 @@ pub const Pardes = struct { const pfx = p.tagPrefix(pane) catch return; if (!std.mem.startsWith(u8, saved_tag, pfx)) return; const rest = saved_tag[pfx.len..]; - const default_tail: []const u8 = if (pane.file != null) file_pane_tail else pane_tail; - if (std.mem.eql(u8, rest, default_tail)) return; + if (std.mem.eql(u8, rest, curTail(pane))) return; pane.tag_tail.appendSlice(p.gpa, rest) catch return; pane.tag_init = true; } @@ -5101,6 +5315,23 @@ pub const Pardes = struct { const pfx = try p.tagPrefix(pane); const tag = try std.fmt.allocPrint(arena, "{s}{s}", .{ pfx, curTail(pane) }); _ = s.print(tx, r.y, tw, tag, tag_style); + // pending SPC leader path, on the ACTIVE pane's tag at its RIGHT edge: + // the same rule as the `/` input — state you can see, nothing that + // disappears — but right-aligned, so it never collides with the + // builtins in the tail. Printed AFTER the tag, so a tag long enough to + // reach it loses its last columns instead: what you are about to press + // beats the path, which is still readable in the prefix. + if (active and p.leader_on) { + var ibuf: [16]u8 = @splat(' '); + @memcpy(ibuf[1..4], "SPC"); + var iw: usize = 4; + for (p.leader_keys[0..p.leader_n]) |ch| { + ibuf[iw + 1] = ch; + iw += 2; + } + const w: u16 = @intCast(iw); + if (w < tw) _ = s.print(tx + tw - w, r.y, w, ibuf[0..iw], tag_style); + } // tag-tail char selection highlight (helix v/x), inclusive [lo, hi] if (pane.tag_edit and pane.tag_sel) { const b = tagSelBounds(pane); @@ -5203,12 +5434,22 @@ pub const Pardes = struct { }; // tty colors: recolor each body cell from the emulator's own style so // shell output keeps its ansi colors (gated on the Colors toggle). + // Rows the edit buffer stands in for are OURS, not the emulator's: + // they keep the plain body style, and the rows under them are read + // from the shell row the surface actually shows there. + const off = pane.scroll(); if (p.colors_on and pane.isTerminal()) { + const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); var vr: u16 = 0; while (vr < body_h and vr < pane.rows) : (vr += 1) { + const grid = pane.gridRow(off + @as(i32, vr)) - goff; + if (pane.mode != .tty) if (pane.ovl) |o| { + if (off + @as(i32, vr) >= o.row and off + @as(i32, vr) < o.row + @as(i32, @intCast(modal.lineCount(o.text)))) continue; + }; + if (grid < 0) continue; var c: u16 = 0; while (c < tw) : (c += 1) { - const ci = pane.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(c), .y = @intCast(vr) } }) orelse continue; + const ci = pane.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(c), .y = @intCast(grid) } }) orelse continue; if (ci.cell.wide == .spacer_tail) continue; const cell = s.at(tx + c, r.y + BOX_H + vr); if (cell.default) continue; @@ -5217,24 +5458,6 @@ pub const Pardes = struct { } } - // repaint inserted cells with the plain body style so typed text reads - // exactly like normal text: the tty recolor above painted cell (c,vr) - // from emulator column c, but runs shifted the row right. - const off = pane.scroll(); - if (pane.mode != .tty) for (pane.edits[0..pane.nedits]) |*e| { - if (e.len == 0) continue; - const prow = e.row - off + @as(i32, BOX_H); - if (prow < BOX_H or prow >= r.h) continue; - const start = pane.renderedCol(e.row, e.col, 0); - var k: i32 = 0; - while (k < e.len) : (k += 1) { - const cx = start + k; - if (cx < 0 or cx >= tw) continue; - const cell = s.at(tx + @as(u16, @intCast(cx)), r.y + @as(u16, @intCast(prow))); - if (!cell.default) cell.style = body_style; - } - }; - // mouse selections (pane-local coords), one pass per button — later // buttons win on overlap. A left .done stays highlighted after release; // middle/right .done are transient (they fire their action on release). @@ -5299,17 +5522,13 @@ pub const Pardes = struct { if (active and !pane.tag_edit) { const cur = pane.vt.screens.active.cursor; if (pane.mode != .tty) { - const crow = if (pane.cur_pinned) pane.cur_row else @as(i32, @intCast(cur.y)) + off; + const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); + const crow = if (pane.cur_pinned) pane.cur_row else pane.surfRow(@as(i32, @intCast(cur.y)) + goff); const ccol = if (pane.cur_pinned) pane.cur_col else @as(i32, @intCast(cur.x)); - const coff = if (pane.cur_pinned) pane.cur_off else 0; const prow = crow - off + @as(i32, BOX_H); - // files: cur_col is content-relative, add the lineno prefix; - // insert mode maps the run anchor to its visual column; normal - // mode keeps cur_col already-visual. + // files: cur_col is content-relative, add the lineno prefix const cx = if (pane.file != null) - @as(i32, PREFIX_W) + ccol + @as(i32, coff) - pane.hscroll - else if (pane.mode == .insert) - pane.renderedCol(crow, ccol, coff) + @as(i32, PREFIX_W) + ccol - pane.hscroll else ccol; if (prow >= BOX_H and cx >= 0 and prow < r.h and cx < tw) @@ -5421,9 +5640,11 @@ pub const Pardes = struct { } /// The pane body as text. Image: blank rows (the picture draws over it). - /// File: line-numbered content from f.scroll, edits spliced like the tty. + /// File: line-numbered content from f.scroll. /// Terminal: viewport rows, padded to the grid height, prompt rows blanked - /// outside tty mode (OSC 133), typed insertion runs spliced in. + /// outside tty mode (OSC 133), the edit buffer standing in for the shell + /// rows it covers — which is where a buffer holding more lines than those + /// rows pushes the output below it down the screen. fn bodyText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]const u8 { _ = p; if (pane.image != null) { @@ -5451,10 +5672,9 @@ pub const Pardes = struct { var base: std.ArrayList(u8) = .empty; try base.appendSlice(arena, prefix); try base.appendSlice(arena, text); - try spliceRow(arena, &out, pane, abs, base.items); - } else try spliceRow(arena, &out, pane, abs, ""); - // horizontal scroll: cut hscroll columns after the gutter, - // from the fully composed row (edit splices shift with it) + try out.appendSlice(arena, base.items); + } + // horizontal scroll: cut hscroll columns after the gutter if (pane.hscroll > 0) { const cut_at = row_start + PREFIX_W; if (out.items.len > cut_at) { @@ -5466,29 +5686,44 @@ pub const Pardes = struct { return out.items; } const raw = try pane.vt.plainString(arena); - var flags: [512]bool = @splat(false); - const nrows: usize = @min(pane.rows, flags.len); - if (pane.mode != .tty) { - var it = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .viewport = .{} }, null); - var i: usize = 0; - while (it.next()) |pin| : (i += 1) { - if (i >= nrows) break; - flags[i] = pin.rowAndCell().row.semantic_prompt != .none; - } + // the viewport's shell rows, prompt rows blanked outside tty mode + var prompts = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .viewport = .{} }, null); + var vp: std.ArrayList([]const u8) = .empty; + var lines = std.mem.splitScalar(u8, raw, '\n'); + while (lines.next()) |ln| { + const is_prompt = if (pane.mode != .tty) + if (prompts.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false + else + false; + try vp.append(arena, if (is_prompt) "" else ln); } + const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); const off = pane.scroll(); + var g: i32 = pane.gridRow(off); + // the buffer can start above the viewport: drop the lines scrolled past + var skip: usize = if (pane.ovl) |o| @intCast(@max(0, off - pane.surfRow(o.row))) else 0; var out: std.ArrayList(u8) = .empty; - var lines = std.mem.splitScalar(u8, raw, '\n'); - var i: usize = 0; - while (i < nrows) : (i += 1) { - if (i > 0) try out.append(arena, '\n'); - const line = if (flags[i]) "" else lines.next() orelse ""; - if (flags[i]) _ = lines.next(); - if (pane.mode == .tty) { - try out.appendSlice(arena, line); - } else { - try spliceRow(arena, &out, pane, @as(i32, @intCast(i)) + off, line); - } + var n: usize = 0; + while (n < pane.rows) { + if (pane.mode != .tty) if (pane.ovl) |o| if (g == o.row) { + var bit = std.mem.splitScalar(u8, o.text, '\n'); + var k: usize = 0; + while (bit.next()) |ln| : (k += 1) { + if (k < skip) continue; + if (n >= pane.rows) break; + if (n > 0) try out.append(arena, '\n'); + try out.appendSlice(arena, ln); + n += 1; + } + skip = 0; + g += o.rows; + continue; + }; + if (n > 0) try out.append(arena, '\n'); + const vi = g - goff; + if (vi >= 0 and @as(usize, @intCast(vi)) < vp.items.len) try out.appendSlice(arena, vp.items[@intCast(vi)]); + n += 1; + g += 1; } return out.items; } @@ -5566,45 +5801,6 @@ fn ptyReport(handler: *ghostty_vt.TerminalStream.Handler, data: [:0]const u8) vo pane.reply_len += @intCast(n); } -/// Splice this row's insertion runs into `line` (the raw tty text for absolute -/// row `abs_row`), shifting the underlying text right rather than overwriting. -fn spliceRow(arena: std.mem.Allocator, out: *std.ArrayList(u8), pane: *Pane, abs_row: i32, line: []const u8) !void { - var idxs: [MAX_EDITS]usize = undefined; - var n: usize = 0; - for (pane.edits[0..pane.nedits], 0..) |e, i| { - if (e.row == abs_row and e.len > 0) { - idxs[n] = i; - n += 1; - } - } - // insertion-sort the runs by column (n is tiny) - var a: usize = 1; - while (a < n) : (a += 1) { - const key = idxs[a]; - var b = a; - while (b > 0 and pane.edits[idxs[b - 1]].col > pane.edits[key].col) : (b -= 1) idxs[b] = idxs[b - 1]; - idxs[b] = key; - } - var vpos: usize = 0; // original-line columns consumed so far - for (idxs[0..n]) |i| { - const e = &pane.edits[i]; - const c: usize = @intCast(@max(0, e.col)); - if (c <= line.len) { - if (c > vpos) try out.appendSlice(arena, line[vpos..c]); - vpos = c; - } else { - // run anchored past the text: emit the rest, then pad with spaces - if (vpos < line.len) { - try out.appendSlice(arena, line[vpos..]); - vpos = line.len; - } - while (vpos < c) : (vpos += 1) try out.append(arena, ' '); - } - try out.appendSlice(arena, e.text[0..e.len]); - } - if (vpos < line.len) try out.appendSlice(arena, line[vpos..]); -} - const DeviceAttrs = @typeInfo(@typeInfo(@typeInfo( @FieldType(ghostty_vt.TerminalStream.Handler.Effects, "device_attributes"), ).optional.child).pointer.child).@"fn".return_type.?; diff --git a/src/tty.zig b/src/tty.zig index 3ff714bf..2326924c 100644 --- a/src/tty.zig +++ b/src/tty.zig @@ -175,6 +175,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { .text = key.text orelse "", .ctrl = key.mods.ctrl, .alt = key.mods.alt, + .shift = key.mods.shift, } }), .mouse => |m| { const button: ?pardes.Mouse.Button = switch (m.button) { diff --git a/src/tutor.txt b/src/tutor.txt index cf9ecab0..65788c70 100644 --- a/src/tutor.txt +++ b/src/tutor.txt @@ -25,8 +25,8 @@ This tutor is in THREE parts, ordered by what's most different from editors you may know: PART 1 — the MOUSE. Acme's three buttons; nothing like Vim/Helix. - PART 2 — the TTY. A terminal is just a pane; Ctrl-b drops into it - by default. + PART 2 — the TTY. A terminal is just a pane; Ctrl-b (or Shift-Esc) + drops into it. PART 3 — the KEYS. Helix-style modal (with the Pardes differences). PRACTICE BLOCKS (part 3): the "# keys:" line lists keystrokes @@ -121,9 +121,27 @@ Kill Newcol Tutor Debug Colors NextColor Middle-click "Newcol" for a new column, "Tutor" to spawn this tutor, "Kill" to quit; Debug/Colors/NextColor toggle the stats overlay, the - syntax/ansi recolor, and the theme. + syntax/ansi recolor, and the theme. Every one of them also has a KEY + PATH under SPC (part 3.7): SPC ? lists the lot, "Help" included. Right-click a directory in any body to open a terminal there. + SEARCH: `/` in normal mode types a pattern into that pane's own tag (it + stays visible while you type; Esc abandons it). Enter searches the pane + — a file's text, a shell's scrollback, anything — for the pattern, plain + and case-insensitive, and writes one row per hit into a "+Search" pane. + That is an OUTPUT BUFFER: a file pane with no file behind it, so it has + no Save, but everything else about it is an ordinary buffer you can read, + edit, select and look in. `n`/`N` step the rows and look each one, so the + view follows along. Right-clicking a word that names no file searches for + it the same way — acme's button 3 — everywhere except a shell in tty mode, + where the click belongs to the program on the other end. On a shell with no + search armed, n/N instead step the lookable tokens in its output. + A hit in a file reads `path:LINE:COL`, the ordinary look target. A hit in + a shell or an output buffer has no file to name, so it reads `@pN:LINE:COL` + — pane N, line LINE, column COL. Looking either one goes there; the column + is optional (`main.zig:100`, `@p3:12`) and you can type one yourself + anywhere text lives. + LAYOUT: columns split the screen; windows stack within a column. Panes abut with no wasted gap — a pane's own trailing edge (its last column, or last row above the next tag) IS the resize handle: hover it and a dashed @@ -165,8 +183,10 @@ TTY (TTY) the REAL shell — prompts + typed input shown, and keys go straight to the pty as terminal input. - ESC: insert -> normal. (Esc never reaches tty.) - Ctrl-b: toggles TTY on a terminal by default — the ONLY way in or out. + ESC: insert -> normal. (A plain Esc never reaches tty.) + Ctrl-b: toggles TTY on a terminal by default; SHIFT-ESC is the same + toggle, where the host reports modifiers on Escape. Those are + the ONLY ways in or out. Use `pardes --tty-toggle=g` (or another letter) to make Ctrl-g the toggle instead. Entering is tty-native: if the shell is at a prompt and your modal cursor sits on the input line, it first @@ -201,9 +221,9 @@ on whichever selection is active (else the current line). - NO verb+noun (Vim's dw, cw). Motions only MOVE. To delete a word, select it (`v` then motions, or `x` for whole lines) then `d`. - - ESC never reaches tty; it only does insert -> normal. Dropping a - terminal into the live shell is the tty toggle (Ctrl-b by default; - see Part 2). + - A plain ESC never reaches tty; it only does insert -> normal. + Dropping a terminal into the live shell is the tty toggle (Ctrl-b + by default, or Shift-Esc; see Part 2). - Enter / Tab in normal mirror the mouse: Enter = look (open the path under the cursor), Tab = execute. `:` runs a command from the tag (see Part 1). `u` undo, `U` redo. @@ -432,6 +452,31 @@ (No practice blocks: these are viewport/layout, not text edits.) +----------------------------------------------------------------- += 3.7 SPC — THE LEADER = +----------------------------------------------------------------- + + Every builtin has a NAME you can execute anywhere text lives, and a + KEY PATH you can press. SPC in normal mode starts the path; the keys + you have typed so far show at the right edge of the pane's tag until + the sequence fires. Esc abandons it — so does any key that leads + nowhere, rather than leaving the next keystroke armed. + + SPC ? Help: every builtin and the keys that run it + SPC k Kill (quit) SPC d Del (close this pane) + SPC w Save (vim's :w) SPC h t Tutor (this file) + SPC c n / c d Newcol / Delcol + SPC t d/c/n/r Debug / Colors / NextColor / Crt toggles + SPC s d / s r Dump / Restore the session + + `?` works at ANY depth: SPC ? lists everything, SPC h ? lists only + what the "h" group holds. Help writes into a "+Help" OUTPUT BUFFER, + the same kind of pane "/" search results land in — ordinary text, so + the names in it are live: middle-click "Tutor" there and it opens. + + (No practice blocks: these run builtins, not text edits.) + + ================================================================= = SUMMARY = ================================================================= @@ -446,13 +491,15 @@ LOOK opens URLs or embedded tracked .zig source (read-only) published TTY dump lists every source; tree-sitter colors it tag/separator start = left drag; touch HUD requires Debug - TTY: a terminal IS a pane; Ctrl-b toggles the live shell by default, + TTY: a terminal IS a pane; Ctrl-b (or Shift-Esc) toggles the shell, landing its cursor where you navigated (prompt start if the input is empty, mid-text otherwise) KEYS (helix): h j k l w b e 0 $ ^ gg G v x d c y p i a I A o O u undo U redo Enter=look Tab=execute : = the tag as a command line (motions, Enter, back) zt zz zb Ctrl-d/u Ctrl-f Ctrl-w hjkl Alt-n/c + SPC = the leader: a key path runs a builtin (SPC ? lists + them; SPC k Kill, SPC d Del, SPC w Save, SPC h t Tutor) vs Helix: no multi-cursor; selection is LINE-first (x), plus v chars. vs Vim: no verb+noun (dw); motions only move; Esc never reaches tty. @@ -461,7 +508,7 @@ shell's own prompt to position you precisely. To spawn THIS tutor again from anywhere: middle-click "Tutor" in the - top bar (next to Kill / Newcol). + top bar (next to Kill / Newcol), or press SPC h t. Quit the tutor: this is a file pane — `:q` isn't wired; close the window (middle-click "Del" in its tag), "Kill" (top bar) to quit diff --git a/test/hxcases/parity-waivers.jsonl b/test/hxcases/parity-waivers.jsonl new file mode 100644 index 00000000..d48f4670 --- /dev/null +++ b/test/hxcases/parity-waivers.jsonl @@ -0,0 +1,10 @@ +{"name":"ctrl-b-page","reason":"not a parity bug: Ctrl-b IS pardes's tty toggle (Options.tty_toggle = 'b'), so on a shell pane it enters raw tty mode instead of paging the view. Deliberate binding, same class as the alt-c-window-op waiver in the helix suite; Ctrl-u/Ctrl-d and PageUp cover half/full page scrolling on terminals."} +{"name":"ctrl-f-page","reason":"viewport geometry: a file pane's view scrolls anywhere inside its line count, a terminal's is the vt's and cannot move below the live grid bottom, so a full-page move clamps and the cursor snaps into a different scrolloff band. The edit model is not involved - no text differs, only where the view landed."} +{"name":"ctrl-f-small","reason":"viewport geometry: same as ctrl-f-page with a document shorter than the pane, where the terminal's view cannot scroll at all and the cursor snaps to the top scrolloff margin instead of the last line."} +{"name":"pgdn-basic","reason":"viewport geometry: PageDown is the same view scroll as Ctrl-f, and clamps at the terminal's live grid bottom. Text identical, view position differs."} +{"name":"z-pgdn-alias","reason":"viewport geometry: z-prefixed PageDown aliases the Ctrl-f view scroll, so it clamps at the terminal's live grid bottom exactly like pgdn-basic."} +{"name":"ins-pgdn","reason":"viewport geometry: insert-mode PageDown is bound to the same view scroll as normal mode, so it clamps at the terminal's live grid bottom. Text identical, view position differs."} +{"name":"zj-snaps-cursor","reason":"viewport geometry: zj scrolls the view one row and snaps the cursor back into the scrolloff band; on a terminal whose view is already at the vt's bottom the scroll is a no-op, so the cursor snaps from a different band."} +{"name":"zj-noop-in-band","reason":"viewport geometry: same as zj-snaps-cursor - the terminal's view cannot scroll past the live grid bottom, so the cursor's band differs by one row."} +{"name":"zk-bottom-snap","reason":"viewport geometry: zk scrolls the view up one row and snaps the cursor; the terminal's starting view offset differs because it could not scroll as far down in the first place."} +{"name":"unindent-tab","reason":"the case text is not representable in a pty pane: a literal TAB fed through the terminal is expanded to the next tab stop by the emulator, so the shell rows hold spaces and never the tab the file pane unindents. Not an editing divergence - unindent-basic, unindent-partial, unindent-realign and unindent-cursor all match; only a byte a terminal cannot hold verbatim."} diff --git a/test/hxcases/parity.jsonl b/test/hxcases/parity.jsonl new file mode 100644 index 00000000..ee09dac1 --- /dev/null +++ b/test/hxcases/parity.jsonl @@ -0,0 +1,80 @@ +{"name": "type-midline", "text": "abc def\n", "keys": "lliXY"} +{"name": "type-linestart", "text": "abc\ndef\n", "keys": "iZZ"} +{"name": "type-lineend", "text": "abc\ndef\n", "keys": "AZZ"} +{"name": "type-second-line", "text": "abc\ndef\n", "keys": "jlliQ"} +{"name": "type-append", "text": "abc\n", "keys": "aX"} +{"name": "type-insert-at-first-nonws", "text": " abc\n", "keys": "glIX"} +{"name": "enter-splits-line", "text": "abcdef\n", "keys": "lli"} +{"name": "enter-at-line-end", "text": "abc\ndef\n", "keys": "A"} +{"name": "enter-at-line-start", "text": "abc\ndef\n", "keys": "i"} +{"name": "enter-then-type", "text": "abcdef\n", "keys": "lliXY"} +{"name": "enter-twice", "text": "abcdef\n", "keys": "lli"} +{"name": "enter-last-line", "text": "abc\ndef\n", "keys": "jAZZ"} +{"name": "enter-keeps-indent", "text": " abc\n", "keys": "AX"} +{"name": "enter-mid-indented", "text": " abcdef\n", "keys": "6li"} +{"name": "multiline-insert", "text": "abc\n", "keys": "lliabc"} +{"name": "multiline-insert-midline", "text": "abcdef\n", "keys": "lliXYZ"} +{"name": "open-below", "text": "abc\ndef\n", "keys": "oX"} +{"name": "open-below-last", "text": "abc\ndef\n", "keys": "joX"} +{"name": "open-above", "text": "abc\ndef\n", "keys": "OX"} +{"name": "open-above-second", "text": "abc\ndef\n", "keys": "jOX"} +{"name": "open-below-indent", "text": " abc\n", "keys": "oX"} +{"name": "open-below-count", "text": "abc\ndef\n", "keys": "2oX"} +{"name": "open-above-count", "text": "abc\ndef\n", "keys": "j2OX"} +{"name": "bs-joins-lines", "text": "abc\ndef\n", "keys": "ji"} +{"name": "bs-joins-then-types", "text": "abc\ndef\n", "keys": "jiX"} +{"name": "bs-midline", "text": "abcdef\n", "keys": "lli"} +{"name": "bs-joins-twice", "text": "ab\ncd\nef\n", "keys": "jji"} +{"name": "bs-at-doc-start", "text": "abc\n", "keys": "i"} +{"name": "ctrl-h-joins", "text": "abc\ndef\n", "keys": "ji"} +{"name": "del-at-line-end", "text": "abc\ndef\n", "keys": "A"} +{"name": "del-midline", "text": "abcdef\n", "keys": "lli"} +{"name": "del-joins-twice", "text": "ab\ncd\nef\n", "keys": "A"} +{"name": "enter-then-bs", "text": "abcdef\n", "keys": "lli"} +{"name": "tab-insert", "text": "abc\n", "keys": "i"} +{"name": "tab-midline", "text": "abcdef\n", "keys": "lli"} +{"name": "ctrl-w-kills-word", "text": "alpha beta\n", "keys": "A"} +{"name": "ctrl-w-crosses-newline", "text": "alpha\nbeta\n", "keys": "ji"} +{"name": "ctrl-u-kills-to-start", "text": "alpha beta\n", "keys": "A"} +{"name": "ctrl-k-kills-to-end", "text": "alpha beta\n", "keys": "lli"} +{"name": "insert-home-end", "text": "abc def\n", "keys": "lliXY"} +{"name": "insert-arrows", "text": "abc\ndef\n", "keys": "iX"} +{"name": "d-char", "text": "abc\n", "keys": "d"} +{"name": "d-line-selection", "text": "aa\nbb\ncc\n", "keys": "jxd"} +{"name": "d-across-lines", "text": "abc\ndef\nghi\n", "keys": "lvjd"} +{"name": "d-across-two-lines", "text": "abc\ndef\nghi\n", "keys": "lv2jd"} +{"name": "d-whole-first-line", "text": "abc\ndef\n", "keys": "xd"} +{"name": "d-two-lines", "text": "aa\nbb\ncc\ndd\n", "keys": "2xd"} +{"name": "c-across-lines", "text": "abc\ndef\nghi\n", "keys": "lvjcZ"} +{"name": "c-line-selection", "text": "aa\nbb\ncc\n", "keys": "jxcZ"} +{"name": "c-char", "text": "abc\n", "keys": "cZ"} +{"name": "c-then-enter", "text": "abc\ndef\n", "keys": "lvjcXY"} +{"name": "d-then-o", "text": "abc\ndef\nghi\n", "keys": "jxdoZ"} +{"name": "d-then-type", "text": "abcdef\n", "keys": "lvldiZ"} +{"name": "r-replace-char", "text": "abc\n", "keys": "lrZ"} +{"name": "r-replace-selection", "text": "abc def\n", "keys": "vlrZ"} +{"name": "tilde-case", "text": "abc\n", "keys": "vll~"} +{"name": "yank-paste-charwise", "text": "abc def\n", "keys": "vlyp"} +{"name": "yank-paste-linewise", "text": "aa\nbb\n", "keys": "xyp"} +{"name": "undo-after-typing", "text": "abc\n", "keys": "liXYu"} +{"name": "undo-after-enter", "text": "abcdef\n", "keys": "lliu"} +{"name": "undo-redo-after-enter", "text": "abcdef\n", "keys": "lliuU"} +{"name": "undo-after-open", "text": "abc\n", "keys": "oXu"} +{"name": "undo-after-delete", "text": "abc\ndef\n", "keys": "xdu"} +{"name": "type-then-motion", "text": "abc\ndef\n", "keys": "liXjw"} +{"name": "enter-then-motion", "text": "abcdef\nghi\n", "keys": "llige"} +{"name": "enter-then-goto-last", "text": "ab\ncd\n", "keys": "AXge"} +{"name": "open-then-goto-last", "text": "ab\ncd\n", "keys": "ozzge"} +{"name": "md-second-line", "text": "aa\nb(cd)e\n", "keys": "j3lmd("} +{"name": "md-third-line", "text": "aa\nbb\nx(yz)w\n", "keys": "2j3lmd("} +{"name": "mr-second-line", "text": "aa\nb(cd)e\n", "keys": "j3lmr(["} +{"name": "mr-quote-third-line", "text": "aa\nbb\nc 'de' f\n", "keys": "2j4lmr'\""} +{"name": "ms-second-line", "text": "aa\nbcd\n", "keys": "jvlms("} +{"name": "indent-second-line", "text": "aa\nbb\ncc\n", "keys": "j"} +{"name": "indent-span-later-lines", "text": "aa\nbb\ncc\ndd\n", "keys": "jx2x"} +{"name": "unindent-second-line", "text": "aa\n bb\n", "keys": "j"} +{"name": "J-second-line", "text": "aa\nbb\ncc\n", "keys": "jJ"} +{"name": "J-sel-later-lines", "text": "aa\nbb\ncc\ndd\n", "keys": "jxxJ"} +{"name": "d-on-nl-second-line", "text": "aa\nbb\ncc\n", "keys": "jlld"} +{"name": "adjust-number-second-line", "text": "aa\nx 41 y\n", "keys": "jw"} +{"name": "R-replace-second-line", "text": "aa\nbb\ncc\n", "keys": "vlyjvlR"} diff --git a/test/hxdiff.zig b/test/hxdiff.zig index 2170961e..dc901263 100644 --- a/test/hxdiff.zig +++ b/test/hxdiff.zig @@ -10,13 +10,27 @@ // // zig build hxdiff (diff the checked-in corpus) // zig build hxdiff -- (results to stdout, no diff) +// zig build hxparity (file-vs-pty parity, both corpora) // ./zig-out/bin/pardes-hxdiff [goldens.jsonl [waivers.jsonl]] +// ./zig-out/bin/pardes-hxdiff --parity [--waivers w.jsonl] ... // // DIFF MODE (goldens given): every result is compared field-by-field // (text/mode/cursor/anchor) against the same-named golden line. Waivers // ({"name":...,"reason":...}) exempt named cases — each must carry a reason. // Any unwaivered mismatch prints a per-case report and exits 1. // +// PARITY MODE (--parity): no goldens at all. Each case is run TWICE, once in +// a file pane and once in a pty pane, over the same starting text and the +// same keys, and the two result lines must be identical. That is the whole +// contract of "editing a shell pane behaves like editing a text file": the +// oracle is the file pane itself, so it cannot drift. The case's own "pane" +// field is ignored, and SEVERAL case files may be given — the gate runs the +// whole helix corpus, not just the editing cases, because a parity suite +// that only covers what its author remembered cannot catch the next +// regression. Divergences that are not editing (pardes key bindings, pty +// viewport geometry, text a terminal cannot hold verbatim) are waived by +// name in test/hxcases/parity-waivers.jsonl, each with its reason. +// // Viewport is fixed at 80x24, matching the helix harness: one pane = 22 body // rows (topbar + tag take two; helix: statusline + commandline take two). // pane "file" swaps the boot pane for a file pane holding the case text @@ -43,21 +57,31 @@ pub fn main(init: std.process.Init) !void { const arena = arena_state.allocator(); const args = try init.minimal.args.toSlice(init.arena.allocator()); - var cases_path: ?[]const u8 = null; + var parity = false; var goldens_path: ?[]const u8 = null; var waivers_path: ?[]const u8 = null; + var case_paths: std.ArrayList([]const u8) = .empty; + var want_waivers = false; for (args[1..]) |a| { - if (cases_path == null) { - cases_path = a; + if (want_waivers) { + waivers_path = a; + want_waivers = false; + } else if (std.mem.eql(u8, a, "--parity")) { + parity = true; + } else if (std.mem.eql(u8, a, "--waivers")) { + want_waivers = true; + } else if (parity or case_paths.items.len == 0) { + // diff mode is positional (cases, goldens, waivers); parity mode + // takes any number of case files and nothing else + case_paths.append(arena, a) catch fatal("oom", .{}); } else if (goldens_path == null) { goldens_path = a; } else if (waivers_path == null) { waivers_path = a; } else fatal("usage: pardes-hxdiff [goldens.jsonl [waivers.jsonl]]", .{}); } - const path = cases_path orelse fatal("usage: pardes-hxdiff [goldens.jsonl [waivers.jsonl]]", .{}); - - const src = readFileAlloc(arena, path) catch |e| fatal("read {s}: {s}", .{ path, @errorName(e) }); + if (case_paths.items.len == 0) + fatal("usage: pardes-hxdiff [--parity] ... [goldens.jsonl [waivers.jsonl]]", .{}); // goldens + waivers, keyed by name (diff mode) var goldens: std.StringHashMap(Result) = .init(arena); @@ -94,38 +118,56 @@ pub fn main(init: std.process.Init) !void { var n_cases: usize = 0; var n_bad: usize = 0; var n_waived: usize = 0; - var lines = std.mem.splitScalar(u8, src, '\n'); - var lineno: usize = 0; - while (lines.next()) |raw| { - lineno += 1; - const line = std.mem.trim(u8, raw, " \t\r"); - if (line.len == 0) continue; - const case = std.json.parseFromSliceLeaky(Case, arena, line, .{ .ignore_unknown_fields = true }) catch |e| - fatal("{s}:{d}: bad case line: {s}", .{ path, lineno, @errorName(e) }); - n_cases += 1; - const res = runCase(arena, case) catch |e| fatal("case {s}: {s}", .{ case.name, @errorName(e) }); - if (goldens_path == null) { - const jl = std.json.Stringify.valueAlloc(arena, res, .{}) catch fatal("oom", .{}); - out.appendSlice(arena, jl) catch fatal("oom", .{}); - out.append(arena, '\n') catch fatal("oom", .{}); - continue; - } - const g = goldens.get(case.name) orelse fatal("no golden for case {s} (regen goldens?)", .{case.name}); - const diff = diffResult(arena, g, res); - if (diff.len == 0) continue; - if (waivers.get(case.name)) |reason| { - n_waived += 1; - std.debug.print("WAIVED {s}: {s}\n", .{ case.name, reason }); - continue; + for (case_paths.items) |path| { + const src = readFileAlloc(arena, path) catch |e| fatal("read {s}: {s}", .{ path, @errorName(e) }); + var lines = std.mem.splitScalar(u8, src, '\n'); + var lineno: usize = 0; + while (lines.next()) |raw| { + lineno += 1; + const line = std.mem.trim(u8, raw, " \t\r"); + if (line.len == 0) continue; + const case = std.json.parseFromSliceLeaky(Case, arena, line, .{ .ignore_unknown_fields = true }) catch |e| + fatal("{s}:{d}: bad case line: {s}", .{ path, lineno, @errorName(e) }); + n_cases += 1; + if (parity) { + // the file pane IS the oracle: same text, same keys, both kinds + const want = runCase(arena, case, "file") catch |e| fatal("case {s} (file): {s}", .{ case.name, @errorName(e) }); + const got = runCase(arena, case, "tty") catch |e| fatal("case {s} (tty): {s}", .{ case.name, @errorName(e) }); + const diff = diffResult(arena, want, got, "file", "pty"); + if (diff.len == 0) continue; + if (waivers.get(case.name)) |reason| { + n_waived += 1; + std.debug.print("WAIVED {s}: {s}\n", .{ case.name, reason }); + continue; + } + n_bad += 1; + std.debug.print("MISMATCH {s} (keys: {s})\n{s}", .{ case.name, case.keys, diff }); + continue; + } + const res = runCase(arena, case, case.pane) catch |e| fatal("case {s}: {s}", .{ case.name, @errorName(e) }); + if (goldens_path == null) { + const jl = std.json.Stringify.valueAlloc(arena, res, .{}) catch fatal("oom", .{}); + out.appendSlice(arena, jl) catch fatal("oom", .{}); + out.append(arena, '\n') catch fatal("oom", .{}); + continue; + } + const g = goldens.get(case.name) orelse fatal("no golden for case {s} (regen goldens?)", .{case.name}); + const diff = diffResult(arena, g, res, "helix", "pardes"); + if (diff.len == 0) continue; + if (waivers.get(case.name)) |reason| { + n_waived += 1; + std.debug.print("WAIVED {s}: {s}\n", .{ case.name, reason }); + continue; + } + n_bad += 1; + std.debug.print("MISMATCH {s} (keys: {s})\n{s}", .{ case.name, case.keys, diff }); } - n_bad += 1; - std.debug.print("MISMATCH {s} (keys: {s})\n{s}", .{ case.name, case.keys, diff }); } - if (goldens_path == null) { + if (!parity and goldens_path == null) { try std.Io.File.stdout().writeStreamingAll(init.io, out.items); return; } - std.debug.print("hxdiff: {d} cases, {d} mismatches, {d} waived\n", .{ n_cases, n_bad, n_waived }); + std.debug.print("{s}: {d} cases, {d} mismatches, {d} waived\n", .{ if (parity) "hxparity" else "hxdiff", n_cases, n_bad, n_waived }); if (n_bad > 0) std.process.exit(1); } @@ -152,20 +194,21 @@ const Waiver = struct { reason: []const u8, }; -/// field-by-field compare; returns a human-readable report ("" = match) -fn diffResult(arena: std.mem.Allocator, want: Result, got: Result) []const u8 { +/// field-by-field compare; returns a human-readable report ("" = match). +/// `wl`/`gl` name the two sides (helix vs pardes, or file vs pty). +fn diffResult(arena: std.mem.Allocator, want: Result, got: Result, wl: []const u8, gl: []const u8) []const u8 { var buf: std.ArrayList(u8) = .empty; if (!std.mem.eql(u8, want.text, got.text)) { - appendf(arena, &buf, " text: helix {f} != pardes {f}\n", .{ std.json.fmt(want.text, .{}), std.json.fmt(got.text, .{}) }); + appendf(arena, &buf, " text: {s} {f} != {s} {f}\n", .{ wl, std.json.fmt(want.text, .{}), gl, std.json.fmt(got.text, .{}) }); } if (!std.mem.eql(u8, want.mode, got.mode)) { - appendf(arena, &buf, " mode: helix {s} != pardes {s}\n", .{ want.mode, got.mode }); + appendf(arena, &buf, " mode: {s} {s} != {s} {s}\n", .{ wl, want.mode, gl, got.mode }); } if (want.cursor.row != got.cursor.row or want.cursor.col != got.cursor.col) { - appendf(arena, &buf, " cursor: helix ({d},{d}) != pardes ({d},{d})\n", .{ @as(i64, want.cursor.row), @as(i64, want.cursor.col), @as(i64, got.cursor.row), @as(i64, got.cursor.col) }); + appendf(arena, &buf, " cursor: {s} ({d},{d}) != {s} ({d},{d})\n", .{ wl, @as(i64, want.cursor.row), @as(i64, want.cursor.col), gl, @as(i64, got.cursor.row), @as(i64, got.cursor.col) }); } if (want.anchor.row != got.anchor.row or want.anchor.col != got.anchor.col) { - appendf(arena, &buf, " anchor: helix ({d},{d}) != pardes ({d},{d})\n", .{ @as(i64, want.anchor.row), @as(i64, want.anchor.col), @as(i64, got.anchor.row), @as(i64, got.anchor.col) }); + appendf(arena, &buf, " anchor: {s} ({d},{d}) != {s} ({d},{d})\n", .{ wl, @as(i64, want.anchor.row), @as(i64, want.anchor.col), gl, @as(i64, got.anchor.row), @as(i64, got.anchor.col) }); } return buf.items; } @@ -175,7 +218,7 @@ fn appendf(arena: std.mem.Allocator, buf: *std.ArrayList(u8), comptime fmt: []co buf.appendSlice(arena, s) catch {}; } -fn runCase(arena: std.mem.Allocator, case: Case) !Result { +fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result { // fresh core per case: the tty_only boot — one shell pane, tty mode, no // greeting. Live-session contract: sizes arrive as the first resize event. const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); @@ -183,9 +226,9 @@ fn runCase(arena: std.mem.Allocator, case: Case) !Result { core.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); pump(core); - if (std.mem.eql(u8, case.pane, "file")) { + if (std.mem.eql(u8, pane_kind, "file")) { _ = try core.hxOpenFileContent(case.text); - } else if (std.mem.eql(u8, case.pane, "tty")) { + } else if (std.mem.eql(u8, pane_kind, "tty")) { // the harness is the pty here: cook \n to \r\n the way ONLCR would var cooked: std.ArrayList(u8) = .empty; for (case.text) |ch| { @@ -205,7 +248,7 @@ fn runCase(arena: std.mem.Allocator, case: Case) !Result { pane.cur_col = 0; pane.cur_pinned = true; } - } else fatal("case {s}: unknown pane {s}", .{ case.name, case.pane }); + } else fatal("case {s}: unknown pane {s}", .{ case.name, pane_kind }); // keys: helix notation — printables verbatim, <...> specials var ki: usize = 0; @@ -280,15 +323,16 @@ fn runCase(arena: std.mem.Allocator, case: Case) !Result { text = f.content; } else { // the tty motion surface, dumped honestly: whole scrollback+grid, - // prompt rows blanked, typed runs spliced — plus the trailing '\n' - // ghostty's dump trims off the cursor's empty final row + // prompt rows blanked, the edit buffer standing in. The surface keeps + // the cursor's empty row below the output (see Pardes.shellRows), so + // its line list is exactly a file content's — join it and you have the + // same bytes, trailing newline included, with nothing to patch up. const pl = try core.paneCursorLines(pane); var buf: std.ArrayList(u8) = .empty; for (pl.lines, 0..) |ln, i| { if (i > 0) try buf.append(arena, '\n'); try buf.appendSlice(arena, ln); } - if (buf.items.len > 0 and buf.items[buf.items.len - 1] != '\n') try buf.append(arena, '\n'); text = buf.items; } var anchor: Pos = .{ .row = pane.cur_row, .col = pane.cur_col }; diff --git a/test/snapshots/chordcut.golden b/test/snapshots/chordcut.golden index eefc6fa3..d4496d09 100644 --- a/test/snapshots/chordcut.golden +++ b/test/snapshots/chordcut.golden @@ -379,8 +379,8 @@ | 4 line 4line | 5 lline 5 | 6 canon -echo; printf '\x f.txt -| 7 ne; printf '\x1b[?1000l BOO -| 8 +| 7 BOO +| | | | diff --git a/test/snapshots/edit.golden b/test/snapshots/edit.golden index 6815dbd6..2d11868f 100644 --- a/test/snapshots/edit.golden +++ b/test/snapshots/edit.golden @@ -1,4 +1,4 @@ -== snap inserted grid=100x30 cursor=4,9 +== snap inserted grid=100x30 cursor=16,9 |Kill Newcol Tutor Debug Colors NextColor Crt Dump | NOR /tmp/pardes-snap/edit/cwd Del NOR /tmp/pardes-snap/edit/cwd Del | diff --git a/test/snapshots/exec-bigdrag.golden b/test/snapshots/exec-bigdrag.golden index 5ea48200..19189267 100644 --- a/test/snapshots/exec-bigdrag.golden +++ b/test/snapshots/exec-bigdrag.golden @@ -1,4 +1,4 @@ -== snap alive grid=200x60 cursor=104,37 +== snap alive grid=200x60 cursor=119,37 |Kill Newcol Tutor Debug Colors NextColor Crt Dump | NOR /tmp/pardes-snap/exec-bigdrag/cwd Del NOR /tmp/pardes-snap/exec-bigdrag/cwd Del | bash: line: command not found diff --git a/test/snapshots/exec.golden b/test/snapshots/exec.golden index 5f1470c9..00211a42 100644 --- a/test/snapshots/exec.golden +++ b/test/snapshots/exec.golden @@ -1,4 +1,4 @@ -== snap exec grid=100x30 cursor=4,7 +== snap exec grid=100x30 cursor=16,7 |Kill Newcol Tutor Debug Colors NextColor Crt Dump | NOR /tmp/pardes-snap/exec/cwd Del NOR /tmp/pardes-snap/exec/cwd Del | diff --git a/test/snapshots/fsearch.golden b/test/snapshots/fsearch.golden index 8a7aabd8..8ea0ffbf 100644 --- a/test/snapshots/fsearch.golden +++ b/test/snapshots/fsearch.golden @@ -69,19 +69,19 @@ | 4 l4 | 5 l5 | 6 MARK b g.txt -| 7 l7 /tmp/pardes-snap/fsearch/cwd/g.txt:2:MARK a -| 8 l8 /tmp/pardes-snap/fsearch/cwd/g.txt:6:MARK b -| 9 l9 /tmp/pardes-snap/fsearch/cwd/g.txt:10:MARK c +| 7 l7 +| 8 l8 +| 9 l9 | 10 MARK c | 11 l11 | 12 l12 | | -| NOR /tmp/pardes-snap/fsearch/cwd Del -| -| g.txt -| -| +| NOR /tmp/pardes-snap/fsearch/cwd/+Search Del NOR /tmp/pardes-snap/fsearch/cwd Del +| 1 /tmp/pardes-snap/fsearch/cwd/g.txt:2:1 MARK a +| 2 /tmp/pardes-snap/fsearch/cwd/g.txt:6:1 MARK b g.txt +| 3 /tmp/pardes-snap/fsearch/cwd/g.txt:10:1 MARK c +| 4 | | | @@ -100,19 +100,19 @@ | 4 l4 | 5 l5 | 6 MARK b g.txt -| 7 l7 /tmp/pardes-snap/fsearch/cwd/g.txt:2:MARK a -| 8 l8 /tmp/pardes-snap/fsearch/cwd/g.txt:6:MARK b -| 9 l9 /tmp/pardes-snap/fsearch/cwd/g.txt:10:MARK c +| 7 l7 +| 8 l8 +| 9 l9 | 10 MARK c | 11 l11 | 12 l12 | | -| NOR /tmp/pardes-snap/fsearch/cwd Del -| -| g.txt -| -| +| NOR /tmp/pardes-snap/fsearch/cwd/+Search Del NOR /tmp/pardes-snap/fsearch/cwd Del +| 1 /tmp/pardes-snap/fsearch/cwd/g.txt:2:1 MARK a +| 2 /tmp/pardes-snap/fsearch/cwd/g.txt:6:1 MARK b g.txt +| 3 /tmp/pardes-snap/fsearch/cwd/g.txt:10:1 MARK c +| 4 | | | @@ -131,7 +131,7 @@ |5: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d, 120-121 d,#525252, 122-179 d,d, |6: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d, 120-121 d,#525252, 122-179 d,d, |7: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d, 120-121 d,#525252, 122-179 d,d, -|8: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 #d8c8a8,#4a3a1e, 120-121 d,#525252, 122-179 d,d, +|8: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d, 120-121 d,#525252, 122-179 d,d, |9: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d, 120-121 d,#525252, 122-179 d,d, |10: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d, 120-121 d,#525252, 122-179 d,d, |11: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d, 120-121 d,#525252, 122-179 d,d, @@ -139,8 +139,8 @@ |13: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d, 120-121 d,#525252, 122-179 d,d, |14: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d, 120-121 d,#525252, 122-179 d,d, |15: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d, 120-121 d,#525252, 122-179 d,d, -|16: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#383c54, 62-97 #969696,#2c2a3e, 98-119 d,#2c2a3e, 120-121 d,#525252, 122-179 d,d, -|17: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d, 120-121 d,#525252, 122-179 d,d, +|16: 0-1 d,#383c54, 2-45 #969696,#2c2a3e, 46-59 d,#2c2a3e, 60-61 d,#383c54, 62-97 #969696,#2c2a3e, 98-119 d,#2c2a3e, 120-121 d,#525252, 122-179 d,d, +|17: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 #d8c8a8,#4a3a1e, 60-61 d,#525252, 62-119 d,d, 120-121 d,#525252, 122-179 d,d, |18: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d, 120-121 d,#525252, 122-179 d,d, |19: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d, 120-121 d,#525252, 122-179 d,d, |20: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-59 d,d, 60-61 d,#525252, 62-119 d,d, 120-121 d,#525252, 122-179 d,d, @@ -162,17 +162,20 @@ | 4 l4 | 5 l5 | 6 MARK b g.txt -| 7 l7 /tmp/pardes-snap/fsearch/cwd/g.txt:2:MARK a -| 8 l8 /tmp/pardes-snap/fsearch/cwd/g.txt:6:MARK b -| 9 l9 /tmp/pardes-snap/fsearch/cwd/g.txt:10:MARK c +| 7 l7 +| 8 l8 +| 9 l9 | 10 MARK c | 11 l11 | 12 l12 | | -| NOR /tmp/pardes-snap/fsearch/cwd Del +| NOR /tmp/pardes-snap/fsearch/cwd/+Search Del NOR /tmp/pardes-snap/fsearch/cwd Del +| 1 /tmp/pardes-snap/fsearch/cwd/g.txt:2:1 MARK a +| 2 /tmp/pardes-snap/fsearch/cwd/g.txt:6:1 MARK b g.txt +| 3 /tmp/pardes-snap/fsearch/cwd/g.txt:10:1 MARK c +| 4 | -| g.txt | | | @@ -181,31 +184,28 @@ | | | -| -| -| -== snap third grid=180x30 cursor=7,11 +== snap third grid=180x30 cursor=7,9 |Kill Newcol Tutor Debug Colors NextColor Crt Dump | NOR /tmp/pardes-snap/fsearch/cwd/g.txt Save Del NOR /tmp/pardes-snap/fsearch/cwd Del NOR /tmp/pardes-snap/fsearch/cwd Del -| 1 l1 -| 2 MARK a g.txt g.txt | 3 l3 -| 4 l4 +| 4 l4 g.txt g.txt | 5 l5 -| 6 MARK b g.txt -| 7 l7 /tmp/pardes-snap/fsearch/cwd/g.txt:2:MARK a -| 8 l8 /tmp/pardes-snap/fsearch/cwd/g.txt:6:MARK b -| 9 l9 /tmp/pardes-snap/fsearch/cwd/g.txt:10:MARK c +| 6 MARK b +| 7 l7 +| 8 l8 g.txt +| 9 l9 | 10 MARK c | 11 l11 | 12 l12 | | -| NOR /tmp/pardes-snap/fsearch/cwd Del -| -| g.txt | | +| NOR /tmp/pardes-snap/fsearch/cwd/+Search Del NOR /tmp/pardes-snap/fsearch/cwd Del +| 1 /tmp/pardes-snap/fsearch/cwd/g.txt:2:1 MARK a +| 2 /tmp/pardes-snap/fsearch/cwd/g.txt:6:1 MARK b g.txt +| 3 /tmp/pardes-snap/fsearch/cwd/g.txt:10:1 MARK c +| 4 | | | @@ -224,19 +224,19 @@ | 4 l4 | 5 l5 | 6 MARK b g.txt -| 7 l7 /tmp/pardes-snap/fsearch/cwd/g.txt:2:MARK a -| 8 l8 /tmp/pardes-snap/fsearch/cwd/g.txt:6:MARK b -| 9 l9 /tmp/pardes-snap/fsearch/cwd/g.txt:10:MARK c +| 7 l7 +| 8 l8 +| 9 l9 | 10 MARK c | 11 l11 | 12 l12 | | -| NOR /tmp/pardes-snap/fsearch/cwd Del -| -| g.txt -| -| +| NOR /tmp/pardes-snap/fsearch/cwd/+Search Del NOR /tmp/pardes-snap/fsearch/cwd Del +| 1 /tmp/pardes-snap/fsearch/cwd/g.txt:2:1 MARK a +| 2 /tmp/pardes-snap/fsearch/cwd/g.txt:6:1 MARK b g.txt +| 3 /tmp/pardes-snap/fsearch/cwd/g.txt:10:1 MARK c +| 4 | | | @@ -255,19 +255,19 @@ | 4 l4 | 5 l5 | 6 MARK b g.txt -| 7 l7 /tmp/pardes-snap/fsearch/cwd/g.txt:2:MARK a -| 8 l8 /tmp/pardes-snap/fsearch/cwd/g.txt:6:MARK b -| 9 l9 /tmp/pardes-snap/fsearch/cwd/g.txt:10:MARK c +| 7 l7 +| 8 l8 +| 9 l9 | 10 MARK c | 11 l11 | 12 l12 | | -| NOR /tmp/pardes-snap/fsearch/cwd Del -| -| g.txt -| -| +| NOR /tmp/pardes-snap/fsearch/cwd/+Search Del NOR /tmp/pardes-snap/fsearch/cwd Del +| 1 /tmp/pardes-snap/fsearch/cwd/g.txt:2:1 MARK a +| 2 /tmp/pardes-snap/fsearch/cwd/g.txt:6:1 MARK b g.txt +| 3 /tmp/pardes-snap/fsearch/cwd/g.txt:10:1 MARK c +| 4 | | | @@ -286,16 +286,16 @@ | 4 l4 | 5 l5 | 6 MARK b g.txt -| 7 l7 /tmp/pardes-snap/fsearch/cwd/g.txt:2:MARK a -| 8 l8 /tmp/pardes-snap/fsearch/cwd/g.txt:6:MARK b -| 9 l9 /tmp/pardes-snap/fsearch/cwd/g.txt:10:MARK c +| 7 l7 +| 8 l8 +| 9 l9 | 10 MARK c | 11 l11 | 12 l12 | | -| NOR /tmp/pardes-snap/fsearch/cwd Del -| +| NOR /tmp/pardes-snap/fsearch/cwd/+Search Del NOR /tmp/pardes-snap/fsearch/cwd Del +| 1 | g.txt | | diff --git a/test/snapshots/fsearch.snap b/test/snapshots/fsearch.snap index 38c39c02..7953b408 100644 --- a/test/snapshots/fsearch.snap +++ b/test/snapshots/fsearch.snap @@ -1,8 +1,9 @@ # `/` file search: the pattern types into the tag (persistent, no popup); -# Enter runs grep -nH into a shell pane so the results are ordinary terminal -# text; n/N select the next/prev result row there (prompt rows skipped via the -# OSC 133 semantic marks) and look it — the file pane scrolls to path:NNN. -# Esc abandons the input; a no-match search leaves n with nowhere to go. +# Enter searches the buffer in-core and writes `path:NNN: text` into a +Search +# OUTPUT BUFFER (a file pane with no file behind it — no Save in its tag); +# n/N select the next/prev row there and look it, so the file pane scrolls to +# path:NNN. Esc abandons the input; a no-match search empties the buffer and +# leaves n with nowhere to go. file g.txt l1\nMARK a\nl3\nl4\nl5\nMARK b\nl7\nl8\nl9\nMARK c\nl11\nl12 start 30 180 -n 3 wait 8000 g.txt @@ -28,10 +29,10 @@ text MARK stable 400 5000 snap typing key enter -wait 10000 g.txt:2:MARK +wait 10000 g.txt:2:1 MARK stable 700 10000 snap results -# n selects result 1 in the results pane + scrolls the file to line 2 +# n selects result 1 in the results buffer + scrolls the file to line 2 key n stable 700 15000 snap first diff --git a/test/snapshots/images.golden b/test/snapshots/images.golden index c5a2322e..fc5af97b 100644 --- a/test/snapshots/images.golden +++ b/test/snapshots/images.golden @@ -1,4 +1,4 @@ -== snap img-open grid=150x30 cursor=4,7 +== snap img-open grid=150x30 cursor=11,7 |Kill Newcol Tutor Debug Colors NextColor Crt Dump | img /tmp/pardes-snap/images/cwd/pic.ppm Petscii NOR /tmp/pardes-snap/images/cwd Del NOR /tmp/pardes-snap/images/cwd Del | diff --git a/test/snapshots/layout-open.golden b/test/snapshots/layout-open.golden index c4ab4219..6ccc8467 100644 --- a/test/snapshots/layout-open.golden +++ b/test/snapshots/layout-open.golden @@ -1,4 +1,4 @@ -== snap before grid=100x30 cursor=4,7 +== snap before grid=100x30 cursor=2,8 |Kill Newcol Tutor Debug Colors NextColor Crt Dump | NOR /tmp/pardes-snap/layout-open/cwd Del NOR /tmp/pardes-snap/layout-open/cwd Del | diff --git a/test/snapshots/leader.golden b/test/snapshots/leader.golden new file mode 100644 index 00000000..9bf559c0 --- /dev/null +++ b/test/snapshots/leader.golden @@ -0,0 +1,369 @@ +== snap spc grid=100x40 cursor=7,2 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| NOR /tmp/pardes-snap/leader/cwd/cmds.txt Sav SPC NOR /tmp/pardes-snap/leader/cwd Del +| 1 Help +| cmds.txt +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap spc-h grid=100x40 cursor=7,2 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| NOR /tmp/pardes-snap/leader/cwd/cmds.txt S SPC h NOR /tmp/pardes-snap/leader/cwd Del +| 1 Help +| cmds.txt +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap abandoned grid=100x40 cursor=7,2 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| NOR /tmp/pardes-snap/leader/cwd/cmds.txt Save De NOR /tmp/pardes-snap/leader/cwd Del +| 1 Help +| cmds.txt +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap unmapped grid=100x40 cursor=7,2 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| NOR /tmp/pardes-snap/leader/cwd/cmds.txt Save De NOR /tmp/pardes-snap/leader/cwd Del +| 1 Help +| cmds.txt +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap help-word grid=100x40 cursor=7,22 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| NOR /tmp/pardes-snap/leader/cwd/cmds.txt Save De NOR /tmp/pardes-snap/leader/cwd Del +| 1 Help +| cmds.txt +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| NOR /tmp/pardes-snap/leader/cwd/+Help Del +| 1 pardes builtins - SPC then the keys, +| 2 or execute the name anywhere +| 3 +| 4 SPC ? Help +| 5 SPC c d Delcol +| 6 SPC c n Newcol +| 7 SPC d Del +| 8 SPC h t Tutor +| 9 SPC k Kill +| 10 SPC s d Dump +| 11 SPC s r Restore +| 12 SPC t c Colors +| 13 SPC t d Debug +| 14 SPC t n NextColor +| 15 SPC t r Crt +| 16 SPC w Save +| 17 +| +== snap help-group grid=100x40 cursor=7,22 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| NOR /tmp/pardes-snap/leader/cwd/cmds.txt Save De NOR /tmp/pardes-snap/leader/cwd Del +| 1 Help +| cmds.txt +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| NOR /tmp/pardes-snap/leader/cwd/+Help Del +| 1 pardes builtins under SPC h +| 2 +| 3 SPC h t Tutor +| 4 +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap tutor grid=100x40 cursor=7,32 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| NOR /tmp/pardes-snap/leader/cwd/cmds.txt Save De NOR /tmp/pardes-snap/leader/cwd Del +| 1 Help +| cmds.txt +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| NOR /tmp/pardes-snap/leader/cwd/+Help Del +| 1 pardes builtins under SPC h +| 2 +| 3 SPC h t Tutor +| 4 +| +| +| +| +| +| NOR /Tutor Save Del +| 1 +| 2 PARDES TUTOR +| 3 tmux + vi + acme, from +| 4 +| 5 The model is acme's: EVERYTHING is +| 6 the same way. Modal editing (helix- +| 7 Press j until you reach the introdu +| 8 +== snap deleted grid=100x40 cursor=7,22 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| NOR /tmp/pardes-snap/leader/cwd/cmds.txt Save De NOR /tmp/pardes-snap/leader/cwd Del +| 1 Help +| cmds.txt +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| NOR /tmp/pardes-snap/leader/cwd/+Help Del +| 1 pardes builtins under SPC h +| 2 +| 3 SPC h t Tutor +| 4 +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap help-all grid=100x40 cursor=7,22 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| NOR /tmp/pardes-snap/leader/cwd/cmds.txt Save De NOR /tmp/pardes-snap/leader/cwd Del +| 1 Help +| cmds.txt +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| NOR /tmp/pardes-snap/leader/cwd/+Help Del +| 1 pardes builtins - SPC then the keys, +| 2 or execute the name anywhere +| 3 +| 4 SPC ? Help +| 5 SPC c d Delcol +| 6 SPC c n Newcol +| 7 SPC d Del +| 8 SPC h t Tutor +| 9 SPC k Kill +| 10 SPC s d Dump +| 11 SPC s r Restore +| 12 SPC t c Colors +| 13 SPC t d Debug +| 14 SPC t n NextColor +| 15 SPC t r Crt +| 16 SPC w Save +| 17 +| diff --git a/test/snapshots/leader.snap b/test/snapshots/leader.snap new file mode 100644 index 00000000..9da5f288 --- /dev/null +++ b/test/snapshots/leader.snap @@ -0,0 +1,64 @@ +# The SPC leader: every builtin also has a KEY PATH, and SPC walks it. While a +# path is pending the active pane's tag shows it at its RIGHT edge (the `/` +# input's rule — state you can see — right-aligned so it never collides with +# the tail's builtins). `?` at any depth opens the Help builtin, listing what +# the prefix typed so far can still reach, into a +Help OUTPUT BUFFER (a file +# pane with no file behind it, like +Search). The paths, the Help lines and +# the execute dispatch are all folded out of ONE enum at comptime — so +# executing the WORD `Help` runs the same builtin the leader does. +file cmds.txt Help +start 40 100 cmds.txt +wait 8000 Kill Newcol +wait 8000 cmds.txt +stable 700 20000 +# SPC arms the leader: the indicator appears on the active pane's tag +key space +stable 400 5000 +snap spc +# ... and grows as the path is typed (`h` = the docs group) +key h +stable 400 5000 +snap spc-h +# Esc abandons the sequence: indicator gone, nothing ran, cursor untouched +key esc +settle 700 +stable 400 5000 +snap abandoned +# an UNMAPPED key abandons too, rather than waiting: a typo must not leave the +# next keystroke armed at a builtin that closes panes +key space +key q +stable 400 5000 +snap unmapped +# executing the WORD `Help` (middle-click) runs the builtin — the enum IS the +# lookup, so a name works anywhere text lives. Every builtin, with its keys. +press middle 9 3 +release middle 9 3 +wait 10000 SPC h t +stable 700 15000 +snap help-word +# `SPC h ?`: only the `h` group, rewritten into the SAME +Help buffer +key space +key h +key ? +wait 10000 under SPC h +stable 700 15000 +snap help-group +# `SPC h t`: the Tutor builtin, zero arguments +key space +key h +key t +wait 10000 PARDES TUTOR +stable 700 15000 +snap tutor +# `SPC d`: Del is pane-scoped — it closes the tutor, focus falls back +key space +key d +stable 700 15000 +snap deleted +# `SPC ?` at the root lists everything again +key space +key ? +wait 10000 SPC s r +stable 700 15000 +snap help-all diff --git a/test/snapshots/modal.golden b/test/snapshots/modal.golden index 57b68c99..b94e986f 100644 --- a/test/snapshots/modal.golden +++ b/test/snapshots/modal.golden @@ -246,7 +246,7 @@ |27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d, |28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d, |29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d, -== snap typed grid=100x30 cursor=2,6 +== snap typed grid=100x30 cursor=7,6 |Kill Newcol Tutor Debug Colors NextColor Crt Dump | NOR /tmp/pardes-snap/modal/cwd Del NOR /tmp/pardes-snap/modal/cwd Del | @@ -308,7 +308,7 @@ | | | -== snap redone grid=100x30 cursor=2,6 +== snap redone grid=100x30 cursor=7,6 |Kill Newcol Tutor Debug Colors NextColor Crt Dump | NOR /tmp/pardes-snap/modal/cwd Del NOR /tmp/pardes-snap/modal/cwd Del | @@ -339,7 +339,7 @@ | | | -== snap bottom grid=100x30 cursor=2,8 +== snap bottom grid=100x30 cursor=2,9 |Kill Newcol Tutor Debug Colors NextColor Crt Dump | NOR /tmp/pardes-snap/modal/cwd Del NOR /tmp/pardes-snap/modal/cwd Del | diff --git a/test/snapshots/psearch.golden b/test/snapshots/psearch.golden new file mode 100644 index 00000000..8e5443d5 --- /dev/null +++ b/test/snapshots/psearch.golden @@ -0,0 +1,310 @@ +== snap output grid=140x30 cursor=4,8 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| NOR /tmp/pardes-snap/psearch/cwd Del +| +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap typing grid=140x30 cursor=44,1 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| INS /tmp/pardes-snap/psearch/cwd Del /MARK +| +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap results grid=140x30 cursor=74,8 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| NOR /tmp/pardes-snap/psearch/cwd/+Search Del NOR /tmp/pardes-snap/psearch/cwd Del +| 1 @p0:2:3 x MARK a +| 2 @p0:4:3 x MARK b x MARK a +| 3 @p0:6:3 x MARK c zz +| 4 x MARK b +| zz +| x MARK c +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap first grid=140x30 cursor=74,3 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| NOR /tmp/pardes-snap/psearch/cwd/+Search Del NOR /tmp/pardes-snap/psearch/cwd Del +| 1 @p0:2:3 x MARK a +| 2 @p0:4:3 x MARK b x MARK a +| 3 @p0:6:3 x MARK c zz +| 4 x MARK b +| zz +| x MARK c +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== style first +|0: 0-48 #969696,#2c2a3e, 49-139 d,#2c2a3e, +|1: 0-1 d,#383c54, 2-45 #969696,#2c2a3e, 46-69 d,#2c2a3e, 70-71 d,#6e4fd0, 72-107 #969696,#2c2a3e, 108-139 d,#2c2a3e, +|2: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-69 #d8c8a8,#4a3a1e, 70-71 d,#525252, 72-139 d,d, +|3: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-69 d,d, 70-71 d,#525252, 72-139 d,d, +|4: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-69 d,d, 70-71 d,#525252, 72-139 d,d, +|5: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-69 d,d, 70-71 d,#525252, 72-139 d,d, +|6: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-69 d,d, 70-71 d,#525252, 72-139 d,d, +|7: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-69 d,d, 70-71 d,#525252, 72-139 d,d, +|8: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-69 d,d, 70-71 d,#525252, 72-139 d,d, +|9: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-69 d,d, 70-71 d,#525252, 72-139 d,d, +|10: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-69 d,d, 70-71 d,#525252, 72-139 d,d, +|11: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-69 d,d, 70-71 d,#525252, 72-139 d,d, +|12: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-69 d,d, 70-71 d,#525252, 72-139 d,d, +|13: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-69 d,d, 70-71 d,#525252, 72-139 d,d, +|14: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-69 d,d, 70-71 d,#525252, 72-139 d,d, +|15: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-69 d,d, 70-71 d,#525252, 72-139 d,d, +|16: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-69 d,d, 70-71 d,#525252, 72-139 d,d, +|17: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-69 d,d, 70-71 d,#525252, 72-139 d,d, +|18: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-69 d,d, 70-71 d,#525252, 72-139 d,d, +|19: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-69 d,d, 70-71 d,#525252, 72-139 d,d, +|20: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-69 d,d, 70-71 d,#525252, 72-139 d,d, +|21: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-69 d,d, 70-71 d,#525252, 72-139 d,d, +|22: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-69 d,d, 70-71 d,#525252, 72-139 d,d, +|23: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-69 d,d, 70-71 d,#525252, 72-139 d,d, +|24: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-69 d,d, 70-71 d,#525252, 72-139 d,d, +|25: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-69 d,d, 70-71 d,#525252, 72-139 d,d, +|26: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-69 d,d, 70-71 d,#525252, 72-139 d,d, +|27: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-69 d,d, 70-71 d,#525252, 72-139 d,d, +|28: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-69 d,d, 70-71 d,#525252, 72-139 d,d, +|29: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-69 d,d, 70-71 d,#525252, 72-139 d,d, +== snap second grid=140x30 cursor=74,5 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| NOR /tmp/pardes-snap/psearch/cwd/+Search Del NOR /tmp/pardes-snap/psearch/cwd Del +| 1 @p0:2:3 x MARK a +| 2 @p0:4:3 x MARK b x MARK a +| 3 @p0:6:3 x MARK c zz +| 4 x MARK b +| zz +| x MARK c +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap back grid=140x30 cursor=74,3 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| NOR /tmp/pardes-snap/psearch/cwd/+Search Del NOR /tmp/pardes-snap/psearch/cwd Del +| 1 @p0:2:3 x MARK a +| 2 @p0:4:3 x MARK b x MARK a +| 3 @p0:6:3 x MARK c zz +| 4 x MARK b +| zz +| x MARK c +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap nomatch grid=140x30 cursor=74,3 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| NOR /tmp/pardes-snap/psearch/cwd/+Search Del NOR /tmp/pardes-snap/psearch/cwd Del +| 1 +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap ttyinert grid=140x30 cursor=74,8 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| NOR /tmp/pardes-snap/psearch/cwd/+Search Del TTY /tmp/pardes-snap/psearch/cwd Del +| 1 $ printf 'x MA''RK a\nzz\nx MA''RK b\nzz\nx MA''RK c\n' +| x MARK a +| zz +| x MARK b +| zz +| x MARK c +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap rightsearch grid=140x30 cursor=74,3 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| NOR /tmp/pardes-snap/psearch/cwd/+Search Del NOR /tmp/pardes-snap/psearch/cwd Del +| 1 @p0:2:3 x MARK a +| 2 @p0:4:3 x MARK b x MARK a +| 3 @p0:6:3 x MARK c zz +| 4 x MARK b +| zz +| x MARK c +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| diff --git a/test/snapshots/psearch.snap b/test/snapshots/psearch.snap new file mode 100644 index 00000000..4c5e74d0 --- /dev/null +++ b/test/snapshots/psearch.snap @@ -0,0 +1,68 @@ +# `/` search on a TERMINAL pane: the same tag input as a file pane, searching +# the shell's own scrollback (the plain text view Look reads, prompt rows +# hidden). Results land in a +Search OUTPUT BUFFER — a file pane with no file +# behind it, so its tag has no Save — and point at `@p0:LINE`, the look target +# that names a live pane, since a terminal has no path. n/N walk the buffer +# and look each row, scrolling the terminal to that line of its scrollback. +start 30 140 +wait 8000 Kill Newcol +stable 700 20000 +# print something to search (a bare boot starts in tty mode; the marker is +# split so the echoed command line, which normal mode hides anyway, can never +# be the thing that matched) +text printf 'x MA''RK a\nzz\nx MA''RK b\nzz\nx MA''RK c\n' +key enter +wait 10000 MARK c +stable 700 10000 +key c-b +stable 600 8000 +snap output +# / types the pattern into the terminal's tag exactly as on a file pane +key / +stable 400 5000 +text MARK +stable 400 5000 +snap typing +# Enter opens the results buffer: three @p0:LINE:COL rows, focus stays here +key enter +wait 10000 @p0:2:3 +stable 700 15000 +snap results +# n looks result 1: the terminal's cursor lands on that scrollback line, at +# the matched COLUMN (3, past the leading "x ") — not the start of the line +key n +stable 700 15000 +snap first +snapstyle first +key n +stable 700 15000 +snap second +# N walks back +key N +stable 700 15000 +snap back +# a no-match search empties the buffer; n then has nowhere to go +key / +text zzqq +key enter +stable 700 10000 +key n +stable 400 5000 +snap nomatch +# right-clicking a word that names no file is the same search (acme button 3), +# but NOT in tty mode: there the click belongs to the program on the other end, +# so the buffer stays empty +key c-b +stable 600 8000 +press right 75 4 +release right 75 4 +stable 700 10000 +snap ttyinert +# back in normal mode the same click searches for MARK +key c-b +stable 600 8000 +press right 75 4 +release right 75 4 +wait 10000 @p0:2:3 +stable 700 15000 +snap rightsearch diff --git a/test/snapshots/rsearch.golden b/test/snapshots/rsearch.golden index c067d1ee..e716ef39 100644 --- a/test/snapshots/rsearch.golden +++ b/test/snapshots/rsearch.golden @@ -39,17 +39,17 @@ | | code.txt | -| /tmp/pardes-snap/rsearch/cwd/code.txt:1:run count_x now -| /tmp/pardes-snap/rsearch/cwd/code.txt:3:count_x again | | | | | -| NOR /tmp/pardes-snap/rsearch/cwd Del | -| code.txt sub | +| NOR /tmp/pardes-snap/rsearch/cwd/+Search Del NOR /tmp/pardes-snap/rsearch/cwd Del +| 1 /tmp/pardes-snap/rsearch/cwd/code.txt:1:5 run count_x +| 2 /tmp/pardes-snap/rsearch/cwd/code.txt:3:1 count_x aga code.txt sub +| 3 | | | @@ -70,18 +70,16 @@ | | code.txt | -| /tmp/pardes-snap/rsearch/cwd/code.txt:1:run count_x now -| /tmp/pardes-snap/rsearch/cwd/code.txt:3:count_x again | | -| /tmp/pardes-snap/rsearch/cwd/code.txt:4:pick two words | | -| NOR /tmp/pardes-snap/rsearch/cwd Del | -| code.txt sub | | +| NOR /tmp/pardes-snap/rsearch/cwd/+Search Del NOR /tmp/pardes-snap/rsearch/cwd Del +| 1 /tmp/pardes-snap/rsearch/cwd/code.txt:4:1 pick two wo +| 2 code.txt sub | | | @@ -91,7 +89,9 @@ | | | -== snap path-open grid=180x30 cursor=7,19 +| +| +== snap path-open grid=180x30 cursor=7,12 |Kill Newcol Tutor Debug Colors NextColor Crt Dump | NOR /tmp/pardes-snap/rsearch/cwd/code.txt Save Del NOR /tmp/pardes-snap/rsearch/cwd Del NOR /tmp/pardes-snap/rsearch/cwd Del | 1 run count_x now @@ -101,20 +101,20 @@ | | code.txt | -| /tmp/pardes-snap/rsearch/cwd/code.txt:1:run count_x now -| /tmp/pardes-snap/rsearch/cwd/code.txt:3:count_x again -| -| -| /tmp/pardes-snap/rsearch/cwd/code.txt:4:pick two words -| -| -| NOR /tmp/pardes-snap/rsearch/cwd/sub/dep.txt Save Del NOR /tmp/pardes-snap/rsearch/cwd Del +| NOR /tmp/pardes-snap/rsearch/cwd/sub/dep.txt Save Del | 1 d1 -| 2 d2 code.txt sub +| 2 d2 | 3 DEPTARGET | 4 d4 | 5 d5 | 6 d6 +| NOR /tmp/pardes-snap/rsearch/cwd/+Search Del NOR /tmp/pardes-snap/rsearch/cwd Del +| 1 /tmp/pardes-snap/rsearch/cwd/code.txt:4:1 pick two wo +| 2 code.txt sub +| +| +| +| | | | diff --git a/test/snapshots/rsearch.snap b/test/snapshots/rsearch.snap index e42d195a..16f40137 100644 --- a/test/snapshots/rsearch.snap +++ b/test/snapshots/rsearch.snap @@ -1,7 +1,7 @@ # acme button-3 in a text file = search: right-click / right-select reuse the -# `/` grep machinery. A click expands the file-ish word (look.isFileChar) and, -# when it names no file, greps it into a shell (results are ordinary terminal -# text; n/N walk them like `/`). A right-select greps the swept span verbatim. +# `/` machinery. A click expands the file-ish word (look.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 / . : # so paths are not broken; only the non-file case falls through to search. dirmk sub @@ -25,10 +25,10 @@ wait 10000 count_x stable 700 15000 snap opened # right-CLICK the identifier `count_x` (line 1, the 'n' at col 15). It names no -# file, so the click greps it: the results list both lines that contain it. +# file, so the click searches for it: the results list both lines containing it. press right 15 3 release right 15 3 -wait 10000 code.txt:1:run count_x +wait 10000 code.txt:1:5 run count_x stable 700 15000 snap search # right-SELECT `pick two` (line 4, sweep cols 8..15): a multi-word span passes @@ -36,7 +36,7 @@ snap search press right 8 6 drag right 15 6 release right 15 6 -wait 10000 code.txt:4:pick two +wait 10000 code.txt:4:1 pick two stable 700 15000 snap select # right-CLICK the path `sub/dep.txt:3` (line 2, col 18): isFileChar keeps / . : diff --git a/test/snapshots/shrinktag.golden b/test/snapshots/shrinktag.golden index 031f15ef..d087fbda 100644 --- a/test/snapshots/shrinktag.golden +++ b/test/snapshots/shrinktag.golden @@ -1,4 +1,4 @@ -== snap before grid=100x30 cursor=4,7 +== snap before grid=100x30 cursor=2,8 |Kill Newcol Tutor Debug Colors NextColor Crt Dump | NOR /tmp/pardes-snap/shrinktag/cwd Del NOR /tmp/pardes-snap/shrinktag/cwd Del | @@ -29,7 +29,7 @@ | | | -== snap shrunk grid=100x30 cursor=4,7 +== snap shrunk grid=100x30 cursor=2,8 |Kill Newcol Tutor Debug Colors NextColor Crt Dump | NOR /tmp/pardes-snap/shrinktag/cwd Del NOR /tmp/pardes-snap/shrinktag/cwd Del | NOR /tmp/pardes-snap/shrinktag/cwd Del @@ -60,7 +60,7 @@ | | | -== snap move-preview grid=100x30 cursor=4,7 +== snap move-preview grid=100x30 cursor=2,8 |Kill Newcol Tutor Debug Colors NextColor Crt Dump | NOR /tmp/pardes-snap/shrinktag/cwd Del NOR /tmp/pardes-snap/shrinktag/cwd Del | NOR /tmp/pardes-snap/shrinktag/cwd Del @@ -91,7 +91,7 @@ | | | -== snap restored grid=100x30 cursor=4,7 +== snap restored grid=100x30 cursor=2,8 |Kill Newcol Tutor Debug Colors NextColor Crt Dump | NOR /tmp/pardes-snap/shrinktag/cwd Del NOR /tmp/pardes-snap/shrinktag/cwd Del | @@ -122,7 +122,7 @@ | | | -== snap bottom-shrunk grid=100x30 cursor=4,7 +== snap bottom-shrunk grid=100x30 cursor=2,8 |Kill Newcol Tutor Debug Colors NextColor Crt Dump | NOR /tmp/pardes-snap/shrinktag/cwd Del NOR /tmp/pardes-snap/shrinktag/cwd Del | diff --git a/test/snapshots/tty.golden b/test/snapshots/tty.golden index d4cbb360..1f7b8c0b 100644 --- a/test/snapshots/tty.golden +++ b/test/snapshots/tty.golden @@ -91,3 +91,65 @@ | | | +== snap shift-esc-on grid=100x30 cursor=4,6 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| TTY /tmp/pardes-snap/tty/cwd Del NOR /tmp/pardes-snap/tty/cwd Del +| $ ls +| m.txt m.txt +| $ echo SNAP''OK +| SNAPOK +| $ +| +| +| +| +| +| +| +| +| +| NOR /tmp/pardes-snap/tty/cwd Del +| +| m.txt +| +| +| +| +| +| +| +| +| +| +| +== snap shift-esc-off grid=100x30 cursor=4,6 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| NOR /tmp/pardes-snap/tty/cwd Del NOR /tmp/pardes-snap/tty/cwd Del +| +| m.txt m.txt +| +| SNAPOK +| +| +| +| +| +| +| +| +| +| +| NOR /tmp/pardes-snap/tty/cwd Del +| +| m.txt +| +| +| +| +| +| +| +| +| +| +| diff --git a/test/snapshots/tty.snap b/test/snapshots/tty.snap index 97eee3f5..a3af511d 100644 --- a/test/snapshots/tty.snap +++ b/test/snapshots/tty.snap @@ -1,4 +1,6 @@ -# tty mode round-trip: Ctrl-b in, run a command, Ctrl-b out +# tty mode round-trip: Ctrl-b in, run a command, Ctrl-b out. Shift-Esc is the +# same toggle (CSI 27;2u — needs a host that reports modifiers on Escape); a +# PLAIN Esc is not, it still goes to the shell. file m.txt marker start 30 100 -n 3 wait 8000 m.txt @@ -14,3 +16,10 @@ snap tty-echo key c-b stable 600 8000 snap tty-off +# Shift-Esc: in, then out again — the same toggle from the other key +send \e[27;2u +stable 600 8000 +snap shift-esc-on +send \e[27;2u +stable 600 8000 +snap shift-esc-off diff --git a/test/snapshots/ttyedit.golden b/test/snapshots/ttyedit.golden new file mode 100644 index 00000000..6671634e --- /dev/null +++ b/test/snapshots/ttyedit.golden @@ -0,0 +1,155 @@ +== snap before grid=100x30 cursor=7,6 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| NOR /tmp/pardes-snap/ttyedit/cwd Del NOR /tmp/pardes-snap/ttyedit/cwd Del +| +| words.txt words.txt +| +| +| one two three +| four five six +| seven eight nine +| +| +| +| +| +| +| +| NOR /tmp/pardes-snap/ttyedit/cwd Del +| +| words.txt +| +| +| +| +| +| +| +| +| +| +| +== snap split grid=100x30 cursor=2,7 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| INS /tmp/pardes-snap/ttyedit/cwd Del NOR /tmp/pardes-snap/ttyedit/cwd Del +| +| words.txt words.txt +| +| +| one t +| wo three +| four five six +| seven eight nine +| +| +| +| +| +| +| NOR /tmp/pardes-snap/ttyedit/cwd Del +| +| words.txt +| +| +| +| +| +| +| +| +| +| +| +== snap opened grid=100x30 cursor=6,8 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| NOR /tmp/pardes-snap/ttyedit/cwd Del NOR /tmp/pardes-snap/ttyedit/cwd Del +| +| words.txt words.txt +| +| +| one t +| wo three +| OPEN +| four five six +| seven eight nine +| +| +| +| +| +| NOR /tmp/pardes-snap/ttyedit/cwd Del +| +| words.txt +| +| +| +| +| +| +| +| +| +| +| +== snap joined grid=100x30 cursor=10,7 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| NOR /tmp/pardes-snap/ttyedit/cwd Del NOR /tmp/pardes-snap/ttyedit/cwd Del +| +| words.txt words.txt +| +| +| one t +| wo threeOPEN +| four five six +| seven eight nine +| +| +| +| +| +| +| NOR /tmp/pardes-snap/ttyedit/cwd Del +| +| words.txt +| +| +| +| +| +| +| +| +| +| +| +== snap undone grid=100x30 cursor=7,6 +|Kill Newcol Tutor Debug Colors NextColor Crt Dump +| NOR /tmp/pardes-snap/ttyedit/cwd Del NOR /tmp/pardes-snap/ttyedit/cwd Del +| +| words.txt words.txt +| +| +| one two three +| four five six +| seven eight nine +| +| +| +| +| +| +| +| NOR /tmp/pardes-snap/ttyedit/cwd Del +| +| words.txt +| +| +| +| +| +| +| +| +| +| +| diff --git a/test/snapshots/ttyedit.snap b/test/snapshots/ttyedit.snap new file mode 100644 index 00000000..18872ce4 --- /dev/null +++ b/test/snapshots/ttyedit.snap @@ -0,0 +1,45 @@ +# editing a shell pane exactly like a document: Enter SPLITS the line under +# the cursor and pushes the shell rows below it down, `o` opens a fresh line, +# and backspace at column 0 joins back up. Before the edit buffer landed a +# terminal could only splice single-row insertion runs, so none of this was +# expressible. test/hxcases/parity.jsonl pins the keystroke-level equivalence +# with a file pane case for case; this is the end-to-end pty proof. +file words.txt alpha beta +start 30 100 -n 3 +wait 8000 words.txt +stable 700 20000 +key c-b +stable 600 8000 +text printf 'one two three\nfour five six\nseven eight nine\n' +key enter +wait 8000 seven +stable 700 10000 +key c-b +stable 600 8000 +# click into the middle of the first output row +press left 8 7 +release left 8 7 +stable 400 5000 +snap before +# Enter splits the row there: "one t" stays, "wo three" becomes a real line +# of its own and the two output rows below it slide down +key i enter +stable 400 5000 +snap split +key esc +stable 400 5000 +# `o` opens a line below the split's tail and pushes the rest down again +key o +text OPEN +key esc +stable 400 5000 +snap opened +# backspace at column 0 joins the opened line back onto its predecessor +key i home bs +key esc +stable 400 5000 +snap joined +# undo walks the whole session back out to untouched shell output +key u u u +stable 400 5000 +snap undone diff --git a/test/snapshots/ttylook.golden b/test/snapshots/ttylook.golden index 410c71f7..c36f8f92 100644 --- a/test/snapshots/ttylook.golden +++ b/test/snapshots/ttylook.golden @@ -122,7 +122,7 @@ | | | -== snap insert grid=120x30 cursor=2,3 +== snap insert grid=120x30 cursor=4,3 |Kill Newcol Tutor Debug Colors NextColor Crt Dump | NOR /tmp/pardes-snap/ttylook/cwd Del NOR /tmp/pardes-snap/ttylook/cwd Del | -- cgit v1.3