summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-27 23:42:53 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commit9584b0682b45f17aaa42157540947904d867777a (patch)
treefaac80b84c02e9611e9df9ff0e210c1d1c895a84
parent95a3a1bb822e583988bfe0f312d0ef219b27a454 (diff)
downloadpardes-9584b0682b45f17aaa42157540947904d867777a.tar.gz
pardes-9584b0682b45f17aaa42157540947904d867777a.zip
Give prompts their own line to type into instead of the tag's tail
A search, pipe or Save prompt wrote its marker and answer onto the end of the tag, so the tag's cursor, its selection, its 9P writes and a prompt abandoned by a click all shared one buffer: the abandoned text stayed in the tag and a 9P write landed inside the prompt. The answer is now its own Text on the pane, typed in insert mode and shown on the notice band where it was already drawn, and the tag never holds any of it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--docs/helix-keys.md5
-rw-r--r--src/File.zig3
-rw-r--r--src/Pipe.zig19
-rw-r--r--src/Text.zig33
-rw-r--r--src/body_layer.zig4
-rw-r--r--src/edit.zig116
-rw-r--r--src/exec.zig8
-rw-r--r--src/mouse.zig4
-rw-r--r--src/normal.zig11
-rw-r--r--src/panes.zig36
-rw-r--r--src/pardes.zig168
-rw-r--r--test/panes.zig154
-rw-r--r--test/pdf.zig4
-rw-r--r--test/snapshots/find.golden1
-rw-r--r--test/snapshots/find.snap3
-rw-r--r--test/snapshots/tagbottomshort.golden2
-rw-r--r--test/snapshots/tagbottomshort.snap9
17 files changed, 282 insertions, 298 deletions
diff --git a/docs/helix-keys.md b/docs/helix-keys.md
index 4de3901a..0813c193 100644
--- a/docs/helix-keys.md
+++ b/docs/helix-keys.md
@@ -235,8 +235,9 @@ other cursors where they are (`FileSnap` holds one range).
### Regex selection (`s` / `S`) — the interactive pair
-Both arm the tag input the same way `/` does — the pattern is typed into the
-tag tail after a marker, no popup — and both re-run on EVERY keystroke, from
+Both arm the prompt the same way `/` does — the pattern is typed after a
+marker on the prompt's own line in the notice band, no popup — and both
+re-run on EVERY keystroke, from
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
diff --git a/src/File.zig b/src/File.zig
index 239aa4ba..bf4bf08c 100644
--- a/src/File.zig
+++ b/src/File.zig
@@ -1129,7 +1129,8 @@ pub fn drawGutter(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, bod
const goff = pane.scroll();
const gcur = Terminal.gridCursor(pane);
const gcrow = if (pane.body.cur_pinned) pane.body.cur_row else @as(i32, gcur.y) + goff;
- const cur_line: i32 = if (active and !pane.tag_edit) gcrow else std.math.minInt(i32);
+ const typing_body = !pane.tag_edit and (pane.prompt == .none or pane.prompt == .del_side);
+ const cur_line: i32 = if (active and typing_body) gcrow else std.math.minInt(i32);
// the body's first row, the way renderPane derives it (Tagbottom)
const body_y = p.bodyTop(r);
const context_bg = p.theme().tag_bg;
diff --git a/src/Pipe.zig b/src/Pipe.zig
index c2b49f31..3e6752a8 100644
--- a/src/Pipe.zig
+++ b/src/Pipe.zig
@@ -73,13 +73,12 @@ 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 prompt = switch (pane.prompt) {
+ const how = switch (pane.prompt) {
.pipe => |pipe| pipe,
else => return,
};
- const tail = pane.tagSlice();
- const armed = tail[@min(prompt.at, tail.len)..];
- const marker = pipeMarker(prompt.how);
+ const armed = pane.promptText() orelse return;
+ const marker = pipeMarker(how);
if (!std.mem.startsWith(u8, armed, marker)) return;
const command = armed[marker.len..];
if (command.len == 0) return;
@@ -87,11 +86,11 @@ pub fn submitPipe(p: *Pardes, id: usize) void {
var ranges: [panes.Text.max_selections]modal.Selection = undefined;
const got = pane.body.ranges(f.content, 0, &ranges);
// `!`/`A-!` take no stdin and run ONCE — see `PendingPipe.nranges`.
- const ninputs = if (prompt.how.pipes()) got.n else 1;
+ const ninputs = if (how.pipes()) got.n else 1;
const inputs = p.gpa.alloc(selection_pipe.Input, ninputs) catch return;
var made: usize = 0;
for (ranges[0..ninputs], 0..) |range, i| {
- const bytes = if (prompt.how.pipes()) bytes: {
+ 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;
@@ -131,7 +130,7 @@ pub fn submitPipe(p: *Pardes, id: usize) void {
.ranges = ranges,
.primary = @intCast(got.pri),
.explicit = (pane.body.vsel.active and pane.body.vsel.explicit) or pane.body.msel.active,
- .how = prompt.how,
+ .how = how,
.nranges = @intCast(got.n),
};
p.emit(.{ .pipe = .{ .id = p.pipe.seq } });
@@ -295,12 +294,12 @@ test "selection pipe prompt submits exact request and Escape cancels" {
pane.body.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true };
p.update(.{ .key = .{ .cp = '|' } });
- try std.testing.expect(pane.tag_edit and pane.prompt == .pipe);
- try std.testing.expect(std.mem.endsWith(u8, pane.tagSlice(), config.pipe_marker));
+ try std.testing.expect(!pane.tag_edit and pane.prompt == .pipe);
+ try std.testing.expectEqualStrings(config.pipe_marker, pane.promptText().?);
try std.testing.expect(nextPipeEffect(p) == null);
p.update(.{ .key = .{ .cp = 't', .text = "tr a-z A-Z" } });
p.update(.{ .key = .{ .cp = Key.enter } });
- try std.testing.expect(!pane.tag_edit and pane.prompt != .pipe);
+ try std.testing.expect(!pane.tag_edit and pane.prompt != .pipe and pane.input.own == null);
const id = nextPipeEffect(p) orelse return error.MissingPipeEffect;
const request = p.pipe.pipeRequest(id) orelse return error.MissingPipeRequest;
try std.testing.expectEqualStrings("tr a-z A-Z", request.command);
diff --git a/src/Text.zig b/src/Text.zig
index ad5aa186..aaa6dc88 100644
--- a/src/Text.zig
+++ b/src/Text.zig
@@ -1,9 +1,11 @@
//! One editable text, acme's Text (editors/acme/dat.h:171-190, text.c): the
//! cursor, the selections and the modal state that edit it, and the undo of
-//! an edit buffer. A pane's body is one. Where its characters come from -- a
-//! file's content, a terminal's edit overlay, a PDF's text -- is the window's
-//! business (edit.zig's editText and flatSurface), the way acme's Text reads
-//! its File; everything here works on the flat text it is handed.
+//! an edit buffer. A pane's body is one, and so is the answer typed into a
+//! pane's prompt. Where a body's characters come from -- a file's content, a
+//! terminal's edit overlay, a PDF's text -- is the window's business
+//! (edit.zig's editText and flatSurface), the way acme's Text reads its File;
+//! every other text owns its characters. Everything here works on the flat
+//! text it is handed.
const std = @import("std");
const panes = @import("panes.zig");
const modal = @import("modal.zig");
@@ -15,6 +17,10 @@ const Text = @This();
pub const Mode = enum { normal, insert, tty };
+/// Which text of its window this is, acme's `what`. It says where the
+/// characters live and whether moving the cursor scrolls a body.
+pub const What = enum { body, input };
+
/// A modal line selection (helix `x`): whole rows [r0, r1], absolute.
pub const LineSel = struct {
active: bool = false,
@@ -41,6 +47,10 @@ pub const SelRange = struct {
sticky: i32 = -1,
};
+what: What = .body,
+/// The characters of a text that holds its own (gpa-owned): everything but
+/// a body, which reads its pane's.
+own: ?[]u8 = null,
mode: Mode = .normal,
cur_pinned: bool = false,
cur_row: i32 = 0,
@@ -65,7 +75,16 @@ ed_redo_len: usize = 0,
/// The window this text belongs to (acme's t->w).
pub fn pane(t: *Text) *Pane {
- return @alignCast(@fieldParentPtr("body", t));
+ return switch (t.what) {
+ .body => @alignCast(@fieldParentPtr("body", t)),
+ .input => @alignCast(@fieldParentPtr("input", t)),
+ };
+}
+
+/// Bring the cursor into view (acme's textshow). Only a body scrolls to it;
+/// a prompt's answer is drawn whole.
+pub fn show(t: *Text) void {
+ if (t.what == .body) t.pane().ensureCursorVisible();
}
pub fn toModalCursor(t: *Text) modal.Cursor {
@@ -143,7 +162,7 @@ pub fn setRange(t: *Text, text: []const u8, row0: i32, r0: modal.Selection, expl
t.cur_pinned = true;
t.sticky_col = -1;
t.normal.clear();
- t.pane().ensureCursorVisible();
+ t.show();
}
pub fn ranges(t: *Text, text: []const u8, row0: i32, out: *[max_selections]modal.Selection) struct { n: usize, pri: usize } {
@@ -602,5 +621,5 @@ pub fn clampCursor(t: *Text, text: []const u8, row0: i32) void {
t.cur_pinned = true;
t.vsel.active = false;
t.msel.active = false;
- t.pane().ensureCursorVisible();
+ t.show();
}
diff --git a/src/body_layer.zig b/src/body_layer.zig
index 71529326..1f210220 100644
--- a/src/body_layer.zig
+++ b/src/body_layer.zig
@@ -460,8 +460,8 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id
}
// cursor: tracks the shell cursor until pinned by a click or a key
- // (the tag cursor above wins while the tag is focused)
- if (active and !pane.tag_edit) {
+ // (the tag's or a prompt's cursor wins while that is being typed into)
+ if (active and !pane.tag_edit and (pane.prompt == .none or pane.prompt == .del_side)) {
if (pane.body.mode != .tty) {
const cur = panes.Terminal.gridCursor(pane);
const goff = panes.Terminal.gridOffset(pane);
diff --git a/src/edit.zig b/src/edit.zig
index f1b381d6..5249a64c 100644
--- a/src/edit.zig
+++ b/src/edit.zig
@@ -84,12 +84,23 @@ pub fn applyPaste(p: *Pardes, bytes: []const u8) void {
const pane = p.panes[id] orelse return;
if (req) |r| if (pane.serial != r.serial) return;
p.active = id;
+ if (pane.prompt != .none and pane.prompt != .del_side) {
+ // an answer is one line: a newline in it would only be submitting it
+ const line = p.scratch.allocator().alloc(u8, bytes.len) catch return;
+ var n: usize = 0;
+ for (bytes) |byte| if (byte != '\n' and byte != '\r') {
+ line[n] = byte;
+ 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);
+ return;
+ }
if (pane.tag_edit) {
const text = p.tagText(p.scratch.allocator(), pane) catch return;
const range: @TypeOf(Pardes.tagSelBounds(pane)) = if (pane.tag_sel) Pardes.tagSelBounds(pane) else .{ .lo = pane.tag_col, .hi = pane.tag_col };
const hi = if (pane.tag_sel) modal.nextGrapheme(text, range.hi) else range.hi;
_ = p.replaceTagText(pane, range.lo, hi, bytes);
- if (normal.selRegexArmed(pane)) |a| normal.applySelRegex(p, pane, a.pat, a.split);
return;
}
if (pane.isTerminal() and pane.body.mode == .tty) return typeToTty(p, id, pane, bytes);
@@ -274,7 +285,7 @@ pub fn exitInsert(p: *Pardes, t: *Text) void {
t.cur_col = @intCast(bc.col);
t.vsel = .{ .active = a_off != back, .row = aa.row, .col = aa.col, .explicit = false };
t.cur_pinned = true;
- t.pane().ensureCursorVisible();
+ t.show();
for (t.sels[0..t.nsel]) |*s| {
const sgap = modal.offsetAt(text, .{ .row = @intCast(@max(0, s.row)), .col = @intCast(@max(0, s.col)) });
const b2 = if (back == gap) sgap else modal.prevGrapheme(text, sgap);
@@ -288,8 +299,15 @@ pub fn exitInsert(p: *Pardes, t: *Text) void {
}
pub fn paneCursorLines(p: *Pardes, t: *Text) ![]const []const u8 {
- const pane = t.pane();
const arena = p.scratch.allocator();
+ if (t.what != .body) {
+ const text = t.own orelse "";
+ const lines = try arena.alloc([]const u8, std.mem.count(u8, text, "\n") + 1);
+ var it = std.mem.splitScalar(u8, text, '\n');
+ for (lines) |*line| line.* = it.next().?;
+ return lines;
+ }
+ const pane = t.pane();
if (pane.file) |*f| return panes.File.cursorLines(arena, pane, f);
if (pane.hasPdf()) {
if (comptime pdf_enabled) return panes.Pdf.textLines(&pane.pdf.?, p.pdf_gpa, arena);
@@ -299,9 +317,8 @@ pub fn paneCursorLines(p: *Pardes, t: *Text) ![]const []const u8 {
}
pub fn paneByteAtDisplay(p: *Pardes, t: *Text, row: i32, from_raw: i32, display_col: i32) i32 {
- const pane = t.pane();
- if (pane.file != null)
- return panes.File.byteAtRowDisplay(pane, row, from_raw, display_col);
+ if (t.what == .body and t.pane().file != null)
+ return panes.File.byteAtRowDisplay(t.pane(), row, from_raw, display_col);
const lines = paneCursorLines(p, t) catch return @max(0, from_raw + display_col);
const local = row;
if (local < 0 or @as(usize, @intCast(local)) >= lines.len)
@@ -316,14 +333,20 @@ pub fn paneByteAtDisplay(p: *Pardes, t: *Text, row: i32, from_raw: i32, display_
/// Freeze the live terminal cursor into the modal coordinate space. The
/// emulator reports screen cells; editing state stores UTF-8 byte offsets.
pub fn pinPaneCursor(p: *Pardes, t: *Text) void {
- const pane = t.pane();
if (t.cur_pinned) return;
+ // Only a body has a live cursor of someone else's to freeze.
+ if (t.what != .body) {
+ t.cur_pinned = true;
+ return;
+ }
+ const pane = t.pane();
pane.pinCursor();
if (pane.file == null and !pane.hasPdf())
t.cur_col = paneByteAtDisplay(p, t, t.cur_row, 0, t.cur_col);
}
pub fn flatSurface(p: *Pardes, t: *Text) ![]const u8 {
+ if (t.what != .body) return t.own orelse "";
const pane = t.pane();
if (pane.file) |f| return f.content;
if (pane.hasPdf()) {
@@ -335,13 +358,16 @@ pub fn flatSurface(p: *Pardes, t: *Text) ![]const u8 {
}
pub fn paneWrapWidth(p: *const Pardes, t: *Text) usize {
+ if (t.what != .body) return 0;
const pane = t.pane();
if (pane.file == null) return 0;
return panes.File.wrapWidth(pane, p.settings.wrap);
}
-/// Route shared edit operations to a file's content or a terminal overlay.
+/// Route shared edit operations to the characters `t` edits: its own, or
+/// for a body its file's content or its terminal's overlay.
fn editText(p: *Pardes, t: *Text, lo: i32, hi: i32, col: i32) ?panes.EditText {
+ if (t.what != .body) return .{ .text = t.own orelse &.{}, .row0 = 0 };
const pane = t.pane();
if (pane.file) |f| return .{ .text = f.content, .row0 = 0 };
if (pane.image != null or pane.hasPdf()) return null;
@@ -358,6 +384,11 @@ 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 {
+ if (t.what != .body) {
+ if (t.own) |old| p.gpa.free(old);
+ t.own = new;
+ return;
+ }
const pane = t.pane();
if (pane.file) |*f| return panes.File.setContent(p, f, new);
panes.Terminal.setEditText(p, pane, new);
@@ -368,6 +399,7 @@ pub const InsertAt = enum { at, append, line_start, line_end, open_below, open_a
/// Files insert only inside existing rows and at grapheme boundaries.
/// Terminal overlays can materialize virtual rows/columns, so keep those.
pub fn clampFileCursor(t: *Text) bool {
+ if (t.what != .body) return false;
const pane = t.pane();
const file = if (pane.file) |*file| file else return false;
const old_row = t.cur_row;
@@ -380,8 +412,7 @@ pub fn clampFileCursor(t: *Text) bool {
}
pub fn enterInsert(p: *Pardes, t: *Text, where: InsertAt, cnt: usize) void {
- const pane = t.pane();
- if (pane.hasPdf()) return;
+ if (t.what == .body and t.pane().hasPdf()) return;
pinPaneCursor(p, t);
_ = clampFileCursor(t);
// snapshot once per insert session (WITH the pre-insert selection) so
@@ -478,7 +509,7 @@ pub fn enterInsert(p: *Pardes, t: *Text, where: InsertAt, cnt: usize) void {
t.mode = .insert;
t.msel.active = false;
t.normal.clear();
- pane.ensureCursorVisible();
+ t.show();
}
pub fn handleInsert(p: *Pardes, t: *Text, key: Key) void {
@@ -487,7 +518,6 @@ pub fn handleInsert(p: *Pardes, t: *Text, key: Key) void {
}
pub fn insertKey(p: *Pardes, t: *Text, key: Key) void {
- const pane = t.pane();
// helix aliases: normalize to the base key and re-dispatch
if (hit(key, config.insert_backspace_alias)) return insertKey(p, t, .{ .cp = Key.backspace });
if (hit(key, config.insert_enter_alias)) return insertKey(p, t, .{ .cp = Key.enter });
@@ -507,7 +537,7 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void {
else => cur0,
};
t.fromModalCursor(nc);
- pane.ensureCursorVisible();
+ t.show();
return;
},
Key.page_up, Key.page_down => {
@@ -515,14 +545,15 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void {
// scroll + cursor snap as normal mode
const flat = flatSurface(p, t) catch return;
const range = t.primaryRange(flat, 0);
- const step: i32 = @intCast(@max(1, pane.rows));
+ if (t.what != .body) return;
+ const step: i32 = @intCast(@max(1, t.pane().rows));
t.scrollViewMove(flat, range, if (key.cp == Key.page_down) step else -step);
return;
},
Key.home => {
t.cur_col = 0;
t.cur_pinned = true;
- pane.ensureCursorVisible();
+ t.show();
return;
},
Key.end => {
@@ -531,7 +562,7 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void {
const cur0 = t.toModalCursor();
t.cur_col = @intCast(panes.File.textLine(t, text, cur0.row).len);
t.cur_pinned = true;
- pane.ensureCursorVisible();
+ t.show();
return;
},
else => {},
@@ -549,7 +580,7 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void {
} else t.cur_col += 1;
}
t.cur_pinned = true;
- pane.ensureCursorVisible();
+ t.show();
return;
}
// helix insert-mode kills (word/line; deleteSpan is exclusive)
@@ -569,7 +600,7 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void {
t.cur_row = @as(i32, @intCast(fc.row)) + e2.row0;
t.cur_col = @intCast(fc.col);
t.cur_pinned = true;
- pane.ensureCursorVisible();
+ t.show();
return;
}
if (hit(key, config.delete_word_forward)) {
@@ -592,7 +623,7 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void {
setEditText(p, t, new);
t.cur_col = 0;
t.cur_pinned = true;
- pane.ensureCursorVisible();
+ t.show();
return;
}
if (hit(key, config.kill_to_line_end)) {
@@ -615,7 +646,7 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void {
t.cur_row += 1;
t.cur_col = @intCast(indent);
t.cur_pinned = true;
- pane.ensureCursorVisible();
+ t.show();
},
Key.backspace => {
if (t.cur_col > 0) {
@@ -625,7 +656,7 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void {
setEditText(p, t, new);
t.cur_col = @intCast(prev);
t.cur_pinned = true;
- pane.ensureCursorVisible();
+ t.show();
return;
}
// at col 0: eat the newline above (join up) — a terminal grows
@@ -640,7 +671,7 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void {
t.cur_row -= 1;
t.cur_col = @intCast(prevlen);
t.cur_pinned = true;
- pane.ensureCursorVisible();
+ t.show();
},
Key.delete => {
// delete forward; at line end eat the newline (join), which on
@@ -661,7 +692,8 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void {
Key.tab => {
const ln = modal.lineSlice(text, c.row);
if (!p.multi_on and c.col > 0 and c.col <= ln.len and ln[c.col - 1] == '.') dot: {
- const f = pane.file orelse break :dot;
+ if (t.what != .body) break :dot;
+ const f = t.pane().file orelse break :dot;
if (f.output != null or !lsp.speaks(f.path)) break :dot;
const seq = p.lsp_seq;
p.lspRequest(p.active, .completion, "");
@@ -684,7 +716,7 @@ pub fn insertTab(p: *Pardes, t: *Text) void {
setEditText(p, t, new);
t.cur_col += @intCast(pad);
t.cur_pinned = true;
- t.pane().ensureCursorVisible();
+ t.show();
}
const Bounds = struct { lo_row: i32, lo_col: i32, hi_row: i32, hi_col: i32 };
@@ -779,7 +811,7 @@ pub fn normalDelete(p: *Pardes, t: *Text, yank: bool) void {
t.cur_col = 0;
t.msel.active = false;
t.cur_pinned = true;
- t.pane().ensureCursorVisible();
+ t.show();
} else {
pushUndo(p, t);
const eb = editTextEol(p, t, .{ .lo_row = t.cur_row, .lo_col = t.cur_col, .hi_row = t.cur_row, .hi_col = t.cur_col }) orelse return;
@@ -799,12 +831,11 @@ pub fn normalDelete(p: *Pardes, t: *Text, yank: bool) void {
}
pub fn normalYank(p: *Pardes, t: *Text) void {
- const pane = t.pane();
t.select = false;
- if (pane.rawPointerText(pane.sel[sel_slot])) |text| {
+ if (t.what == .body) if (t.pane().rawPointerText(t.pane().sel[sel_slot])) |text| {
setYank(p, text);
return;
- }
+ };
if (t.vsel.active) {
setYank(p, vselText(p, t));
return;
@@ -882,11 +913,10 @@ pub fn pasteText(p: *Pardes, t: *Text, y0: []const u8, before: bool, count: usiz
}
t.msel.active = false;
t.cur_pinned = true;
- t.pane().ensureCursorVisible();
+ t.show();
}
pub fn normalChange(p: *Pardes, t: *Text) void {
- const pane = t.pane();
t.select = false;
t.sticky_col = -1;
if (t.vsel.active) {
@@ -913,7 +943,7 @@ pub fn normalChange(p: *Pardes, t: *Text) void {
t.cur_pinned = true;
t.mode = .insert;
t.normal.clear();
- pane.ensureCursorVisible();
+ t.show();
return;
}
setYank(p, vselText(p, t));
@@ -974,7 +1004,7 @@ pub fn normalChange(p: *Pardes, t: *Text) void {
t.cur_pinned = true;
t.mode = .insert;
t.normal.clear();
- pane.ensureCursorVisible();
+ t.show();
} else {
pushUndo(p, t);
const eb = editTextEol(p, t, .{ .lo_row = t.cur_row, .lo_col = t.cur_col, .hi_row = t.cur_row, .hi_col = t.cur_col }) orelse return;
@@ -1075,7 +1105,7 @@ fn replaceWithText(p: *Pardes, t: *Text, y: []const u8) void {
}
t.cur_pinned = true;
t.sticky_col = -1;
- t.pane().ensureCursorVisible();
+ t.show();
}
/// `~` / `` ` `` / ``Alt-` ``: case-map the selection (or the cursor char),
@@ -1174,7 +1204,7 @@ pub fn normalJoin(p: *Pardes, t: *Text) void {
t.msel.active = false;
t.cur_pinned = true;
t.sticky_col = -1;
- t.pane().ensureCursorVisible();
+ t.show();
}
pub fn normalIndent(p: *Pardes, t: *Text, cnt: usize, add: bool) void {
@@ -1242,7 +1272,7 @@ pub fn normalIndent(p: *Pardes, t: *Text, cnt: usize, add: bool) void {
t.msel.active = false;
t.cur_pinned = true;
t.sticky_col = -1;
- t.pane().ensureCursorVisible();
+ t.show();
}
pub fn normalToggleComment(p: *Pardes, t: *Text) void {
@@ -1277,7 +1307,7 @@ pub fn normalToggleComment(p: *Pardes, t: *Text) void {
}
next = @max(next, end);
}
- const ext = if (t.pane().file) |f| std.fs.path.extension(f.path) else "";
+ const ext = if (t.what != .body) "" else if (t.pane().file) |f| std.fs.path.extension(f.path) else "";
var token: []const u8 = config.comment_token_default;
lang: for (config.comment_tokens) |row| {
for (row.exts) |e| if (std.ascii.eqlIgnoreCase(ext, e)) {
@@ -1359,7 +1389,7 @@ pub fn normalAdjustNumber(p: *Pardes, t: *Text, delta: i64) void {
t.cur_col = @intCast(cc.col);
t.cur_pinned = true;
t.sticky_col = -1;
- t.pane().ensureCursorVisible();
+ t.show();
}
/// `]Space` / `[Space`: add blank lines below/above, the cursor staying
@@ -1381,7 +1411,7 @@ pub fn addNewline(p: *Pardes, t: *Text, below: bool, cnt: usize) void {
t.vsel.row += @intCast(cnt);
}
t.cur_pinned = true;
- t.pane().ensureCursorVisible();
+ t.show();
}
/// pair mapping for m-mode: either bracket names the pair; anything else
@@ -1450,7 +1480,7 @@ pub fn surroundAdd(p: *Pardes, t: *Text, ch: u21) void {
t.cur_col = @intCast(close_col + @as(usize, if (r.a.row == r.b.row) 1 else 0));
t.cur_pinned = true;
t.sticky_col = -1;
- t.pane().ensureCursorVisible();
+ t.show();
}
/// `md<ch>`: delete the enclosing pair's chars; the cursor maps through
@@ -1487,7 +1517,7 @@ pub fn surroundDelete(p: *Pardes, t: *Text, ch: u21) void {
t.vsel.active = false;
t.msel.active = false;
t.cur_pinned = true;
- t.pane().ensureCursorVisible();
+ t.show();
}
/// `mr<from><to>`: swap the enclosing <from> pair's chars for <to>'s
@@ -1512,20 +1542,24 @@ pub fn surroundReplace(p: *Pardes, t: *Text, from: u21, to: u21) void {
}
pub fn pushUndo(p: *Pardes, t: *Text) void {
- const pane = t.pane();
// one keystroke, one undo step — even when it edited at ten cursors.
if (p.multi_on and !p.multi_first) return;
+ // a prompt's answer is typed once and gone; it keeps no history
+ if (t.what != .body) return;
+ const pane = t.pane();
if (pane.file != null) return panes.File.pushUndo(p, pane);
panes.Terminal.pushUndo(p, pane);
}
pub fn doUndo(p: *Pardes, t: *Text) void {
+ if (t.what != .body) return;
const pane = t.pane();
if (pane.file != null) return panes.File.undo(p, pane);
panes.Terminal.undo(p, pane);
}
pub fn doRedo(p: *Pardes, t: *Text) void {
+ if (t.what != .body) return;
const pane = t.pane();
if (pane.file != null) return panes.File.redo(p, pane);
panes.Terminal.redo(p, pane);
diff --git a/src/exec.zig b/src/exec.zig
index 0a9d6f31..a2dbac7c 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -219,12 +219,8 @@ pub fn withArg(p: *Pardes, txt: []const u8, arg: ?[]const u8) []const u8 {
/// Enter on a save input: the path is everything past the marker.
pub fn submitSave(p: *Pardes, id: usize) void {
const pane = p.panes[id] orelse return;
- const at = switch (pane.prompt) {
- .save => |a| a,
- else => return,
- };
- const armed = pane.tag_tail[at..pane.tag_tail_len];
- pane.tag_tail_len = at; // drop the prompt back to the seeded tail
+ if (pane.prompt != .save) return;
+ const armed = pane.promptText() orelse return;
if (!std.mem.startsWith(u8, armed, config.save_marker)) return;
const path = std.mem.trim(u8, armed[config.save_marker.len..], " \t\r\n");
if (path.len == 0) return;
diff --git a/src/mouse.zig b/src/mouse.zig
index a840c8fc..5f77634b 100644
--- a/src/mouse.zig
+++ b/src/mouse.zig
@@ -304,6 +304,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
{
if (m.button == config.select_button) if (p.panes[p.active]) |pane| {
if (pane.tag_edit) Pardes.exitTagEdit(pane);
+ pane.closePrompt();
};
var column: ?usize = null;
var x: u16 = 0;
@@ -439,6 +440,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
const pane = p.panes[id] orelse return;
if (id != p.active) if (p.panes[p.active]) |previous| {
if (previous.tag_edit) Pardes.exitTagEdit(previous);
+ previous.closePrompt();
};
const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y;
const body_y = p.bodyTop(r);
@@ -449,12 +451,14 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
} else if (mrow >= tag_y and mrow < tag_y + BOX_H) {
if (mcol < r.x + TAG_TEXT_INSET) return;
p.active = id;
+ pane.closePrompt();
p.enterTagEdit(pane, if (tagColumn(p, .pane, id, p.pointer_tag_hit, false)) |value| @as(i32, value) else @as(i32, mcol) - @as(i32, r.x + TAG_TEXT_INSET));
pane.tag_anchor = pane.tag_col;
p.drag = .{ .tag = .{ .id = id } };
} else {
p.active = id;
if (pane.tag_edit) Pardes.exitTagEdit(pane);
+ pane.closePrompt();
const pointed = edit.pointerTextSelection(p, id, mcol, mrow, m.body_hit, p.pointer_tag_hit) orelse return;
pane.clearPointerSelection(sel_slot);
pane.sel[sel_slot] = pointed.sel;
diff --git a/src/normal.zig b/src/normal.zig
index 192fd933..a77f27a7 100644
--- a/src/normal.zig
+++ b/src/normal.zig
@@ -76,13 +76,8 @@ fn startSelRegex(p: *Pardes, pane: *Pane, split: bool) void {
}
pub fn selRegexArmed(pane: *Pane) ?struct { pat: []const u8, split: bool } {
- const prompt_at = switch (pane.prompt) {
- .search => |at| at,
- else => return null,
- };
- if (pane.nsel_snap == 0) return null;
- const tail = pane.tagSlice();
- const armed = tail[@min(prompt_at, tail.len)..];
+ if (pane.prompt != .search or pane.nsel_snap == 0) return null;
+ const armed = pane.input.own orelse "";
const split = std.mem.startsWith(u8, armed, config.split_marker);
if (!split and !std.mem.startsWith(u8, armed, config.select_marker)) return null;
const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return null;
@@ -274,7 +269,7 @@ pub fn replaySels(p: *Pardes, t: *Text, what: Replay) void {
}
t.setRanges(text, rs[0..n], st[0..n], pri, after_expl);
after.into(t);
- t.pane().ensureCursorVisible(); // the view follows the PRIMARY, not the last pass
+ t.show(); // the view follows the PRIMARY, not the last pass
}
pub fn handleNormal(p: *Pardes, t: *Text, key: Key) void {
diff --git a/src/panes.zig b/src/panes.zig
index e6634583..c26065ca 100644
--- a/src/panes.zig
+++ b/src/panes.zig
@@ -59,12 +59,13 @@ pub const Pane = struct {
const PdfSlot = if (Pdf.enabled) ?Pdf.State else void;
+ /// What an open prompt is asking. The answer is typed into `input`.
pub const Prompt = union(enum) {
none,
- search: u16,
- pipe: struct { at: u16, how: modal.Normal.PipeBehavior },
- /// Save on a scratch buffer or a terminal: the tail is a path to write to.
- save: u16,
+ search,
+ pipe: modal.Normal.PipeBehavior,
+ /// Save on a scratch buffer or a terminal: the answer is a path to write to.
+ save,
/// Del from the keyboard with open panes both above and below: which
/// one takes the rows. Nothing is typed; one key answers.
del_side,
@@ -94,9 +95,11 @@ pub const Pane = struct {
file: ?File.State = null,
image: ?Image.State = null,
pdf: PdfSlot = if (Pdf.enabled) null else {},
- /// Tag-tail input state. The tag text is presentation; this tag carries
- /// which operation owns it and the tail offset restored on submit/cancel.
prompt: Prompt = .none,
+ /// The answer to an open prompt: its marker and what is typed after it,
+ /// edited in insert mode and shown on the notice band. It is its own text
+ /// so a prompt never touches the tag.
+ input: Text = .{ .what = .input },
search_pane: ?usize = null,
search_row: ?usize = null,
look_at: ?LookSpot = null,
@@ -297,20 +300,23 @@ pub const Pane = struct {
pub fn tagSlice(p: *const Pane) []const u8 {
return p.tag_tail[0..p.tag_tail_len];
}
- pub fn promptAt(p: *const Pane) ?u16 {
- return switch (p.prompt) {
- .none, .del_side => null,
- .search, .save => |at| at,
- .pipe => |pipe| pipe.at,
- };
+ /// Close an open prompt, answered or not. Its answer goes with it, and an
+ /// s/S preview keeps whatever it last previewed.
+ pub fn closePrompt(pane: *Pane) void {
+ if (pane.input.own) |text| pane.gpa.free(text);
+ pane.input = .{ .what = .input };
+ pane.prompt = .none;
+ pane.nsel_snap = 0;
}
/// What an open prompt shows on its notice band: the one answer every
/// painter of the band reads.
pub fn promptText(p: *const Pane) ?[]const u8 {
- if (p.prompt == .del_side) return config.del_question;
- const at = p.promptAt() orelse return null;
- return p.tagSlice()[@min(at, p.tag_tail_len)..];
+ return switch (p.prompt) {
+ .none => null,
+ .del_side => config.del_question,
+ else => p.input.own orelse "",
+ };
}
pub fn appendTag(p: *Pane, text: []const u8) bool {
diff --git a/src/pardes.zig b/src/pardes.zig
index 15b2c49b..a29973e7 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -1824,10 +1824,10 @@ test "Save on a scratch asks for a path in its inherited dir and makes it a file
// Save on a scratch arms a PATH input, prefilled with the inherited dir
try std.testing.expect(p.executeBuiltinLine(id, "Save"));
try std.testing.expect(np.prompt == .save);
- try std.testing.expect(std.mem.endsWith(u8, np.tagSlice(), " Save /tmp/pardes-save-dir/"));
+ try std.testing.expectEqualStrings(" Save /tmp/pardes-save-dir/", np.promptText().?);
// typing the filename and submitting converts it into an ordinary file
- try std.testing.expect(np.appendTag("note.txt"));
+ edit.insertKey(p, &np.input, .{ .cp = 'n', .text = "note.txt" });
exec.submitSave(p, id);
try std.testing.expect(np.file.?.output == null);
try std.testing.expectEqualStrings("/tmp/pardes-save-dir/note.txt", np.file.?.path);
@@ -1852,7 +1852,7 @@ test "Save on a terminal writes its plaintext scrollback and stays a terminal" {
try std.testing.expect(p.executeBuiltinLine(0, "Save"));
try std.testing.expect(pane.prompt == .save);
- try std.testing.expect(pane.appendTag("log.txt"));
+ edit.insertKey(p, &pane.input, .{ .cp = 'l', .text = "log.txt" });
exec.submitSave(p, 0);
// the pane is untouched: still a terminal, no file behind it
@@ -1901,7 +1901,7 @@ test "Save on an output buffer writes its rows out and leaves the buffer alone"
try std.testing.expectEqualStrings(file_pane_tail, Pardes.curTail(out));
try std.testing.expect(p.executeBuiltinLine(id, "Save"));
try std.testing.expect(out.prompt == .save);
- try std.testing.expect(out.appendTag("help.txt"));
+ edit.insertKey(p, &out.input, .{ .cp = 'h', .text = "help.txt" });
exec.submitSave(p, id);
// the buffer itself is untouched: same origin, same name, still refillable
@@ -4109,6 +4109,7 @@ pub const Pardes = struct {
if (comptime pdf_enabled) if (pane.pdf) |*pv| panes.Pdf.deinitPane(p, pane, pv);
if (pane.file) |*file| panes.File.deinit(p, pane, file);
if (pane.ovl) |o| p.gpa.free(o.text);
+ if (pane.input.own) |text| p.gpa.free(text);
for (pane.body.ed_undo[0..pane.body.ed_undo_len]) |sn| if (sn.ovl) |o| p.gpa.free(o.text);
for (pane.body.ed_redo[0..pane.body.ed_redo_len]) |sn| if (sn.ovl) |o| p.gpa.free(o.text);
panes.Terminal.deinitEmulator(pane, p.gpa);
@@ -4836,7 +4837,7 @@ pub const Pardes = struct {
}
pub fn enterTagEdit(p: *Pardes, pane: *Pane, col: i32) void {
- if (pane.tag_edit and pane.prompt == .none) pane.tag_saved_col = pane.tag_col;
+ if (pane.tag_edit) pane.tag_saved_col = pane.tag_col;
const edit0: i32 = @intCast((p.tagPrefix(pane) catch return).len);
p.seedTail(pane);
if (!pane.tag_init) return;
@@ -4869,7 +4870,7 @@ pub const Pardes = struct {
}
pub fn exitTagEdit(pane: *Pane) void {
- if (pane.tag_edit and pane.prompt == .none) pane.tag_saved_col = pane.tag_col;
+ if (pane.tag_edit) pane.tag_saved_col = pane.tag_col;
if (pane.isTerminal()) {
if (pane.tag_edit) pane.body.mode = pane.tag_mode;
} else pane.body.mode = .normal;
@@ -4877,8 +4878,6 @@ pub const Pardes = struct {
pane.tag_sel = false;
pane.tag_name_len = null;
pane.tag_scroll = 0;
- pane.prompt = .none; // an abandoned prompt stays as tag text
- pane.nsel_snap = 0; // ...and its s/S preview keeps whatever it previewed
}
pub fn tagSelBounds(pane: *Pane) struct { lo: usize, hi: usize } {
@@ -5273,6 +5272,7 @@ pub const Pardes = struct {
p.active = id;
p.exitHeader();
if (pane.tag_edit) exitTagEdit(pane);
+ pane.closePrompt();
pane.body.mode = previous;
switch (previous) {
.tty => {
@@ -5294,6 +5294,7 @@ pub const Pardes = struct {
p.active = id;
p.exitHeader();
if (pane.tag_edit) exitTagEdit(pane);
+ pane.closePrompt();
if (pane.body.mode == .tty) {
pane.body.mode = .normal;
pane.body.normal.clear();
@@ -5351,7 +5352,7 @@ pub const Pardes = struct {
}
// insert mode owns Ctrl-w (delete word backward, helix); the focus
// prefix applies outside raw tty mode
- if (hit(key, config.window_prefix) and pane.body.mode != .insert) {
+ if (hit(key, config.window_prefix) and pane.body.mode != .insert and pane.prompt == .none) {
p.ctrl_w_pending = true;
return;
}
@@ -5384,34 +5385,29 @@ pub const Pardes = struct {
if (pane.isTerminal() and tty_toggle) {
return p.toggleTty(p.active);
}
- if (pane.prompt != .none and (hit(key, config.search_submit) or hit(key, config.escape))) {
- const submit = hit(key, config.search_submit);
- switch (pane.prompt) {
- .pipe => |pipe| {
- if (submit) Pipe.submitPipe(p, p.active);
- pane.tag_tail_len = @min(pipe.at, pane.tag_tail_len);
- },
- .save => |at| {
- if (submit) exec.submitSave(p, p.active) else pane.tag_tail_len = @min(at, pane.tag_tail_len);
- },
- .search => |at| {
- if (submit)
+ if (pane.prompt != .none) {
+ if (hit(key, config.search_submit) or hit(key, config.escape)) {
+ const submit = hit(key, config.search_submit);
+ 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);
- pane.tag_tail_len = @min(at, pane.tag_tail_len);
- },
- .none, .del_side => unreachable,
+ normal.applySelRegex(p, pane, "", false),
+ .none, .del_side => unreachable,
+ }
+ pane.closePrompt();
+ pane.body.mode = .normal;
+ pane.body.normal.clear();
+ return;
}
- exitTagEdit(pane);
- pane.body.mode = .normal;
- pane.body.normal.clear();
+ edit.handleInsert(p, &pane.input, key);
+ if (normal.selRegexArmed(pane)) |a| normal.applySelRegex(p, pane, a.pat, a.split);
return;
}
if (pane.tag_edit) {
if (pane.body.mode == .normal) p.tagNormalKey(pane, key) else p.tagInsertKey(pane, key);
- const pn = p.panes[p.active] orelse return;
- if (normal.selRegexArmed(pn)) |a| normal.applySelRegex(p, pn, a.pat, a.split);
return;
}
if (pane.body.mode == .normal and (hit(key, config.look_key) or hit(key, config.exec_key))) {
@@ -5506,33 +5502,24 @@ pub const Pardes = struct {
break :pipe Pipe.pipeMarker(how);
},
};
- p.seedTail(pane);
- if (!pane.tag_init) return;
+ // Save asks for a path, so its answer starts out as the pane's own
+ // directory for the name to be typed onto.
const dir = if (request == .save) paneDir(pane) else "";
- const slash = request == .save and (dir.len == 0 or dir[dir.len - 1] != '/');
- var room = pane.tag_tail.len - pane.tag_tail_len;
- if (marker.len > room) return;
- room -= marker.len;
- if (dir.len > room) return;
- room -= dir.len;
- if (slash and room == 0) return;
- const prefix_len = (p.tagPrefix(pane) catch return).len;
- const prompt_at: u16 = @intCast(pane.tag_tail_len);
- _ = pane.appendTag(marker);
- if (request == .save) {
- _ = pane.appendTag(dir);
- if (slash) _ = pane.appendTag("/");
- }
+ 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;
+ if (pane.tag_edit) exitTagEdit(pane);
+ // 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) {
- .search => .{ .search = prompt_at },
- .pipe => |how| .{ .pipe = .{ .at = prompt_at, .how = how } },
- .save => .{ .save = prompt_at },
+ .search => .search,
+ .pipe => |how| .{ .pipe = how },
+ .save => .save,
};
- pane.tag_edit = true;
- pane.tag_sel = false;
- pane.body.mode = .insert;
+ 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
+ // prompt is answered or dropped.
+ pane.body.mode = .normal;
pane.body.normal.clear();
- pane.tag_col = @intCast(prefix_len + pane.tag_tail_len);
}
pub const SavePath = Effect.Buf(effect_path_cap);
@@ -6066,7 +6053,7 @@ pub const Pardes = struct {
p.trackJump();
for (&p.panes, 0..) |*slot, id| {
const pane = slot.* orelse continue;
- if (pane.tag_edit and pane.prompt == .none and (id != p.active or p.topbar_col != null))
+ if (pane.tag_edit and (id != p.active or p.topbar_col != null))
exitTagEdit(pane);
if (comptime terminal_panes) if (pane.terminal) |state| {
var off: u16 = 0;
@@ -6279,18 +6266,14 @@ pub const Pardes = struct {
// the detached wire will encode -- which drops every frame for
// as long as the prompt is up.
const printed = Messages.printRight(s, cx, row, chip -| 1, text, style);
- if (kind != .prompt or id != p.active) continue;
- const at = pane.promptAt() orelse continue;
- const prompt0 = (p.tagPrefix(pane) catch continue).len + at;
- const col = @as(usize, pane.tag_col);
- if (col < prompt0) continue;
+ if (kind != .prompt or id != p.active or pane.prompt == .del_side) continue;
// The cursor follows the text to wherever it landed; a caret
// in the part a narrow band dropped has nowhere to be.
- const prompt_col = panes.File.displayWidth(text[0..@min(col - prompt0, text.len)]);
+ const prompt_col = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]);
if (prompt_col < printed.dropped) continue;
const caret = printed.left + (prompt_col - printed.dropped);
if (caret < tx + tw)
- s.cursor = .{ .x = @intCast(caret), .y = row, .bar = pane.body.mode == .insert };
+ s.cursor = .{ .x = @intCast(caret), .y = row, .bar = pane.input.mode == .insert };
}
}
@@ -6652,7 +6635,7 @@ pub const Pardes = struct {
layer.viewport = .{ .x = r.x + TAG_TEXT_INSET, .y = if (p.settings.tag_bottom) r.y + r.h - BOX_H else r.y, .w = r.w - TAG_TEXT_INSET, .h = BOX_H };
layer.cols = columns;
layer.bg = if (id == p.active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg;
- layer.cursor = if (p.topbar_col == null and pane.promptAt() == null and temporary.cursor != null) .{ .col = temporary.cursor.?.x - TAG_TEXT_INSET, .bar = temporary.cursor.?.bar } else null;
+ layer.cursor = if (p.topbar_col == null and temporary.cursor != null) .{ .col = temporary.cursor.?.x - TAG_TEXT_INSET, .bar = temporary.cursor.?.bar } else null;
}
var text_buf: [1200]u8 = undefined;
// With the workspace tag row handed to native chrome there is no
@@ -6711,14 +6694,11 @@ pub const Pardes = struct {
p.surface.tag_layers[layer_index].fade = fade;
// The caret rides the band, where the prompt's glyphs are,
// at the same place in the text the grid pass puts it.
- if (kind != .prompt or id != p.active) continue;
- const at = pane.promptAt() orelse continue;
- const prompt0 = (p.tagPrefix(pane) catch continue).len + at;
- if (pane.tag_col < prompt0) continue;
- const typed = panes.File.displayWidth(text[0..@min(pane.tag_col - prompt0, text.len)]);
+ if (kind != .prompt or id != p.active or pane.prompt == .del_side) continue;
+ const typed = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]);
const dropped = shown - panes.File.displayWidth(kept);
if (typed < dropped) continue;
- p.surface.tag_layers[layer_index].cursor = .{ .col = @intCast(pad + typed - dropped), .bar = pane.body.mode == .insert };
+ p.surface.tag_layers[layer_index].cursor = .{ .col = @intCast(pad + typed - dropped), .bar = pane.input.mode == .insert };
}
}
if (p.columnBarHeight() > 0) for (0..p.ncol) |column| {
@@ -6877,8 +6857,7 @@ pub const Pardes = struct {
.font_role = .tagline,
});
const tag = try p.tagText(arena, pane);
- const prompt_at = pane.promptAt();
- if (active and pane.tag_edit and prompt_at == null) {
+ if (active and pane.tag_edit) {
const caret = panes.File.rawDisplayCol(tag, pane.tag_col);
var scroll: usize = pane.tag_scroll;
if (caret < scroll) scroll = caret;
@@ -6890,16 +6869,12 @@ pub const Pardes = struct {
scroll = panes.File.rawDisplayCol(tag, raw);
}
pane.tag_scroll = @intCast(scroll);
- } else if (!pane.tag_edit or prompt_at != null) pane.tag_scroll = 0;
- const tag_cut = if (prompt_at) |at|
- @min(tag.len, tag.len - @min(tag.len, pane.tag_tail_len) + at)
- else
- tag.len;
- const tag_start = @min(tag_cut, panes.File.rawAtDisplay(tag, pane.tag_scroll));
- _ = s.print(tx, tag_y, tw, tag[tag_start..tag_cut], tag_style);
+ } else pane.tag_scroll = 0;
+ const tag_start = panes.File.rawAtDisplay(tag, pane.tag_scroll);
+ _ = s.print(tx, tag_y, tw, tag[tag_start..], tag_style);
if (tagTintRange(pane, tag, tag.len - curTail(pane).len)) |name| {
const lo = @max(name.lo, tag_start);
- const hi = @min(name.hi, tag_cut);
+ const hi = @min(name.hi, tag.len);
if (lo < hi) {
var col = panes.File.rawDisplayCol(tag, lo) -| pane.tag_scroll;
const end = panes.File.rawDisplayCol(tag, hi) -| pane.tag_scroll;
@@ -7204,6 +7179,43 @@ test "a prompt chip is a band with its caret, still on the grid, and only the ch
for (pane.sel) |selection| try std.testing.expect(selection.state == .none);
}
+test "a prompt is typed on its own line in the notice band and leaves the tag row alone" {
+ if (platform == .web) return;
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ const pane = try p.setTestFile("one two\n");
+ var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ const tag_before = try std.testing.allocator.dupe(Cell, p.surface.tag_layers[0].cells);
+ defer std.testing.allocator.free(tag_before);
+
+ p.update(.{ .key = .{ .cp = '/' } });
+ p.update(.{ .key = .{ .cp = 't', .text = "tw" } });
+ p.update(.{ .key = .{ .cp = Key.left } });
+ try std.testing.expect(pane.prompt == .search and !pane.tag_edit);
+ try std.testing.expectEqualStrings(config.search_marker ++ "tw", pane.promptText().?);
+ _ = frame.reset(.retain_capacity);
+ _ = try p.render(frame.allocator());
+ // The tagline is exactly what it was: nothing of the prompt is in it.
+ try std.testing.expectEqual(tag_before.len, p.surface.tag_layers[0].cells.len);
+ for (tag_before, p.surface.tag_layers[0].cells) |was, is| try std.testing.expect(was.visuallyEqual(&is));
+ try std.testing.expect(p.surface.tag_layers[0].cursor == null);
+ // The band carries the answer and the caret, one cell back from its end.
+ const band = for (p.surface.tagLayers()) |*layer| {
+ if (layer.cols != 0 and layer.kind == .notice) break layer;
+ } else return error.NoPromptBand;
+ const caret = band.cursor orelse return error.MissingBandCaret;
+ try std.testing.expectEqualStrings("t", band.cells[caret.col - 1].grapheme());
+ try std.testing.expectEqualStrings("w", band.cells[caret.col].grapheme());
+ const grid = p.surface.cursor orelse return error.MissingPromptCursor;
+ try std.testing.expectEqual(p.bodyTop(p.rects[0]), grid.y);
+
+ // Submitting searches for what was typed and gives the prompt its line back.
+ p.update(.{ .key = .{ .cp = Key.enter } });
+ try std.testing.expect(pane.prompt == .none and pane.input.own == null);
+}
+
test "a stillborn pane gives its column to the first pane that joins it" {
if (platform == .web) return;
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 40 });
diff --git a/test/panes.zig b/test/panes.zig
index 4c969ab5..f1adafee 100644
--- a/test/panes.zig
+++ b/test/panes.zig
@@ -1353,8 +1353,9 @@ test "paste into pane commands and prompts never inserts into the body" {
try std.testing.expect(std.mem.endsWith(u8, pane.tagSlice(), " CustomCommand"));
p.update(.{ .key = .{ .cp = Key.escape } });
p.startPrompt(pane, .{ .search = config.search_marker });
- p.update(.{ .paste = "two" });
- try std.testing.expect(std.mem.endsWith(u8, pane.tagSlice(), "two"));
+ p.update(.{ .paste = "t\r\nwo" });
+ try std.testing.expectEqualStrings(config.search_marker ++ "two", pane.promptText().?);
+ try std.testing.expect(std.mem.endsWith(u8, pane.tagSlice(), " CustomCommand"));
try std.testing.expectEqualStrings("one two one\n", pane.file.?.content);
p.update(.{ .key = .{ .cp = Key.escape } });
try std.testing.expect(std.mem.endsWith(u8, pane.tagSlice(), " CustomCommand"));
@@ -1646,7 +1647,7 @@ test "terminal eviction and lower-region scrolling in one read preserve a surviv
try std.testing.expectEqualStrings("kept edit", pane.ovl.?.text);
}
-test "tag prompt entry and cancellation preserve exact text and cursor state" {
+test "a prompt's entry and cancellation leave the tag, the body and its cursor as they were" {
const Case = struct { key: ?Key = null, save: bool = false, marker: []const u8 };
const cases = [_]Case{
.{ .marker = config.search_marker },
@@ -1665,8 +1666,6 @@ test "tag prompt entry and cancellation preserve exact text and cursor state" {
@memcpy(pane.tag_tail[0..tail.len], tail);
pane.tag_tail_len = tail.len;
pane.tag_init = true;
- pane.tag_sel = true;
- pane.tag_col = 1;
pane.body.cur_row = 1;
pane.body.cur_col = 2;
pane.body.cur_pinned = true;
@@ -1677,16 +1676,16 @@ test "tag prompt entry and cancellation preserve exact text and cursor state" {
} else {
p.startPrompt(pane, .{ .search = config.search_marker });
}
- try std.testing.expect(pane.tag_edit);
- try std.testing.expect(!pane.tag_sel);
- try std.testing.expectEqual(.insert, pane.body.mode);
+ // The answer is typed into the prompt's own line, never the tag.
+ try std.testing.expect(!pane.tag_edit);
+ try std.testing.expectEqual(.insert, pane.input.mode);
+ try std.testing.expectEqualStrings(case.marker, pane.promptText().?);
+ try std.testing.expectEqual(@as(i32, @intCast(case.marker.len)), pane.input.cur_col);
+ try std.testing.expectEqualStrings(tail, pane.tag_tail[0..pane.tag_tail_len]);
try std.testing.expectEqualDeep(modal.Normal.State{}, pane.body.normal);
- try std.testing.expectEqualStrings(tail, pane.tag_tail[0..tail.len]);
- try std.testing.expectEqualStrings(case.marker, pane.tag_tail[tail.len..pane.tag_tail_len]);
- try std.testing.expectEqual(pane.file.?.path.len + pane.tag_tail_len, pane.tag_col);
p.update(.{ .key = .{ .cp = Key.escape } });
try std.testing.expect(pane.prompt == .none);
- try std.testing.expect(!pane.tag_edit);
+ try std.testing.expect(pane.input.own == null);
try std.testing.expectEqual(.normal, pane.body.mode);
try std.testing.expectEqualStrings(tail, pane.tag_tail[0..pane.tag_tail_len]);
try std.testing.expectEqual(@as(i32, 1), pane.body.cur_row);
@@ -1695,28 +1694,23 @@ test "tag prompt entry and cancellation preserve exact text and cursor state" {
}
}
-test "tag prompt capacity refusal leaves the existing tail and input state untouched" {
+test "a prompt opens whatever the tag already holds" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
defer p.deinit();
const pane = try p.setTestFile("one\n");
@memset(&pane.tag_tail, 'x');
- pane.tag_tail_len = pane.tag_tail.len - 1;
+ pane.tag_tail_len = pane.tag_tail.len;
pane.tag_init = true;
- pane.tag_col = 17;
- pane.tag_sel = true;
for (0..3) |case| {
switch (case) {
0 => p.startPrompt(pane, .{ .search = config.search_marker }),
1 => p.startPrompt(pane, .save),
else => p.update(.{ .key = .{ .cp = '|' } }),
}
- try std.testing.expect(pane.prompt == .none);
- try std.testing.expect(!pane.tag_edit);
- try std.testing.expect(pane.tag_sel);
- try std.testing.expectEqual(.normal, pane.body.mode);
- try std.testing.expectEqual(@as(u16, 17), pane.tag_col);
- try std.testing.expectEqual(pane.tag_tail.len - 1, pane.tag_tail_len);
+ try std.testing.expect(pane.prompt != .none);
+ try std.testing.expectEqual(pane.tag_tail.len, pane.tag_tail_len);
for (pane.tag_tail) |byte| try std.testing.expectEqual(@as(u8, 'x'), byte);
+ p.update(.{ .key = .{ .cp = Key.escape } });
}
}
@@ -1735,7 +1729,7 @@ test "generated output refuses every pipe prompt before seeding the tag" {
}
}
-test "tag prompt owns the pipe behavior through cancellation and submission" {
+test "a prompt owns the pipe behavior through cancellation and submission" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
defer p.deinit();
const pane = try p.setTestFile("one\ntwo\n");
@@ -1746,7 +1740,7 @@ test "tag prompt owns the pipe behavior through cancellation and submission" {
p.startPrompt(pane, .{ .pipe = .append });
p.update(.{ .key = .{ .cp = Key.escape } });
p.startPrompt(pane, .{ .pipe = how });
- try std.testing.expectEqual(how, pane.prompt.pipe.how);
+ try std.testing.expectEqual(how, pane.prompt.pipe);
p.update(.{ .key = .{ .cp = 'c', .text = "cat" } });
p.update(.{ .key = .{ .cp = Key.enter } });
const pending = p.pipe.wait orelse return error.MissingPipe;
@@ -1758,7 +1752,7 @@ test "tag prompt owns the pipe behavior through cancellation and submission" {
}
}
-test "tag prompt cancellation restores the selection before regex previews" {
+test "prompt cancellation restores the selection before regex previews" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
defer p.deinit();
const pane = try p.setTestFile("one two one");
@@ -1786,133 +1780,57 @@ test "tag prompt cancellation restores the selection before regex previews" {
}
}
-test "tag prompt prefix allocation failure preserves an already seeded prompt" {
+test "a prompt that cannot be opened leaves the open one as it was" {
const Request = @typeInfo(@TypeOf(Pardes.startPrompt)).@"fn".params[2].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");
- const tail = " keep /prior";
- @memcpy(pane.tag_tail[0..tail.len], tail);
- pane.tag_tail_len = tail.len;
- pane.tag_init = true;
- pane.prompt = .{ .search = 5 };
- pane.tag_edit = true;
- pane.tag_sel = true;
- pane.tag_col = 14;
- pane.tag_anchor = 7;
+ p.startPrompt(pane, .{ .search = config.search_marker });
+ p.update(.{ .key = .{ .cp = 'p', .text = "prior" } });
pane.body.normal = .{ .count = 9, .prefix = .goto };
- const previous_prompt = pane.prompt;
const previous_normal = pane.body.normal;
_ = p.scratch.reset(.free_all);
failing.fail_index = failing.alloc_index;
p.startPrompt(pane, request);
try std.testing.expect(failing.has_induced_failure);
- try std.testing.expectEqualDeep(previous_prompt, pane.prompt);
+ try std.testing.expect(pane.prompt == .search);
+ try std.testing.expectEqualStrings(config.search_marker ++ "prior", pane.promptText().?);
try std.testing.expectEqualDeep(previous_normal, pane.body.normal);
- try std.testing.expectEqualStrings(tail, pane.tag_tail[0..pane.tag_tail_len]);
- try std.testing.expect(pane.tag_edit and pane.tag_sel);
- try std.testing.expectEqual(.normal, pane.body.mode);
- try std.testing.expectEqual(@as(u16, 14), pane.tag_col);
- try std.testing.expectEqual(@as(u16, 7), pane.tag_anchor);
+ try std.testing.expect(!pane.tag_init and !pane.tag_edit);
}
}
-test "tag prompt Save requires room for its complete directory prefix" {
+test "Save's prompt starts from the pane's directory" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
defer p.deinit();
const pane = try p.setTestFile("one\n");
for ([_][]const u8{ "/dír space", "/dír space/", "" }) |dir| {
try pane.setOwnedCwd(dir);
- const slash: usize = @intFromBool(dir.len == 0 or dir[dir.len - 1] != '/');
- const needed = config.save_marker.len + dir.len + slash;
- for ([_]usize{ 1, 0 }) |shortfall| {
- @memset(&pane.tag_tail, 'x');
- pane.tag_tail_len = pane.tag_tail.len - needed + shortfall;
- pane.tag_init = true;
- pane.tag_edit = false;
- pane.tag_sel = true;
- pane.tag_col = 17;
- const start = pane.tag_tail_len;
- p.startPrompt(pane, .save);
- if (shortfall != 0) {
- try std.testing.expect(pane.prompt == .none);
- try std.testing.expectEqual(start, pane.tag_tail_len);
- try std.testing.expect(!pane.tag_edit and pane.tag_sel);
- try std.testing.expectEqual(@as(u16, 17), pane.tag_col);
- for (pane.tag_tail) |byte| try std.testing.expectEqual(@as(u8, 'x'), byte);
- } else {
- try std.testing.expect(pane.prompt == .save);
- try std.testing.expectEqual(pane.tag_tail.len, pane.tag_tail_len);
- try std.testing.expectEqualStrings(config.save_marker, pane.tag_tail[start..][0..config.save_marker.len]);
- try std.testing.expectEqualStrings(dir, pane.tag_tail[start + config.save_marker.len ..][0..dir.len]);
- try std.testing.expectEqual(@as(u8, '/'), pane.tag_tail[pane.tag_tail_len - 1]);
- p.update(.{ .key = .{ .cp = Key.escape } });
- }
- }
+ const slash = if (dir.len == 0 or dir[dir.len - 1] != '/') "/" else "";
+ p.startPrompt(pane, .save);
+ try std.testing.expect(pane.prompt == .save);
+ const answer = pane.promptText().?;
+ try std.testing.expect(std.mem.startsWith(u8, answer, config.save_marker));
+ try std.testing.expectEqualStrings(dir, answer[config.save_marker.len..][0..dir.len]);
+ try std.testing.expectEqualStrings(slash, answer[config.save_marker.len + dir.len ..]);
+ p.update(.{ .key = .{ .cp = Key.escape } });
}
}
-test "tag prompt successful seeded entry needs one scratch allocation" {
+test "a prompt opens with one allocation" {
const Request = @typeInfo(@TypeOf(Pardes.startPrompt)).@"fn".params[2].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");
- pane.tag_tail_len = 0;
- pane.tag_init = true;
_ = p.scratch.reset(.free_all);
const before = failing.alloc_index;
p.startPrompt(pane, request);
try std.testing.expectEqual(before + 1, failing.alloc_index);
- try std.testing.expect(pane.tag_edit and pane.body.mode == .insert);
- }
-}
-
-test "tag prompt failure may initialize only the default tail" {
- const Request = @typeInfo(@TypeOf(Pardes.startPrompt)).@"fn".params[2].type.?;
- for ([_]Request{ .{ .search = config.search_marker }, .save, .{ .pipe = .replace }, .{ .pipe = .ignore }, .{ .pipe = .insert }, .{ .pipe = .append } }) |request| {
- var saw_seeded_failure = false;
- var succeeded = false;
- for (0..16) |failure| {
- 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");
- const path = try p.gpa.dupe(u8, "/" ++ "a" ** 2048 ++ ".zig");
- p.gpa.free(pane.file.?.path);
- pane.file.?.path = path;
- p.seedTail(pane);
- try std.testing.expect(pane.tag_init);
- const default_tail = pane.tag_tail;
- const default_len = pane.tag_tail_len;
- pane.tag_init = false;
- pane.tag_tail_len = 0;
- pane.tag_col = 17;
- pane.tag_sel = true;
- pane.body.normal = .{ .count = 9, .prefix = .goto };
- const previous_normal = pane.body.normal;
- _ = p.scratch.reset(.free_all);
- failing.fail_index = failing.alloc_index + failure;
- p.startPrompt(pane, request);
- if (!failing.has_induced_failure) {
- try std.testing.expect(pane.tag_edit and pane.body.mode == .insert);
- succeeded = true;
- break;
- }
- try std.testing.expect(pane.prompt == .none);
- try std.testing.expect(!pane.tag_edit and pane.tag_sel);
- try std.testing.expectEqual(.normal, pane.body.mode);
- try std.testing.expectEqualDeep(previous_normal, pane.body.normal);
- try std.testing.expectEqual(@as(u16, 17), pane.tag_col);
- if (pane.tag_init) {
- saw_seeded_failure = true;
- try std.testing.expectEqualStrings(default_tail[0..default_len], pane.tag_tail[0..pane.tag_tail_len]);
- } else try std.testing.expectEqual(@as(usize, 0), pane.tag_tail_len);
- }
- try std.testing.expect(succeeded and saw_seeded_failure);
+ try std.testing.expect(!pane.tag_edit and pane.input.mode == .insert);
}
}
diff --git a/test/pdf.zig b/test/pdf.zig
index 52fb62aa..5bc271d4 100644
--- a/test/pdf.zig
+++ b/test/pdf.zig
@@ -1113,9 +1113,9 @@ test "PDF normal adapter consumes unsupported actions and navigates page fallbac
p.update(.{ .key = .{ .cp = '!' } });
try std.testing.expect(!p.leader_on);
p.update(.{ .key = .{ .cp = '/' } });
- try std.testing.expect(pane.tag_edit);
+ try std.testing.expect(pane.prompt == .search and !pane.tag_edit);
p.update(.{ .key = .{ .cp = Key.escape } });
- try std.testing.expect(!pane.tag_edit);
+ try std.testing.expect(pane.prompt == .none);
}
test "Escape cancels PDF chrome in place and Shift-Escape leaves the pane" {
diff --git a/test/snapshots/find.golden b/test/snapshots/find.golden
index 12dac242..b6b7b322 100644
--- a/test/snapshots/find.golden
+++ b/test/snapshots/find.golden
@@ -81,7 +81,6 @@
|4:
|30: 1 gamma body Last
== snap nomatch grid=120x41 cursor=7,39
-|2: /tmp/pardes-snap/find/cwd/here.txt Save Tty Collapse Del Find /
|3: 1 Find
|18: /tmp/pardes-snap/find/cwd/+Search Save Tty Collapse Del
|19: 1
diff --git a/test/snapshots/find.snap b/test/snapshots/find.snap
index fbd585f7..fe1ecad3 100644
--- a/test/snapshots/find.snap
+++ b/test/snapshots/find.snap
@@ -64,7 +64,8 @@ key enter
stable 700 15000
snap back
# executing the WORD Find (middle-click in a body) arms the same input — the
-# enum IS the dispatch — and Esc abandons it, restoring the tag tail
+# enum IS the dispatch — and Esc abandons it. The input is its own line, so an
+# abandoned one leaves nothing behind in the tag
press middle 8 4
release middle 8 4
stable 700 10000
diff --git a/test/snapshots/tagbottomshort.golden b/test/snapshots/tagbottomshort.golden
index a5d53fe2..c11fe4ac 100644
--- a/test/snapshots/tagbottomshort.golden
+++ b/test/snapshots/tagbottomshort.golden
@@ -21,7 +21,7 @@
| New Tty Find Grep Joincol Delcol
| 1 one /ZZ
| /tmp/pardes-snap/tagbottomshort/cwd/s.txt Save Tty Collapse Del
-== snap tag-only grid=80x2 cursor=70,1
+== snap tag-only grid=80x2 cursor=79,1
|Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill
| /tmp/pardes-snap/tagbottomshort/cwd/s.txt Save Tty Collapse Del
== snap regrown grid=80x11 cursor=79,2
diff --git a/test/snapshots/tagbottomshort.snap b/test/snapshots/tagbottomshort.snap
index 2574df36..58da6a87 100644
--- a/test/snapshots/tagbottomshort.snap
+++ b/test/snapshots/tagbottomshort.snap
@@ -10,8 +10,8 @@ wait 8000 s.txt
stable 700 20000
key space t b
stable 700 15000
-# `/` with room to spare: the prompt is cut off the tagline (grid 9) and drawn
-# on grid 8, the last body row, with the cursor.
+# `/` with room to spare: the prompt is its own line, drawn on grid 8, the last
+# body row, with the cursor; the tagline (grid 9) never holds any of it.
key /
send ZZ
stable 600 8000
@@ -27,9 +27,8 @@ resize 4 80
stable 700 10000
snap one-body
# the tagline alone. No body row, so nothing of the prompt is drawn anywhere —
-# not over the tag, not on the row above the pane. The tagline itself is
-# untouched: the marker sits after the builtins, so cutting the tail there
-# leaves every column of it exactly as `tag-only` would look unarmed.
+# not over the tag, not on the row above the pane, and its caret with it. The
+# tagline itself is untouched, exactly as `tag-only` would look unarmed.
resize 2 80
stable 700 10000
snap tag-only