diff options
| -rw-r--r-- | docs/helix-keys.md | 4 | ||||
| -rw-r--r-- | docs/tags.md | 9 | ||||
| -rw-r--r-- | src/Pipe.zig | 96 | ||||
| -rw-r--r-- | src/body_layer.zig | 9 | ||||
| -rw-r--r-- | src/builtins.zig | 10 | ||||
| -rw-r--r-- | src/edit.zig | 10 | ||||
| -rw-r--r-- | src/look.zig | 2 | ||||
| -rw-r--r-- | src/normal.zig | 30 | ||||
| -rw-r--r-- | src/panes.zig | 5 | ||||
| -rw-r--r-- | src/pardes.zig | 77 | ||||
| -rw-r--r-- | test/panes.zig | 55 |
11 files changed, 226 insertions, 81 deletions
diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 28641a71..5edaccce 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -242,7 +242,9 @@ the selection the prompt opened on (`Pane.sel_snap`). That is what makes the selection a live preview, what makes typing a pattern one character at a time land where pasting it whole would, and what makes Esc a plain restore. Enter re-runs the final pattern down the same path, so a submit can never disagree -with what is on screen. Engine: **mvzr** (`build.zig.zon`), a bytecode VM that +with what is on screen. Pressed in a tag, column tag or workspace tag they +select in that tag's own text, and so does `|` pipe it; `/` searches the +body wherever it is pressed (`Pane.prompt_for`). Engine: **mvzr** (`build.zig.zon`), a bytecode VM that compiles a runtime pattern with no allocator. | Key | Behavior | Notes | Status | diff --git a/docs/tags.md b/docs/tags.md index 45a0cb3b..7c385c08 100644 --- a/docs/tags.md +++ b/docs/tags.md @@ -81,9 +81,12 @@ body. Up from a pane with nothing above it reaches its column's tag, then the workspace's; Down comes back the same way, and Left and Right walk the column tags. -Search (`/`), pipe (`|`) and Save's path prompt are not typed into the tag: -each gets a line of its own on the pane's notice band, with the body's -insert-mode keys, and leaves the tag as it was. +Search (`/`), `s`/`S`, pipe (`|`) and Save's path prompt are not typed into +the tag: each gets a line of its own on the pane's notice band, with the +body's insert-mode keys. Pressed in a tag, `s`, `S` and `|` answer for the +tag's own text, as they do for a body, and a header's go on the active pane's +band; `/` searches the body wherever it is pressed, as acme's Look from a tag +does. Pane filenames and commands now have a single separator space rather than generated right-alignment padding. Intentionally customized spacing is kept. diff --git a/src/Pipe.zig b/src/Pipe.zig index 4cabfd88..1ad7b826 100644 --- a/src/Pipe.zig +++ b/src/Pipe.zig @@ -9,6 +9,7 @@ const config = @import("config.zig"); const selection_pipe = @import("selection_pipe.zig"); const edit = @import("edit.zig"); const Pane = panes.Pane; +const Text = panes.Text; const Key = pardes.Key; const Pardes = pardes.Pardes; const Pipe = @This(); @@ -41,7 +42,14 @@ pub const PendingPipe = struct { id: u32, pane: usize, serial: u32, + /// The text piped: the pane's body or tag, or a header (`column` + /// names which). + what: Text.What, + column: usize, + /// What the answer is checked against before it is applied: a body's + /// file revision, any other text's contents. revision: u32, + sum: u64, command: []u8, cwd: []u8, inputs: []selection_pipe.Input, @@ -71,8 +79,12 @@ pub const PendingPipe = struct { pub fn submitPipe(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; - const f = pane.file orelse return; - if (!panes.Output.fileTraits(f.output).saves) return; + const t = p.promptTarget(pane) orelse return; + if (t.what == .body) { + const f = pane.file orelse return; + if (!panes.Output.fileTraits(f.output).saves) return; + } + const content = edit.flatSurface(p, t) catch return; const how = switch (pane.prompt) { .pipe => |pipe| pipe, else => return, @@ -84,7 +96,7 @@ pub fn submitPipe(p: *Pardes, id: usize) void { if (command.len == 0) return; var ranges: [panes.Text.max_selections]modal.Selection = undefined; - const got = pane.body.ranges(f.content, 0, &ranges); + const got = t.ranges(content, 0, &ranges); // `!`/`A-!` take no stdin and run ONCE — see `PendingPipe.nranges`. const ninputs = if (how.pipes()) got.n else 1; const inputs = p.gpa.alloc(selection_pipe.Input, ninputs) catch return; @@ -93,8 +105,8 @@ pub fn submitPipe(p: *Pardes, id: usize) void { const bytes = if (how.pipes()) bytes: { const lo = @min(range.anchor, range.head); const hi = @max(range.anchor, range.head); - if (hi > f.content.len) break; - break :bytes f.content[lo..hi]; + if (hi > content.len) break; + break :bytes content[lo..hi]; } else ""; const copy = p.gpa.dupe(u8, bytes) catch break; inputs[i] = .{ .bytes = copy }; @@ -123,13 +135,16 @@ pub fn submitPipe(p: *Pardes, id: usize) void { .id = p.pipe.seq, .pane = id, .serial = pane.serial, - .revision = f.revision, + .what = t.what, + .column = p.header_column orelse 0, + .revision = if (pane.file) |f| f.revision else 0, + .sum = std.hash.Wyhash.hash(0, content), .command = command_copy, .cwd = cwd_copy, .inputs = inputs, .ranges = ranges, .primary = @intCast(got.pri), - .explicit = (pane.body.vsel.active and pane.body.vsel.explicit) or pane.body.msel.active, + .explicit = (t.vsel.active and t.vsel.explicit) or t.msel.active, .how = how, .nranges = @intCast(got.n), }; @@ -229,10 +244,20 @@ pub fn pipeResponse( const pane = p.panes[wait.pane] orelse return; if (pane.serial != wait.serial) return; - const f = if (pane.file) |*file| file else return; - if (!panes.Output.fileTraits(f.output).saves) return; - if (f.revision != wait.revision) - return p.reportError(wait.pane, "pipe", error.FileChangedWhileFiltering); + const t: *Text = switch (wait.what) { + .body => &pane.body, + .tag => &pane.tag, + .column => if (wait.column < p.ncol) &p.col_tags[wait.column] else return, + .workspace => &p.global_tag, + .input => return, + }; + const content = edit.flatSurface(p, t) catch return; + const changed = if (t.what == .body) changed: { + const f = pane.file orelse return; + if (!panes.Output.fileTraits(f.output).saves) return; + break :changed f.revision != wait.revision; + } else std.hash.Wyhash.hash(0, content) != wait.sum; + if (changed) return p.reportError(wait.pane, "pipe", error.FileChangedWhileFiltering); if (wait.how == .ignore) return; @@ -241,8 +266,8 @@ pub fn pipeResponse( var removed: usize = 0; var previous_end: usize = 0; for (0..n) |i| { - const cut = pipeCut(&wait, f.content, i) orelse return; - const output = pipeOutput(&wait, outputs, f.content, i); + const cut = pipeCut(&wait, content, i) orelse return; + const output = pipeOutput(&wait, outputs, content, i); if (output.len > selection_pipe.max_stdout_bytes) return; total_output = std.math.add(usize, total_output, output.len) catch return; if (total_output > selection_pipe.max_total_stdout_bytes) return; @@ -250,7 +275,7 @@ pub fn pipeResponse( removed = std.math.add(usize, removed, cut.hi - cut.lo) catch return; previous_end = cut.hi; } - const kept = f.content.len - removed; + const kept = content.len - removed; const final_len = std.math.add(usize, kept, total_output) catch return; const replacement = p.gpa.alloc(u8, final_len) catch return; @@ -259,9 +284,9 @@ pub fn pipeResponse( var write_at: usize = 0; for (0..n) |i| { const range = wait.ranges[i]; - const cut = pipeCut(&wait, f.content, i).?; - const output = pipeOutput(&wait, outputs, f.content, i); - @memcpy(replacement[write_at .. write_at + (cut.lo - read_at)], f.content[read_at..cut.lo]); + const cut = pipeCut(&wait, content, i).?; + const output = pipeOutput(&wait, outputs, content, i); + @memcpy(replacement[write_at .. write_at + (cut.lo - read_at)], content[read_at..cut.lo]); write_at += cut.lo - read_at; const out_start = write_at; @memcpy(replacement[write_at .. write_at + output.len], output); @@ -273,14 +298,14 @@ pub fn pipeResponse( .{ .anchor = out_start, .head = out_end }; read_at = cut.hi; } - @memcpy(replacement[write_at..], f.content[read_at..]); + @memcpy(replacement[write_at..], content[read_at..]); // One async request is one history transaction, even at 64 cursors. - edit.pushUndo(p, &pane.body); - panes.File.setContent(p, f, replacement); - pane.body.setRanges(f.content, new_ranges[0..n], &.{}, wait.primary, wait.explicit); - pane.body.select = false; - pane.ensureCursorVisible(); + edit.pushUndo(p, t); + edit.setEditText(p, t, replacement); + t.setRanges(edit.flatSurface(p, t) catch return, new_ranges[0..n], &.{}, wait.primary, wait.explicit); + t.select = false; + t.show(); } test "selection pipe prompt submits exact request and Escape cancels" { @@ -316,6 +341,31 @@ test "selection pipe prompt submits exact request and Escape cancels" { try std.testing.expect(nextPipeEffect(p) == null); } +test "| in a tag pipes the tag's own text, and undo there brings it back" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = try p.setTestFile("body\n"); + p.update(.{ .key = .{ .cp = ':' } }); // the tag, on Save + p.update(.{ .key = .{ .cp = 'e' } }); // Save selected + p.update(.{ .key = .{ .cp = '|' } }); + try std.testing.expect(pane.focus == .tag and pane.prompt == .pipe and pane.prompt_for == .tag); + p.update(.{ .key = .{ .cp = 't', .text = "tr a-z A-Z" } }); + p.update(.{ .key = .{ .cp = Key.enter } }); + try std.testing.expect(pane.focus == .tag and pane.prompt == .none); + const id = nextPipeEffect(p) orelse return error.MissingPipeEffect; + const request = p.pipe.pipeRequest(id) orelse return error.MissingPipeRequest; + try std.testing.expectEqual(@as(usize, 1), request.inputs.len); + try std.testing.expectEqualSlices(u8, "Save", request.inputs[0].bytes); + const outputs: []const []const u8 = &.{"SAVE"}; + p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = outputs } }); + try std.testing.expect(std.mem.startsWith(u8, pardes.tagline.curTail(pane), " SAVE ")); + try std.testing.expectEqualStrings("body\n", pane.file.?.content); + p.update(.{ .key = .{ .cp = 'u' } }); + try std.testing.expect(std.mem.startsWith(u8, pardes.tagline.curTail(pane), " Save ")); +} + test "selection pipe replaces all ranges atomically and undo restores them" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); diff --git a/src/body_layer.zig b/src/body_layer.zig index ab8a888a..ff1f903c 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -661,8 +661,10 @@ pub fn paintHeaders(p: *Pardes) !void { cell.style.bg = .{ .rgb = th.sel_bg }; } } + // A prompt answering for the header has the caret on its band. + const asking = if (p.panes[p.active]) |active| active.prompt != .none else false; const cur: usize = @intCast(@max(0, t.cur_row)); - if (cur >= p.header_top and cur < p.header_top + rows) { + if (!asking and cur >= p.header_top and cur < p.header_top + rows) { const bar = modal.lineSlice(text, cur); const caret = panes.File.rawDisplayCol(bar, @min(@as(usize, @intCast(@max(0, t.cur_col))), bar.len)) -| p.header_scroll; if (caret < w) s.cursor = .{ .x = x + @as(u16, @intCast(caret)), .y = y + @as(u16, @intCast(cur - p.header_top)), .bar = t.mode == .insert }; @@ -861,7 +863,8 @@ fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: T } } const caret = panes.File.rawDisplayCol(text, @min(@as(usize, @intCast(@max(0, t.cur_col))), text.len)) -| scroll; - if (t.cur_row == line + p.header_top and caret < columns) layer.cursor = .{ .col = @intCast(caret), .bar = t.mode == .insert }; + const asking = if (p.panes[p.active]) |active| active.prompt != .none else false; + if (!asking and t.cur_row == line + p.header_top and caret < columns) layer.cursor = .{ .col = @intCast(caret), .bar = t.mode == .insert }; } else if (p.pointer_inside) { const tag_hit = p.pointer_tag_hit; const col: ?usize = if (tag_hit) |pointer| @@ -920,7 +923,7 @@ pub fn paintPaneTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, null else if (pane.prompt == .name) .{ .row = 0, .at = @min(lines[0].len, @as(usize, @intCast(@max(0, pane.input.cur_col)))) } - else if (pane.focus == .tag and pane.tag.cur_row >= top and pane.tag.cur_row < top + rows) caret: { + else if (pane.focus == .tag and pane.prompt == .none and pane.tag.cur_row >= top and pane.tag.cur_row < top + rows) caret: { const row: usize = @intCast(pane.tag.cur_row - top); const lead: usize = if (row + top == 0) prefix_len else 0; break :caret .{ .row = row, .at = @min(lines[row].len, lead + @as(usize, @intCast(@max(0, pane.tag.cur_col)))) }; diff --git a/src/builtins.zig b/src/builtins.zig index e9f29102..ffb00ff6 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -483,7 +483,7 @@ pub const Save = struct { const path = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); if (path.len > 0) return exec.saveTo(c.p, c.id, path); if (c.pane.file) |file| if (file.output == null) return exec.saveFile(c.p, c.id); - if (c.pane.file != null or c.pane.isTerminal()) c.p.startPrompt(c.pane, .save); + if (c.pane.file != null or c.pane.isTerminal()) c.p.startPrompt(c.pane, &c.pane.body, .save); } }; @@ -717,7 +717,7 @@ pub const Find = struct { const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); if (pat.len > 0) return look.runSearch(c.p, c.id, pat, .find, .top) catch |err| c.p.reportError(c.id, "find", err); - c.p.startPrompt(c.pane, .{ .search = config.find_marker }); + c.p.startPrompt(c.pane, &c.pane.body, .{ .search = config.find_marker }); } }; @@ -730,7 +730,7 @@ pub const Grep = struct { const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); if (pat.len > 0) return look.runSearch(c.p, c.id, pat, .grep, .top) catch |err| c.p.reportError(c.id, "grep", err); - c.p.startPrompt(c.pane, .{ .search = config.grep_marker }); + c.p.startPrompt(c.pane, &c.pane.body, .{ .search = config.grep_marker }); } }; @@ -894,7 +894,7 @@ pub const Rename = struct { pub fn run(c: Ctx) void { const a = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); if (a.len > 0) return c.p.lspRequest(c.id, .rename, a); - c.p.startPrompt(c.pane, .{ .search = config.rename_marker }); + c.p.startPrompt(c.pane, &c.pane.body, .{ .search = config.rename_marker }); } }; @@ -903,7 +903,7 @@ pub const WsSymbols = struct { pub fn run(c: Ctx) void { const a = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); if (a.len > 0) return c.p.lspRequest(c.id, .workspace_symbols, a); - c.p.startPrompt(c.pane, .{ .search = config.symbol_marker }); + c.p.startPrompt(c.pane, &c.pane.body, .{ .search = config.symbol_marker }); } }; diff --git a/src/edit.zig b/src/edit.zig index 837a22c6..e21352e1 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -74,10 +74,12 @@ pub fn typeToTty(p: *Pardes, id: usize, pane: *const Pane, text: []const u8) voi } pub fn applyPaste(p: *Pardes, bytes: []const u8) void { - if (tagline.focusedHeader(p)) |t| { + // A prompt answering for a header takes the paste, as it takes keys. + const asking = if (p.panes[p.active]) |active| active.prompt != .none else false; + if (!asking) if (tagline.focusedHeader(p)) |t| { p.clip_pending = null; return pasteInto(p, t, bytes); - } + }; const req = p.clip_pending; p.clip_pending = null; if (bytes.len == 0) return; @@ -94,7 +96,7 @@ pub fn applyPaste(p: *Pardes, bytes: []const u8) void { n += 1; }; if (n > 0) insertKey(p, &pane.input, .{ .cp = line[0], .text = line[0..n] }); - if (normal.selRegexArmed(pane)) |a| normal.applySelRegex(p, pane, a.pat, a.split); + if (normal.selRegexArmed(pane)) |a| if (p.promptTarget(pane)) |t| normal.applySelRegex(p, pane, t, a.pat, a.split); return; } if (pane.focus == .tag) return pasteInto(p, &pane.tag, bytes); @@ -400,7 +402,7 @@ fn editTextEol(p: *Pardes, t: *Text, b: Bounds) ?panes.EditText { } /// install a rewritten editable text (frees the old one) -fn setEditText(p: *Pardes, t: *Text, new: []u8) void { +pub fn setEditText(p: *Pardes, t: *Text, new: []u8) void { if (t.what != .body) { if (t.own) |old| p.gpa.free(old); t.own = new; diff --git a/src/look.zig b/src/look.zig index cbdf0bea..d7e763d3 100644 --- a/src/look.zig +++ b/src/look.zig @@ -502,7 +502,7 @@ const SearchStart = enum { top, cursor }; pub fn submitSearch(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; - if (normal.selRegexArmed(pane)) |a| return normal.applySelRegex(p, pane, a.pat, a.split); + if (normal.selRegexArmed(pane)) |a| return if (p.promptTarget(pane)) |t| normal.applySelRegex(p, pane, t, a.pat, a.split); const armed = pane.promptText() orelse return; const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return; if (std.mem.startsWith(u8, armed, config.rename_marker)) return p.lspRequest(id, .rename, armed[slash + 1 ..]); diff --git a/src/normal.zig b/src/normal.zig index c7e75d70..ffbb49d5 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -67,13 +67,13 @@ pub fn multiOnce(p: *Pardes) bool { return p.multi_first; } -fn startSelRegex(p: *Pardes, pane: *Pane, split: bool) void { - const text = edit.flatSurface(p, &pane.body) catch return; - const got = pane.body.ranges(text, 0, &pane.sel_snap); +fn startSelRegex(p: *Pardes, pane: *Pane, t: *Text, split: bool) void { + const text = edit.flatSurface(p, t) catch return; + const got = t.ranges(text, 0, &pane.sel_snap); pane.nsel_snap = @intCast(got.n); pane.sel_snap_pri = @intCast(got.pri); - pane.sel_snap_expl = (pane.body.vsel.active and pane.body.vsel.explicit) or pane.body.msel.active; - p.startPrompt(pane, .{ .search = if (split) config.split_marker else config.select_marker }); + pane.sel_snap_expl = (t.vsel.active and t.vsel.explicit) or t.msel.active; + p.startPrompt(pane, t, .{ .search = if (split) config.split_marker else config.select_marker }); } pub fn selRegexArmed(pane: *Pane) ?struct { pat: []const u8, split: bool } { @@ -85,8 +85,10 @@ pub fn selRegexArmed(pane: *Pane) ?struct { pat: []const u8, split: bool } { return .{ .pat = armed[slash + 1 ..], .split = split }; } -pub fn applySelRegex(p: *Pardes, pane: *Pane, pat: []const u8, split: bool) void { - const text = edit.flatSurface(p, &pane.body) catch return; +/// Select (or split on) `pat` inside the selections `t` had when s/S was +/// pressed; the prompt's snapshot of them lives on `pane`. +pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, split: bool) void { + const text = edit.flatSurface(p, t) catch return; const snap = pane.sel_snap[0..pane.nsel_snap]; var out: [Text.max_selections]modal.Selection = undefined; var m: usize = 0; @@ -132,9 +134,9 @@ pub fn applySelRegex(p: *Pardes, pane: *Pane, pat: []const u8, split: bool) void r.anchor = @min(r.anchor, text.len); r.head = @min(r.head, text.len); } - return pane.body.setRanges(text, snap, &.{}, pane.sel_snap_pri, pane.sel_snap_expl); + return t.setRanges(text, snap, &.{}, pane.sel_snap_pri, pane.sel_snap_expl); } - pane.body.setRanges(text, out[0..m], &.{}, 0, true); // helix keeps primary 0 (its own TODO) + t.setRanges(text, out[0..m], &.{}, 0, true); // helix keeps primary 0 (its own TODO) } const KeyState = struct { @@ -521,7 +523,9 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void } return t.multiSelAction(text, multi.kind, multi.count); }, - .select_regex => |split| return startSelRegex(p, pane, split), + // s, S and | answer for the text they are pressed in; `/` searches + // the body wherever it is pressed, as acme's Look from a tag does. + .select_regex => |split| return startSelRegex(p, pane, t, split), .edit => |op| switch (op.kind) { .delete => return edit.normalDelete(p, t, true), .delete_noyank => return edit.normalDelete(p, t, false), @@ -560,8 +564,8 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void .column, .workspace => tagline.exitHeader(p), .body, .input => tagline.enterTag(p, pane), }, - .pipe_selection => |how| return p.startPrompt(pane, .{ .pipe = how }), - .search => return p.startPrompt(pane, .{ .search = config.search_marker }), + .pipe_selection => |how| return p.startPrompt(pane, t, .{ .pipe = how }), + .search => return p.startPrompt(pane, &pane.body, .{ .search = config.search_marker }), .search_step => |direction| return look.lookWalk( p, if (direction == .forward) @as(i32, 1) else -1, @@ -602,7 +606,7 @@ pub fn handlePdfNormal(p: *Pardes, pane: *Pane, key: Key) void { p.leader_n = 0; }, .command_line => tagline.enterTag(p, pane), - .search => p.startPrompt(pane, .{ .search = config.search_marker }), + .search => p.startPrompt(pane, &pane.body, .{ .search = config.search_marker }), .search_forward => look.lookWalk(p, 1), .search_backward => look.lookWalk(p, -1), } diff --git a/src/panes.zig b/src/panes.zig index 1e5be107..a67aa8ac 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -101,6 +101,10 @@ pub const Pane = struct { image: ?Image.State = null, pdf: PdfSlot = if (Pdf.enabled) null else {}, prompt: Prompt = .none, + /// The text an open prompt answers for: `s`/`S` select in it and `|` + /// pipes it. A header's is whichever has the keyboard; `/` and Save's + /// path always answer for the body. + prompt_for: Text.What = .body, /// The answer to an open prompt: its marker and what is typed after it, /// edited in insert mode and shown on the notice band, or a name draft /// shown on the tagline in place of the path. It is its own text so a @@ -314,6 +318,7 @@ pub const Pane = struct { pub fn closePrompt(pane: *Pane) void { pane.input.deinit(pane.gpa); pane.prompt = .none; + pane.prompt_for = .body; pane.nsel_snap = 0; } diff --git a/src/pardes.zig b/src/pardes.zig index 3194b059..65f3aebb 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -2911,6 +2911,25 @@ test "a header's execute key runs the word under its cursor and lets go of the k try std.testing.expectEqual(@as(usize, 1), p.col_n[0]); } +test "s in a header selects in the header's own text" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 12, .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("body col\n"); + tagline.enterHeader(p, null); + p.update(.{ .key = .{ .cp = 'x' } }); + p.update(.{ .key = .{ .cp = 's' } }); + try std.testing.expect(pane.prompt == .search and pane.prompt_for == .workspace); + // the prompt takes the keys while the header keeps the keyboard + p.update(.{ .key = .{ .cp = 'c', .text = "col" } }); + p.update(.{ .key = .{ .cp = Key.enter } }); + try std.testing.expect(p.header_focus and pane.prompt == .none); + var ranges: [Text.max_selections]modal.Selection = undefined; + const text = try edit.flatSurface(p, &p.global_tag); + const got = p.global_tag.ranges(text, 0, &ranges); + try std.testing.expectEqual(@as(usize, 3), got.n); // Newcol, Joincol, NextColor + try std.testing.expectEqual(@as(u8, 0), pane.body.nsel); +} + test "Enter in a header runs the word under its cursor, as the execute key does" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 12, .tty_only = true }); defer p.deinit(); @@ -4832,7 +4851,8 @@ pub const Pardes = struct { return; } const pane = p.panes[p.active] orelse return; - const header = tagline.focusedHeader(p); + // An open prompt takes the keys even when it answers for a header. + const header = if (pane.prompt != .none) null else tagline.focusedHeader(p); if (header == null and pane.isTerminal() and pane.body.mode == .tty and pane.focus == .body and pane.prompt == .none) { p.leader_on = false; p.ctrl_w_pending = false; @@ -4927,22 +4947,25 @@ pub const Pardes = struct { if (header == null and pane.prompt != .none) { if (hit(key, config.search_submit) or hit(key, config.escape)) { const submit = hit(key, config.search_submit); + const target = p.promptTarget(pane); switch (pane.prompt) { .pipe => if (submit) Pipe.submitPipe(p, p.active), .save => if (submit) exec.submitSave(p, p.active), .search => if (submit) look.submitSearch(p, p.active) - else if (normal.selRegexArmed(pane)) |_| - normal.applySelRegex(p, pane, "", false), + else if (normal.selRegexArmed(pane)) |_| if (target) |answered| + normal.applySelRegex(p, pane, answered, "", false), .none, .del_side, .name => unreachable, } pane.closePrompt(); - pane.body.mode = .normal; - pane.body.normal.clear(); + if (target) |answered| { + answered.mode = .normal; + answered.normal.clear(); + } return; } edit.handleInsert(p, &pane.input, key); - if (normal.selRegexArmed(pane)) |a| normal.applySelRegex(p, pane, a.pat, a.split); + if (normal.selRegexArmed(pane)) |a| if (p.promptTarget(pane)) |answered| normal.applySelRegex(p, pane, answered, a.pat, a.split); return; } if (t.mode == .normal and (hit(key, config.look_key) or hit(key, config.exec_key))) { @@ -5045,7 +5068,9 @@ pub const Pardes = struct { return pane.cwdSlice(); } - pub fn startPrompt(p: *Pardes, pane: *Pane, request: union(enum) { + /// Open a prompt on `pane`'s notice band answering for `t`: the body, + /// the pane's tag, or the header that has the keyboard. + pub fn startPrompt(p: *Pardes, pane: *Pane, t: *Text, request: union(enum) { search: []const u8, pipe: modal.Normal.PipeBehavior, save, @@ -5054,8 +5079,12 @@ pub const Pardes = struct { .search => |marker| marker, .save => config.save_marker, .pipe => |how| pipe: { - const file = pane.file orelse return; - if (!panes.Output.fileTraits(file.output).saves) return; + // A body is piped only when it is a file that saves; a tag's + // or a header's text is its own. + if (t.what == .body) { + const file = pane.file orelse return; + if (!panes.Output.fileTraits(file.output).saves) return; + } break :pipe Pipe.pipeMarker(how); }, }; @@ -5064,7 +5093,16 @@ pub const Pardes = struct { const dir = if (request == .save) paneDir(pane) else ""; const slash: []const u8 = if (request == .save and (dir.len == 0 or dir[dir.len - 1] != '/')) "/" else ""; const answer = std.mem.concat(p.gpa, u8, &.{ marker, dir, slash }) catch return; - pane.focus = .body; + // The keyboard comes back to the text answered for; a prompt for + // the body takes it out of a tag or header. + switch (t.what) { + .body => { + pane.focus = .body; + if (p.panes[p.active] == pane) tagline.exitHeader(p); + }, + .tag => pane.focus = .tag, + .column, .workspace, .input => {}, + } // Not closePrompt: an s/S snapshot was taken for this very prompt. if (pane.input.own) |old| p.gpa.free(old); pane.prompt = switch (request) { @@ -5072,11 +5110,22 @@ pub const Pardes = struct { .pipe => |how| .{ .pipe = how }, .save => .save, }; + pane.prompt_for = t.what; pane.input = .{ .what = .input, .own = answer, .mode = .insert, .cur_pinned = true, .cur_col = @intCast(answer.len) }; - // Whatever mode the body was in, it is in normal mode once the + // Whatever mode the text was in, it is in normal mode once the // prompt is answered or dropped. - pane.body.mode = .normal; - pane.body.normal.clear(); + t.mode = .normal; + t.normal.clear(); + } + + /// The text `pane`'s open prompt answers for, if it is still there. + pub fn promptTarget(p: *Pardes, pane: *Pane) ?*Text { + return switch (pane.prompt_for) { + .body => &pane.body, + .tag => &pane.tag, + .column, .workspace => if (tagline.focusedHeader(p)) |h| (if (h.what == pane.prompt_for) h else null) else null, + .input => null, + }; } pub const SavePath = Effect.Buf(effect_path_cap); @@ -6308,7 +6357,7 @@ test "a prompt chip is a band with its caret, still on the grid, and only the ch // the chip ends at the pane's right edge -- so this is the frame that // used to put it one column past the pane, off the grid and past what the // detached wire will encode. - p.startPrompt(pane, .{ .search = "/" }); + p.startPrompt(pane, &pane.body, .{ .search = "/" }); _ = try p.render(frame.allocator()); const r = p.rects[id]; const cursor = p.surface.cursor orelse return error.MissingPromptCursor; diff --git a/test/panes.zig b/test/panes.zig index 9b800430..d8ed517f 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -293,6 +293,33 @@ const TagNavigationTests = struct { p.update(.{ .key = .{ .cp = cp } }); } + test "s, S and | in a tag answer for the tag's own text; / still searches the body" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 20 }); + defer p.deinit(); + const pane = try p.setTestFile("Some body\n"); + try setTag(pane, " Save Del Snarf"); + key(p, ':'); + key(p, 'x'); // the tag's line + key(p, 's'); + try std.testing.expect(pane.prompt == .search and pane.prompt_for == .tag and pane.focus == .tag); + p.update(.{ .key = .{ .cp = 'S', .text = "S" } }); + key(p, Key.enter); + try std.testing.expect(pane.focus == .tag and pane.prompt == .none); + var ranges: [panes.Text.max_selections]modal.Selection = undefined; + const text = try pardes.edit.flatSurface(p, &pane.tag); + const got = pane.tag.ranges(text, 0, &ranges); + try std.testing.expectEqual(@as(usize, 2), got.n); + for (ranges[0..got.n]) |range| try std.testing.expectEqualStrings("S", text[@min(range.anchor, range.head)..@max(range.anchor, range.head)]); + // the body's selection is its own + try std.testing.expectEqual(@as(u8, 0), pane.body.nsel); + // `/` from the tag is the body's search, as acme's Look from a tag is + key(p, ','); + key(p, '/'); + try std.testing.expect(pane.prompt == .search and pane.prompt_for == .body and pane.focus == .body); + key(p, Key.escape); + try std.testing.expect(pane.prompt == .none and pane.focus == .body); + } + fn window(p: *Pardes, letter: u21) void { p.update(.{ .key = .{ .cp = 'w', .ctrl = true } }); key(p, letter); @@ -345,7 +372,7 @@ const TagNavigationTests = struct { try std.testing.expectEqual(@as(i32, 6), first.tag.cur_col); key(p, Key.escape); key(p, ':'); - p.startPrompt(first, .{ .search = config.search_marker }); + p.startPrompt(first, &first.body, .{ .search = config.search_marker }); key(p, Key.escape); key(p, ':'); try std.testing.expectEqual(@as(i32, 6), first.tag.cur_col); @@ -1483,7 +1510,7 @@ test "paste into pane commands and prompts never inserts into the body" { p.update(.{ .paste = " Custom\nCommand" }); try std.testing.expect(std.mem.indexOf(u8, pardes.tagline.curTail(pane), " Custom\nCommand") != null); p.update(.{ .key = .{ .cp = Key.escape } }); - p.startPrompt(pane, .{ .search = config.search_marker }); + p.startPrompt(pane, &pane.body, .{ .search = config.search_marker }); p.update(.{ .paste = "t\r\nwo" }); try std.testing.expectEqualStrings(config.search_marker ++ "two", pane.promptText().?); try std.testing.expect(std.mem.indexOf(u8, pardes.tagline.curTail(pane), " Custom\nCommand") != null); @@ -1806,9 +1833,9 @@ test "a prompt's entry and cancellation leave the tag, the body and its cursor a if (case.key) |key| { p.update(.{ .key = key }); } else if (case.save) { - p.startPrompt(pane, .save); + p.startPrompt(pane, &pane.body, .save); } else { - p.startPrompt(pane, .{ .search = config.search_marker }); + p.startPrompt(pane, &pane.body, .{ .search = config.search_marker }); } // The answer is typed into the prompt's own line, never the tag. try std.testing.expect(pane.focus == .body); @@ -1838,8 +1865,8 @@ test "a prompt opens whatever the tag already holds" { try setTag(pane, full); for (0..3) |case| { switch (case) { - 0 => p.startPrompt(pane, .{ .search = config.search_marker }), - 1 => p.startPrompt(pane, .save), + 0 => p.startPrompt(pane, &pane.body, .{ .search = config.search_marker }), + 1 => p.startPrompt(pane, &pane.body, .save), else => p.update(.{ .key = .{ .cp = '|' } }), } try std.testing.expect(pane.prompt != .none); @@ -1871,9 +1898,9 @@ test "a prompt owns the pipe behavior through cancellation and submission" { pane.body.cur_col = 2; pane.body.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; for (std.enums.values(modal.Normal.PipeBehavior)) |how| { - p.startPrompt(pane, .{ .pipe = .append }); + p.startPrompt(pane, &pane.body, .{ .pipe = .append }); p.update(.{ .key = .{ .cp = Key.escape } }); - p.startPrompt(pane, .{ .pipe = how }); + p.startPrompt(pane, &pane.body, .{ .pipe = how }); try std.testing.expectEqual(how, pane.prompt.pipe); p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); p.update(.{ .key = .{ .cp = Key.enter } }); @@ -1915,19 +1942,19 @@ test "prompt cancellation restores the selection before regex previews" { } test "a prompt that cannot be opened leaves the open one as it was" { - const Request = @typeInfo(@TypeOf(Pardes.startPrompt)).@"fn".params[2].type.?; + const Request = @typeInfo(@TypeOf(Pardes.startPrompt)).@"fn".params[3].type.?; for ([_]Request{ .{ .search = config.search_marker }, .save, .{ .pipe = .replace }, .{ .pipe = .ignore }, .{ .pipe = .insert }, .{ .pipe = .append } }) |request| { var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{}); const p = try Pardes.init(failing.allocator(), .{ .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("one\n"); - p.startPrompt(pane, .{ .search = config.search_marker }); + p.startPrompt(pane, &pane.body, .{ .search = config.search_marker }); p.update(.{ .key = .{ .cp = 'p', .text = "prior" } }); pane.body.normal = .{ .count = 9, .prefix = .goto }; const previous_normal = pane.body.normal; _ = p.scratch.reset(.free_all); failing.fail_index = failing.alloc_index; - p.startPrompt(pane, request); + p.startPrompt(pane, &pane.body, request); try std.testing.expect(failing.has_induced_failure); try std.testing.expect(pane.prompt == .search); try std.testing.expectEqualStrings(config.search_marker ++ "prior", pane.promptText().?); @@ -1943,7 +1970,7 @@ test "Save's prompt starts from the pane's directory" { for ([_][]const u8{ "/dír space", "/dír space/", "" }) |dir| { try pane.setOwnedCwd(dir); const slash = if (dir.len == 0 or dir[dir.len - 1] != '/') "/" else ""; - p.startPrompt(pane, .save); + p.startPrompt(pane, &pane.body, .save); try std.testing.expect(pane.prompt == .save); const answer = pane.promptText().?; try std.testing.expect(std.mem.startsWith(u8, answer, config.save_marker)); @@ -1954,7 +1981,7 @@ test "Save's prompt starts from the pane's directory" { } test "a prompt opens with one allocation" { - const Request = @typeInfo(@TypeOf(Pardes.startPrompt)).@"fn".params[2].type.?; + const Request = @typeInfo(@TypeOf(Pardes.startPrompt)).@"fn".params[3].type.?; for ([_]Request{ .{ .search = config.search_marker }, .save, .{ .pipe = .replace }, .{ .pipe = .ignore }, .{ .pipe = .insert }, .{ .pipe = .append } }) |request| { var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{}); const p = try Pardes.init(failing.allocator(), .{ .tty_only = true }); @@ -1962,7 +1989,7 @@ test "a prompt opens with one allocation" { const pane = try p.setTestFile("one\n"); _ = p.scratch.reset(.free_all); const before = failing.alloc_index; - p.startPrompt(pane, request); + p.startPrompt(pane, &pane.body, request); try std.testing.expectEqual(before + 1, failing.alloc_index); try std.testing.expect(pane.focus == .body and pane.input.mode == .insert); } |
