summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 00:01:57 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commit23f430573efe4f6ef55b0180aa0d742ff4eca1d9 (patch)
treeda2157ba73d6abff4b1674541433294fb6c2daa9
parent9584b0682b45f17aaa42157540947904d867777a (diff)
downloadpardes-23f430573efe4f6ef55b0180aa0d742ff4eca1d9.tar.gz
pardes-23f430573efe4f6ef55b0180aa0d742ff4eca1d9.zip
Make a pane's tag a Text edited with the body's own keys
The tag was a fixed buffer with its own one-line editor, a cursor measured across the path, and a hijack of the body's mode while it was typed into. It is now the pane's second Text: its own characters after the computed prefix (path, dirty marker, PDF page), which is never stored in it, so a rename or a dirty marker never moves its cursor. Normal and insert mode are the body's, undo included; `:` moves the keyboard between body and tag, the tag remembers its cursor and starts on Save, and executing a word from it hands the keyboard back. The mouse still sees the prefix: a sweep selects across it and a click on the path drafts a new name. Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--src/File.zig2
-rw-r--r--src/Pipe.zig6
-rw-r--r--src/Terminal.zig63
-rw-r--r--src/Text.zig79
-rw-r--r--src/body_layer.zig5
-rw-r--r--src/colors.zig2
-rw-r--r--src/dump.zig2
-rw-r--r--src/edit.zig41
-rw-r--r--src/exec.zig7
-rw-r--r--src/look.zig3
-rw-r--r--src/mouse.zig82
-rw-r--r--src/ninep/pane.zig44
-rw-r--r--src/normal.zig20
-rw-r--r--src/panes.zig66
-rw-r--r--src/pardes.zig633
-rw-r--r--test/mode.zig31
-rw-r--r--test/panes.zig384
-rw-r--r--test/pdf.zig23
-rw-r--r--test/snapshots/cmdword.golden4
-rw-r--r--test/snapshots/cmdword.snap24
-rw-r--r--test/snapshots/collapse.golden4
-rw-r--r--test/snapshots/collapse.snap7
-rw-r--r--test/snapshots/tag.snap20
-rw-r--r--test/snapshots/tagalign.snap9
-rw-r--r--test/snapshots/tagcmd.snap20
-rw-r--r--test/snapshots/tagnav.golden43
-rw-r--r--test/snapshots/tagnav.snap136
-rw-r--r--test/snapshots/tagpath.golden23
-rw-r--r--test/snapshots/tagpath.snap28
-rw-r--r--test/snapshots/ttyclick.golden4
-rw-r--r--test/snapshots/ttyclick.snap14
31 files changed, 853 insertions, 976 deletions
diff --git a/src/File.zig b/src/File.zig
index bf4bf08c..2bf9f57d 100644
--- a/src/File.zig
+++ b/src/File.zig
@@ -1129,7 +1129,7 @@ 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 typing_body = !pane.tag_edit and (pane.prompt == .none or pane.prompt == .del_side);
+ const typing_body = pane.focus == .body 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);
diff --git a/src/Pipe.zig b/src/Pipe.zig
index 3e6752a8..4cabfd88 100644
--- a/src/Pipe.zig
+++ b/src/Pipe.zig
@@ -294,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(pane.focus == .body 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 and pane.input.own == null);
+ try std.testing.expect(pane.focus == .body 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);
@@ -311,7 +311,7 @@ test "selection pipe prompt submits exact request and Escape cancels" {
p.update(.{ .key = .{ .cp = '|' } });
p.update(.{ .key = .{ .cp = 'c', .text = "cat" } });
p.update(.{ .key = .{ .cp = Key.escape } });
- try std.testing.expect(!pane.tag_edit and pane.prompt != .pipe);
+ try std.testing.expect(pane.focus == .body and pane.prompt != .pipe);
try std.testing.expectEqualSlices(u8, before, pane.file.?.content);
try std.testing.expect(nextPipeEffect(p) == null);
}
diff --git a/src/Terminal.zig b/src/Terminal.zig
index fbe7bfc0..7b5af696 100644
--- a/src/Terminal.zig
+++ b/src/Terminal.zig
@@ -686,8 +686,7 @@ pub fn feedOutput(p: *Pardes, pane: *Pane, bytes: []const u8) void {
const at_bottom = atBottom(pane);
if (has_positions) ingestWithPositions(pane, bytes) else ingest(pane, bytes);
p.shell_rows.markStale(pane);
- const body_mode = if (pane.tag_edit) pane.tag_mode else pane.body.mode;
- if (body_mode == .tty and at_bottom) followOutput(pane);
+ if (pane.body.mode == .tty and at_bottom) followOutput(pane);
}
const RowPin = struct { pin: ?*ghostty_vt.Pin = null, offset: i32 = 0 };
@@ -2463,64 +2462,22 @@ pub fn padOutputBelowEdits(p: *Pardes, id: usize) void {
while (i < pad) : (i += 1) p.emitWrite(id, "\r");
}
-/// the snapshot takes ownership of a COPY of the edit buffer's text
-pub fn snap(p: *Pardes, pane: *Pane) ?Snapshot {
- var ovl: ?EditBuffer = null;
- if (pane.ovl) |o| ovl = .{ .row = o.row, .rows = o.rows, .text = p.gpa.dupe(u8, o.text) catch return null };
- return .{ .ovl = ovl, .cur_row = pane.body.cur_row, .cur_col = pane.body.cur_col, .vsel = pane.body.vsel };
-}
-
-/// undo/redo restores the selection recorded with the snapshot (helix
-/// keeps selections in its history transactions)
-pub fn restoreSnap(p: *Pardes, pane: *Pane, s: Snapshot) void {
- if (pane.ovl) |o| p.gpa.free(o.text);
- pane.ovl = s.ovl;
- pane.body.cur_row = s.cur_row;
- pane.body.cur_col = s.cur_col;
- pane.body.cur_pinned = true;
- pane.body.vsel = s.vsel;
- pane.body.msel.active = false;
- pane.ensureCursorVisible();
-}
-
-fn pushHistory(gpa: std.mem.Allocator, slots: []Snapshot, len: *usize, value: Snapshot) void {
- if (len.* == slots.len) {
- if (slots[0].ovl) |overlay| gpa.free(overlay.text);
- std.mem.copyForwards(Snapshot, slots[0 .. slots.len - 1], slots[1..]);
- len.* -= 1;
- }
- slots[len.*] = value;
- len.* += 1;
-}
-
pub fn pushUndo(p: *Pardes, pane: *Pane) void {
- const current = pane.ovl orelse EditBuffer{ .rows = 0 };
- if (pane.body.ed_undo_len > 0) {
- const top = pane.body.ed_undo[pane.body.ed_undo_len - 1];
- const same = if (top.ovl) |overlay| pane.ovl != null and overlay.row == current.row and
- overlay.rows == current.rows and std.mem.eql(u8, overlay.text, current.text) else pane.ovl == null;
- if (same) return;
- }
- const value = snap(p, pane) orelse return;
- pushHistory(p.gpa, &pane.body.ed_undo, &pane.body.ed_undo_len, value);
- for (pane.body.ed_redo[0..pane.body.ed_redo_len]) |item| if (item.ovl) |overlay| p.gpa.free(overlay.text);
- pane.body.ed_redo_len = 0;
+ pane.body.remember(p.gpa, pane.ovl);
}
pub fn undo(p: *Pardes, pane: *Pane) void {
- if (pane.body.ed_undo_len == 0) return;
- const current = snap(p, pane) orelse return;
- pushHistory(p.gpa, &pane.body.ed_redo, &pane.body.ed_redo_len, current);
- pane.body.ed_undo_len -= 1;
- restoreSnap(p, pane, pane.body.ed_undo[pane.body.ed_undo_len]);
+ const back_to = pane.body.step(p.gpa, pane.ovl, true) orelse return;
+ if (pane.ovl) |o| p.gpa.free(o.text);
+ pane.ovl = back_to.ovl;
+ pane.ensureCursorVisible();
}
pub fn redo(p: *Pardes, pane: *Pane) void {
- if (pane.body.ed_redo_len == 0) return;
- const current = snap(p, pane) orelse return;
- pushHistory(p.gpa, &pane.body.ed_undo, &pane.body.ed_undo_len, current);
- pane.body.ed_redo_len -= 1;
- restoreSnap(p, pane, pane.body.ed_redo[pane.body.ed_redo_len]);
+ const forward_to = pane.body.step(p.gpa, pane.ovl, false) orelse return;
+ if (pane.ovl) |o| p.gpa.free(o.text);
+ pane.ovl = forward_to.ovl;
+ pane.ensureCursorVisible();
}
pub fn ptyReport(handler: *ghostty_vt.TerminalStream.Handler, data: [:0]const u8) void {
diff --git a/src/Text.zig b/src/Text.zig
index aaa6dc88..3e48a42e 100644
--- a/src/Text.zig
+++ b/src/Text.zig
@@ -1,11 +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, 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.
+//! an edit buffer. A pane's body is one, its tag is another, 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");
@@ -19,7 +19,7 @@ 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 };
+pub const What = enum { body, tag, input };
/// A modal line selection (helix `x`): whole rows [r0, r1], absolute.
pub const LineSel = struct {
@@ -49,7 +49,8 @@ pub const SelRange = struct {
what: What = .body,
/// The characters of a text that holds its own (gpa-owned): everything but
-/// a body, which reads its pane's.
+/// a body, which reads its pane's. A tag nobody has changed has none yet and
+/// shows the default its pane's kind starts with.
own: ?[]u8 = null,
mode: Mode = .normal,
cur_pinned: bool = false,
@@ -77,12 +78,14 @@ ed_redo_len: usize = 0,
pub fn pane(t: *Text) *Pane {
return switch (t.what) {
.body => @alignCast(@fieldParentPtr("body", t)),
+ .tag => @alignCast(@fieldParentPtr("tag", 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.
+/// the tagline follows its caret where it is drawn, and a prompt's answer is
+/// drawn whole.
pub fn show(t: *Text) void {
if (t.what == .body) t.pane().ensureCursorVisible();
}
@@ -273,8 +276,8 @@ pub fn multiSelAction(t: *Text, text: []const u8, kind: modal.Normal.Multi, cnt:
return setRanges(t, text, out[0..m], &.{}, @min(got.pri, m - 1), expl);
}
if (kind == .rotate_forward or kind == .rotate_backward) {
- const step = cnt % n;
- const pri = if (kind == .rotate_forward) (got.pri + step) % n else (got.pri + (n - step)) % n;
+ const turn = cnt % n;
+ const pri = if (kind == .rotate_forward) (got.pri + turn) % n else (got.pri + (n - turn)) % n;
return setRanges(t, text, rs[0..n], &.{}, pri, expl);
}
if (kind == .merge) {
@@ -510,6 +513,8 @@ pub fn visualMove(
/// Ctrl-d/u: scroll half a page AND move the cursor by the same rows
pub fn halfPageMove(t: *Text, text: []const u8, range: modal.Selection, down: bool) void {
+ // a view to move through is a body's; a tag or an answer is all shown
+ if (t.what != .body) return;
const half: i32 = @max(1, @divTrunc(@as(i32, t.pane().rows), 2));
t.pane().scrollBy(if (down) half else -half);
verticalMove(t, text, range, down, @intCast(half));
@@ -518,6 +523,8 @@ pub fn halfPageMove(t: *Text, text: []const u8, range: modal.Selection, down: bo
/// helix `scroll` without cursor sync (Ctrl-f/b, PgUp/PgDn, zj/zk): shift
/// the view, then snap a fallen-out cursor to the near scrolloff edge, col 0
pub fn scrollViewMove(t: *Text, text: []const u8, range: modal.Selection, delta: i32) void {
+ // a view to move through is a body's; a tag or an answer is all shown
+ if (t.what != .body) return;
const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, t.pane().rows) - 1, 2));
t.pane().scrollBy(delta);
const top = t.pane().scroll();
@@ -540,6 +547,8 @@ pub fn scrollViewMove(t: *Text, text: []const u8, range: modal.Selection, delta:
/// gt/gc/gb: view-relative rows, col 0, scrolloff clamped (helix goto_window)
pub fn gotoWindow(t: *Text, text: []const u8, range: modal.Selection, which: enum { top, center, bottom }, cnt: usize) void {
+ // a view to move through is a body's; a tag or an answer is all shown
+ if (t.what != .body) return;
const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, t.pane().rows) - 1, 2));
const top = t.pane().scroll();
const last_row: i32 = @intCast(File.textLineCount(t, text) - 1);
@@ -623,3 +632,51 @@ pub fn clampCursor(t: *Text, text: []const u8, row0: i32) void {
t.msel.active = false;
t.show();
}
+
+fn pushHistory(gpa: std.mem.Allocator, slots: []Terminal.Snapshot, len: *usize, value: Terminal.Snapshot) void {
+ if (len.* == slots.len) {
+ if (slots[0].ovl) |overlay| gpa.free(overlay.text);
+ std.mem.copyForwards(Terminal.Snapshot, slots[0 .. slots.len - 1], slots[1..]);
+ len.* -= 1;
+ }
+ slots[len.*] = value;
+ len.* += 1;
+}
+
+/// Record an edit buffer (a terminal's overlay, a tag's own text) as it
+/// stands before an edit, once per change of it, and forget what could have
+/// been redone. Null is a buffer not made yet.
+pub fn remember(t: *Text, gpa: std.mem.Allocator, current: ?Terminal.EditBuffer) void {
+ if (t.ed_undo_len > 0) {
+ const top = t.ed_undo[t.ed_undo_len - 1];
+ const same = if (top.ovl) |overlay| if (current) |now| overlay.row == now.row and
+ overlay.rows == now.rows and std.mem.eql(u8, overlay.text, now.text) else false else current == null;
+ if (same) return;
+ }
+ const copy: ?Terminal.EditBuffer = if (current) |now| .{ .row = now.row, .rows = now.rows, .text = gpa.dupe(u8, now.text) catch return } else null;
+ pushHistory(gpa, &t.ed_undo, &t.ed_undo_len, .{ .ovl = copy, .cur_row = t.cur_row, .cur_col = t.cur_col, .vsel = t.vsel });
+ for (t.ed_redo[0..t.ed_redo_len]) |item| if (item.ovl) |overlay| gpa.free(overlay.text);
+ t.ed_redo_len = 0;
+}
+
+/// Undo (back) or redo one edit of an edit buffer: `current` goes on the
+/// other history and the state to return to comes back, its cursor and
+/// selection already restored here. The caller installs its buffer, which
+/// it then owns. Null when there is nothing to step to.
+pub fn step(t: *Text, gpa: std.mem.Allocator, current: ?Terminal.EditBuffer, back: bool) ?Terminal.Snapshot {
+ const from, const from_len, const to, const to_len = if (back)
+ .{ &t.ed_undo, &t.ed_undo_len, &t.ed_redo, &t.ed_redo_len }
+ else
+ .{ &t.ed_redo, &t.ed_redo_len, &t.ed_undo, &t.ed_undo_len };
+ if (from_len.* == 0) return null;
+ const copy: ?Terminal.EditBuffer = if (current) |now| .{ .row = now.row, .rows = now.rows, .text = gpa.dupe(u8, now.text) catch return null } else null;
+ pushHistory(gpa, to, to_len, .{ .ovl = copy, .cur_row = t.cur_row, .cur_col = t.cur_col, .vsel = t.vsel });
+ from_len.* -= 1;
+ const back_to = from[from_len.*];
+ t.cur_row = back_to.cur_row;
+ t.cur_col = back_to.cur_col;
+ t.cur_pinned = true;
+ t.vsel = back_to.vsel;
+ t.msel.active = false;
+ return back_to;
+}
diff --git a/src/body_layer.zig b/src/body_layer.zig
index 1f210220..e85e10f1 100644
--- a/src/body_layer.zig
+++ b/src/body_layer.zig
@@ -363,6 +363,9 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id
for (pane.sel, 0..) |sl, b| {
if (sl.state == .none) continue;
if (sl.state == .done and b != 0) continue;
+ // A select sweep over the tag is the tag's selection, painted there
+ // in the tag's own selection colours.
+ if (b == pardes.sel_slot and @min(sl.r0, sl.r1) < BOX_H) continue;
paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, sl, sel_btn[b], th.sel_fg);
}
// modal line selection (helix `x`): whole body rows, normal mode only
@@ -461,7 +464,7 @@ 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'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 (active and pane.focus == .body 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/colors.zig b/src/colors.zig
index 741187c6..f92f67b1 100644
--- a/src/colors.zig
+++ b/src/colors.zig
@@ -602,7 +602,7 @@ test "line numbers stay quiet without a current-line tag background" {
const moved = try p.render(p.scratch.allocator());
try std.testing.expect(!moved.at(x, y).style.bold);
try std.testing.expect(moved.at(x, y + 1).style.bold);
- p.enterTagEdit(pane, -1);
+ p.enterTag(pane);
const tag_edit = try p.render(p.scratch.allocator());
try std.testing.expect(!tag_edit.at(x, y + 1).style.bold);
}
diff --git a/src/dump.zig b/src/dump.zig
index 2fb9ce80..7112e1f0 100644
--- a/src/dump.zig
+++ b/src/dump.zig
@@ -621,7 +621,7 @@ pub fn dumpState(p: *Pardes) !void {
try pardes.panes.Image.dumpPane(p, arena, pane, tag, body, scroll, iv.path, iv.raw)
else
try pardes.panes.Terminal.dumpPane(pane, arena, tag, body, scroll);
- dp.tag_tail = if (pane.tag_init) pane.tagSlice() else null;
+ dp.tag_tail = pane.tag.own;
dump_panes[panes_len] = dp;
panes_len += 1;
}
diff --git a/src/edit.zig b/src/edit.zig
index 5249a64c..02e138e8 100644
--- a/src/edit.zig
+++ b/src/edit.zig
@@ -96,18 +96,12 @@ pub fn applyPaste(p: *Pardes, bytes: []const u8) void {
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);
- return;
- }
- if (pane.isTerminal() and pane.body.mode == .tty) return typeToTty(p, id, pane, bytes);
+ const t = if (pane.focus == .tag) &pane.tag else &pane.body;
+ if (t.what == .body and pane.isTerminal() and pane.body.mode == .tty) return typeToTty(p, id, pane, bytes);
switch (if (req) |r| r.mode else .after) {
- .after => pasteText(p, &pane.body, bytes, false, 1),
- .before => pasteText(p, &pane.body, bytes, true, 1),
- .replace => replaceWithText(p, &pane.body, bytes),
+ .after => pasteText(p, t, bytes, false, 1),
+ .before => pasteText(p, t, bytes, true, 1),
+ .replace => replaceWithText(p, t, bytes),
}
}
@@ -301,7 +295,7 @@ pub fn exitInsert(p: *Pardes, t: *Text) void {
pub fn paneCursorLines(p: *Pardes, t: *Text) ![]const []const u8 {
const arena = p.scratch.allocator();
if (t.what != .body) {
- const text = t.own orelse "";
+ const text = try flatSurface(p, t);
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().?;
@@ -346,6 +340,7 @@ pub fn pinPaneCursor(p: *Pardes, t: *Text) void {
}
pub fn flatSurface(p: *Pardes, t: *Text) ![]const u8 {
+ if (t.what == .tag) return Pardes.curTail(t.pane());
if (t.what != .body) return t.own orelse "";
const pane = t.pane();
if (pane.file) |f| return f.content;
@@ -367,6 +362,10 @@ pub fn paneWrapWidth(p: *const Pardes, t: *Text) usize {
/// 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 {
+ // A tag nobody has changed shows its default; the first edit makes that
+ // default its own text.
+ if (t.what == .tag and t.own == null)
+ t.own = p.gpa.dupe(u8, Pardes.defaultTail(t.pane())) catch return null;
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 };
@@ -1544,14 +1543,19 @@ pub fn surroundReplace(p: *Pardes, t: *Text, from: u21, to: u21) void {
pub fn pushUndo(p: *Pardes, t: *Text) void {
// 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;
+ switch (t.what) {
+ .body => {},
+ .tag => return t.remember(p.gpa, if (t.own) |own| .{ .text = own } else null),
+ // a prompt's answer is typed once and gone; it keeps no history
+ .input => 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 == .tag) return stepOwn(p, t, true);
if (t.what != .body) return;
const pane = t.pane();
if (pane.file != null) return panes.File.undo(p, pane);
@@ -1559,12 +1563,21 @@ pub fn doUndo(p: *Pardes, t: *Text) void {
}
pub fn doRedo(p: *Pardes, t: *Text) void {
+ if (t.what == .tag) return stepOwn(p, t, false);
if (t.what != .body) return;
const pane = t.pane();
if (pane.file != null) return panes.File.redo(p, pane);
panes.Terminal.redo(p, pane);
}
+/// Undo or redo an edit of a text's own characters; stepping back past its
+/// first edit gives a tag its default again.
+fn stepOwn(p: *Pardes, t: *Text, back: bool) void {
+ const to = t.step(p.gpa, if (t.own) |own| .{ .text = own } else null, back) orelse return;
+ if (t.own) |own| p.gpa.free(own);
+ t.own = if (to.ovl) |buffer| buffer.text else null;
+}
+
const PointerTextSelection = struct {
sel: Pane.Sel,
on_tag: bool,
diff --git a/src/exec.zig b/src/exec.zig
index a2dbac7c..82b3da4a 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -260,8 +260,9 @@ pub fn saveTo(p: *Pardes, id: usize, path: []const u8) void {
f.output = null; // an ordinary file pane from here on
f.watch_after_save = true;
pane.clearCwd();
- pane.tag_init = false; // re-derive the tag as a plain file
- pane.tag_tail_len = 0;
+ // re-derive the tag as a plain file's
+ if (pane.tag.own) |own| p.gpa.free(own);
+ pane.tag.own = null;
p.emit(.{ .save_file = .{ .pane = @intCast(id) } });
return;
}
@@ -351,7 +352,7 @@ pub fn replaceStillborn(p: *Pardes) void {
const out = file.output orelse return;
if (out.from != .cmd or out.from.cmd != .New) return;
if (file.revision != 0 or file.content.len != 0) return;
- if (old.tag_init and !std.mem.eql(u8, old.tagSlice(), Pardes.defaultTail(old))) return;
+ if (old.tag.own) |own| if (!std.mem.eql(u8, own, Pardes.defaultTail(old))) return;
const was_active = p.active == old_id;
layout.compute(p); // the joiner may have no rect yet; absorb snaps to rows
p.removePane(old_id, new_id) catch |err| return p.reportError(old_id, "close", err);
diff --git a/src/look.zig b/src/look.zig
index efc3f312..6f9f8c6a 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -1150,8 +1150,7 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
const found = resolveLookTarget(p, id, txt, &realbuf);
switch (found) {
.none => {
- const bmode = if (pane.tag_edit) pane.tag_mode else pane.body.mode;
- if (pane.image != null or bmode == .tty) return;
+ if (pane.image != null or pane.body.mode == .tty) return;
runSearch(p, id, trimmed, .text, .cursor) catch |err| {
p.reportError(id, "search", err);
return;
diff --git a/src/mouse.zig b/src/mouse.zig
index 5f77634b..ae047c58 100644
--- a/src/mouse.zig
+++ b/src/mouse.zig
@@ -39,7 +39,6 @@ pub const Drag = union(enum) {
/// `grab`: how far into the grip it was pressed, so the column's left
/// edge lands at `cur_x - grab`. A release still on the grip moves nothing.
column_move: struct { serial: u32, cur_x: u16, grab: u16 = 0 },
- tag: struct { id: usize },
/// chorded: a 1-2/1-3 cut/paste chord fired during this left drag —
/// the drag's own release is then inert
select: struct {
@@ -55,7 +54,7 @@ pub const Drag = union(enum) {
fn button(drag: Drag) ?Mouse.Button {
return switch (drag) {
.none => null,
- .border_v, .border_h, .move, .column_move, .tag => config.select_button,
+ .border_v, .border_h, .move, .column_move => config.select_button,
.select => |selection| selection.button,
};
}
@@ -228,7 +227,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
const previous_tag_hit = p.pointer_tag_hit;
p.pointer_tag_hit = if (m.tag_hit) |point| p.reprojectTagHit(point) else null;
if (m.tag_hit != null and p.pointer_tag_hit == null and m.kind == .press) return;
- if (m.tag_hit == null and (m.kind == .drag or m.kind == .release) and (p.drag == .tag or p.drag == .select or p.header_drag)) {
+ if (m.tag_hit == null and (m.kind == .drag or m.kind == .release) and (p.drag == .select or p.header_drag)) {
if (previous_tag_hit) |previous| {
var continued = previous;
continued.pixel_x = (@as(f32, @floatFromInt(raw_col)) + 0.5) * @as(f32, @floatFromInt(previous.metrics.body_w));
@@ -236,7 +235,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
p.pointer_tag_hit = continued;
}
}
- if ((m.kind == .drag or m.kind == .release) and (p.drag == .tag or p.drag == .select or p.header_drag)) {
+ if ((m.kind == .drag or m.kind == .release) and (p.drag == .select or p.header_drag)) {
if (previous_tag_hit) |previous| if (p.pointer_tag_hit) |*current| {
current.kind = previous.kind;
current.id = previous.id;
@@ -303,7 +302,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
(m.button == config.select_button or m.button == config.exec_button or m.button == config.look_button))
{
if (m.button == config.select_button) if (p.panes[p.active]) |pane| {
- if (pane.tag_edit) Pardes.exitTagEdit(pane);
+ pane.focus = .body;
pane.closePrompt();
};
var column: ?usize = null;
@@ -439,7 +438,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
const r = p.rects[id];
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.focus = .body;
previous.closePrompt();
};
const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y;
@@ -448,20 +447,29 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
// gutter scrollbar: a left click scrolls UP to that row
p.active = id;
pane.scrollBy(-(@as(i32, mrow) - @as(i32, body_y)));
- } 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 {
+ const on_tag = mrow >= tag_y and mrow < tag_y + BOX_H;
+ if (on_tag and mcol < r.x + TAG_TEXT_INSET) return;
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;
+ // A clicked tag is typed straight into, from where it was
+ // clicked; `:` is the door to its normal mode.
+ pane.focus = if (on_tag) .tag else .body;
+ if (on_tag) {
+ // a count typed before the click was meant for the body
+ pane.body.normal.clear();
+ pane.tag.mode = .insert;
+ pane.tag.normal.clear();
+ pane.tag.vsel.active = false;
+ pane.tag.msel.active = false;
+ pane.tag.nsel = 0;
+ pane.tag.cur_pinned = true;
+ pane.tag.cur_row = 0;
+ pane.tag.cur_col = @intCast(p.tagOffsetAt(pane, @intCast(@max(0, pointed.sel.c0))) orelse 0);
+ }
p.drag = .{ .select = .{
.id = id,
.button = config.select_button,
@@ -472,7 +480,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
config.select_button,
mcol,
mrow,
- false,
+ pointed.on_tag,
),
} };
}
@@ -617,13 +625,6 @@ pub fn dragUpdate(p: *Pardes, mcol: u16, mrow: u16, body_hit: ?Mouse.BodyHit) vo
panes.Pdf.pointerUpdate(&s.pdf, p, pane, mcol, mrow);
}
},
- .tag => |d| {
- const pane = p.panes[d.id] orelse return;
- const c = if (tagColumn(p, .pane, d.id, p.pointer_tag_hit, true)) |value| @as(i32, value) else @as(i32, mcol) - @as(i32, p.rects[d.id].x + TAG_TEXT_INSET);
- const text = p.tagText(p.scratch.allocator(), pane) catch return;
- pane.tag_col = @intCast(@min(text.len, panes.File.rawAtDisplay(text, @as(usize, @intCast(@max(0, c))) + pane.tag_scroll)));
- pane.tag_sel = pane.tag_col != pane.tag_anchor;
- },
.none => {},
}
}
@@ -816,6 +817,16 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void {
const sl = pane.sel[sel_slot];
const dragged = sl.c0 != sl.c1 or sl.r0 != sl.r1;
if (!dragged) pane.sel[sel_slot].state = .none;
+ if (sl.r0 < BOX_H) {
+ // On the tag the caret goes where the button came up. A
+ // click on the path, if it can be renamed, drafts a name.
+ const at = p.tagOffsetAt(pane, @intCast(@max(0, sl.c1)));
+ if (at) |col| {
+ pane.tag.cur_row = 0;
+ pane.tag.cur_col = @intCast(col);
+ } else if (!dragged) p.draftName(pane, @intCast(@max(0, sl.c1)));
+ return;
+ }
if (dragged) edit.capturePointerSelection(p, pane, sel_slot) catch {};
if (dragged and pane.body.mode == .tty) mirrorTtySelection(p, pane);
const body_vis = sl.r1 - @as(i32, BOX_H);
@@ -869,7 +880,7 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void {
}
const txt = operand.text;
const cmd = if (s.button == config.look_button) config.look_cmd else config.exec_cmd;
- if (clk.r0 < BOX_H and s.button == config.exec_button and pane.tag_name_len != null) {
+ if (clk.r0 < BOX_H and s.button == config.exec_button and pane.prompt == .name) {
const prefix_width = panes.File.displayWidth(p.tagPrefix(pane) catch return);
const on_name = @as(usize, @intCast(@max(0, clk.c0))) < prefix_width;
if (!p.commitTagName(pane)) return;
@@ -895,7 +906,6 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void {
});
}
},
- .tag => {}, // dragUpdate already left the tag cursor + selection set
.none => {},
}
}
@@ -913,6 +923,30 @@ fn chordCutPaste(p: *Pardes, cut: bool) void {
if (cut and pv.selection_text.len > 0) edit.setYank(p, pv.selection_text);
return;
};
+ if (@min(pane.sel[sel_slot].r0, pane.sel[sel_slot].r1) < BOX_H) {
+ // A sweep over the tag cuts from and pastes into the tag's own text;
+ // the part of it over the prefix is not the tag's to cut.
+ const t = &pane.tag;
+ if (!s.chorded) {
+ s.chorded = true;
+ const sl = pane.sel[sel_slot];
+ pane.sel[sel_slot].state = .none;
+ const from = p.tagOffsetAt(pane, @intCast(@max(0, @min(sl.c0, sl.c1)))) orelse 0;
+ const to = p.tagOffsetAt(pane, @intCast(@max(0, @max(sl.c0, sl.c1)))) orelse 0;
+ t.cur_row = 0;
+ t.cur_col = @intCast(to);
+ t.cur_pinned = true;
+ t.vsel = .{ .active = from != to, .row = 0, .col = @intCast(from), .explicit = false };
+ t.msel.active = false;
+ t.nsel = 0;
+ }
+ if (cut) {
+ if (t.vsel.active) edit.normalDelete(p, t, true);
+ } else if (t.vsel.active) {
+ edit.normalReplaceYank(p, t);
+ } else edit.pasteText(p, t, p.yank orelse return, true, 1);
+ return;
+ }
if (pane.body.mode == .tty) {
if (!s.chorded) {
s.chorded = true;
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index 7e9cf7e7..282ae473 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -15,6 +15,7 @@ const addressing = @import("addr.zig");
const ctl = @import("ctl.zig");
const pty = @import("pty.zig");
const screen = @import("screen.zig");
+const limits = @import("../memory.zig").limits;
const Pardes = pardes.Pardes;
const Pane = pardes.Pane;
@@ -384,7 +385,7 @@ pub fn write(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply
return switch (file) {
.name => writeName(p, req, id, pane),
.body => writeBody(p, req, id, pane),
- .tag => writeTag(p, req, pane),
+ .tag => writeTag(req, pane),
.ctl => ctl.writePane(p, req, pane),
.addr, .dot, .limit => writeRange(req, pane, file),
.data, .xdata => writeData(p, req, pane),
@@ -417,15 +418,15 @@ fn writeBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
return .{ .tag = req.tag, .written = @intCast(take) };
}
-fn writeTag(p: *Pardes, req: Req, pane: *Pane) Reply {
+fn writeTag(req: Req, pane: *Pane) Reply {
if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 };
- p.seedTail(pane);
- const room = pane.tag_tail.len - pane.tag_tail_len;
+ // The tag's own text grows by what is written, newlines and all: a tag
+ // is a text like any other (acme's tag file appends the same way).
+ const had = Pardes.curTail(pane).len;
+ const room = limits.max_tag_tail -| had;
if (room == 0) return Reply.fail(req.tag, E.NOSPC);
const take = wholeUtf8(req.data[0..@min(req.data.len, room)]);
- @memcpy(pane.tag_tail[pane.tag_tail_len..][0..take], req.data[0..take]);
- pane.tag_tail_len += take;
- pane.tag_init = true;
+ pane.appendTag(req.data[0..take]) catch return Reply.fail(req.tag, E.NOMEM);
return .{ .tag = req.tag, .written = @intCast(take) };
}
@@ -553,8 +554,8 @@ pub fn nameBuffer(p: *Pardes, id: usize, full: []const u8, preserve_tag: bool) !
f.output = null;
pane.clearCwd();
if (!preserve_tag) {
- pane.tag_init = false;
- pane.tag_tail_len = 0;
+ if (pane.tag.own) |own| p.gpa.free(own);
+ pane.tag.own = null;
}
f.saved_revision = f.revision -% 1;
f.watch_after_save = fs.localPath(full) != null;
@@ -606,8 +607,13 @@ pub fn truncate(p: *Pardes, pane: *Pane, file: PaneFile) tree.Status {
setDot(pane, .{});
},
.tag => {
- pane.tag_tail_len = 0;
- pane.tag_init = true;
+ const empty = p.gpa.alloc(u8, 0) catch return .err;
+ if (pane.tag.own) |own| p.gpa.free(own);
+ pane.tag.own = empty;
+ pane.tag.cur_row = 0;
+ pane.tag.cur_col = 0;
+ pane.tag.vsel.active = false;
+ pane.tag.nsel = 0;
},
.addr => pf.addr = .{},
.limit => pf.limit = null,
@@ -740,12 +746,20 @@ test "tag reads the whole tag and writes append to the editable tail" {
const before = rd(p, node, 0, 4096).bytes.len;
const w = wr(p, node, " Mine");
try testing.expectEqual(@as(u32, 5), w.reply.written);
- try testing.expect(std.mem.endsWith(u8, pane.tag_tail[0..pane.tag_tail_len], " Mine"));
+ try testing.expect(std.mem.endsWith(u8, Pardes.curTail(pane), " Mine"));
const after = rd(p, node, 0, 4096);
try testing.expectEqual(before + 5, after.bytes.len);
try testing.expect(std.mem.endsWith(u8, after.bytes, " Mine"));
- pane.tag_tail_len = pane.tag_tail.len;
+ // A tag is a text like the body: a newline written to it stays one, and
+ // the tag reads back taller by it.
+ try testing.expectEqual(@as(u32, 10), wr(p, node, "\nNext Line").reply.written);
+ try testing.expect(std.mem.endsWith(u8, Pardes.curTail(pane), " Mine\nNext Line"));
+ try testing.expect(std.mem.endsWith(u8, rd(p, node, 0, 4096).bytes, " Mine\nNext Line"));
+
+ if (pane.tag.own) |own| p.gpa.free(own);
+ pane.tag.own = try p.gpa.alloc(u8, limits.max_tag_tail);
+ @memset(pane.tag.own.?, 'x');
try testing.expectEqual(E.NOSPC, wr(p, node, "x").errno());
}
@@ -873,10 +887,10 @@ test "truncating the tag clears its editable tail" {
const pane = p.panes[0].?;
const tag = Node.of(pane.serial, .tag);
_ = wr(p, tag, " Mine");
- try testing.expect(pane.tag_tail_len > 0);
+ try testing.expect(Pardes.curTail(pane).len > 0);
const cleared = call(p, .{ .tag = 1, .op = .setattr, .node = tag, .truncate = true });
try testing.expectEqual(Status.ok, cleared.reply.status);
- try testing.expectEqual(@as(usize, 0), pane.tag_tail_len);
+ try testing.expectEqualStrings("", pane.tag.own.?);
try testing.expect(std.mem.indexOf(u8, rd(p, tag, 0, 4096).bytes, " Mine") == null);
}
diff --git a/src/normal.zig b/src/normal.zig
index a77f27a7..e4fa075d 100644
--- a/src/normal.zig
+++ b/src/normal.zig
@@ -257,7 +257,7 @@ pub fn replaySels(p: *Pardes, t: *Text, what: Replay) void {
if (p.multi_stop) {
p.multi_stop = false;
const pn = p.panes[id] orelse return;
- if (pn.serial == serial) pn.body.nsel = 0;
+ if (pn.serial == serial) t.nsel = 0;
return;
}
const text = edit.flatSurface(p, t) catch return;
@@ -285,7 +285,7 @@ pub fn handleNormal(p: *Pardes, t: *Text, key: Key) void {
fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void {
const pane = t.pane();
- const raw_selection = pane.rawPointerText(pane.sel[sel_slot]) != null;
+ const raw_selection = t.what == .body and pane.rawPointerText(pane.sel[sel_slot]) != null;
const before = .{ t.cur_row, t.cur_col, t.vsel, t.msel, t.select, t.nsel, t.mode };
defer if (raw_selection and !std.meta.eql(before, .{ t.cur_row, t.cur_col, t.vsel, t.msel, t.select, t.nsel, t.mode })) {
pane.clearPointerSelection(sel_slot);
@@ -334,7 +334,7 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void
.view_center => return t.gotoWindow(text, range, .center, go.count),
.view_bottom => return t.gotoWindow(text, range, .bottom, go.count),
},
- .view => |view| switch (view) {
+ .view => |view| if (t.what == .body) switch (view) {
.top => {
pane.scrollBy(t.cur_row - pane.scroll());
pane.ensureCursorVisible();
@@ -545,10 +545,11 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void
p.leader_on = true;
p.leader_n = 0;
},
- .command_line => {
- p.enterTagEdit(pane, -1);
- if (pane.tag_edit) t.mode = .normal;
- },
+ // `:` is the one key a tag and a body do not share: it moves the
+ // keyboard between them.
+ .command_line => if (t.what == .tag) {
+ pane.focus = .body;
+ } else p.enterTag(pane),
.pipe_selection => |how| return p.startPrompt(pane, .{ .pipe = how }),
.search => return p.startPrompt(pane, .{ .search = config.search_marker }),
.search_step => |direction| return look.lookWalk(
@@ -590,10 +591,7 @@ pub fn handlePdfNormal(p: *Pardes, pane: *Pane, key: Key) void {
p.leader_on = true;
p.leader_n = 0;
},
- .command_line => {
- p.enterTagEdit(pane, -1);
- if (pane.tag_edit) pane.body.mode = .normal;
- },
+ .command_line => p.enterTag(pane),
.search => p.startPrompt(pane, .{ .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 c26065ca..720f655f 100644
--- a/src/panes.zig
+++ b/src/panes.zig
@@ -69,9 +69,14 @@ pub const Pane = struct {
/// Del from the keyboard with open panes both above and below: which
/// one takes the rows. Nothing is typed; one key answers.
del_side,
+ /// A new name for the file, drafted in place of the path on the
+ /// tagline after a click there; Enter commits it, as a buffer name
+ /// and never a disk rename.
+ name,
};
pub const Cwd = union(enum) { none, inherited: *Pane, owned: []u8 };
+ pub const Focus = enum { body, tag };
terminal: ?*Terminal.State = null,
gpa: std.mem.Allocator,
serial: u32 = 0,
@@ -97,8 +102,9 @@ pub const Pane = struct {
pdf: PdfSlot = if (Pdf.enabled) null else {},
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.
+ /// 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
+ /// prompt never touches the tag.
input: Text = .{ .what = .input },
search_pane: ?usize = null,
search_row: ?usize = null,
@@ -107,23 +113,12 @@ pub const Pane = struct {
nsel_snap: u8 = 0,
sel_snap_pri: u8 = 0,
sel_snap_expl: bool = false,
- /// the editable tag tail: a bounded one-line command buffer. Input that
- /// does not fit is refused atomically.
- tag_tail: [limits.max_tag_tail]u8 = undefined,
- tag_tail_len: usize = 0,
- tag_init: bool = false,
- tag_edit: bool = false,
- tag_sel: bool = false,
- /// the body mode a tag edit hijacked (tags are always insert); terminals
- /// restore it on exit so clicking the tag never changes the pane's mode
- tag_mode: Text.Mode = .normal,
- tag_col: u16 = 0,
- tag_saved_col: ?u16 = null,
- tag_anchor: u16 = 0,
- /// Filename edits are staged separately from the live buffer identity.
- /// Enter commits the name; leaving tag editing discards the draft.
- tag_name: [limits.host_path_cap]u8 = undefined,
- tag_name_len: ?u16 = null,
+ /// The tag: what the user keeps after the computed prefix (the path, the
+ /// dirty marker, a PDF's page), its cursor, selections and undo. The
+ /// prefix is never stored in it, so it never moves the tag's cursor.
+ tag: Text = .{ .what = .tag },
+ /// Which of the pane's texts the keyboard types into.
+ focus: Focus = .body,
/// Display columns hidden to reveal the tag caret; tag addresses stay whole.
tag_scroll: u16 = 0,
cwd: Cwd = .none,
@@ -297,9 +292,15 @@ pub const Pane = struct {
return null;
}
- pub fn tagSlice(p: *const Pane) []const u8 {
- return p.tag_tail[0..p.tag_tail_len];
+ /// Add `text` to the end of the tag's own text, which starts out as the
+ /// default a pane of its kind shows.
+ pub fn appendTag(pane: *Pane, text: []const u8) !void {
+ const had = pane.tag.own orelse pardes.Pardes.defaultTail(pane);
+ const grown = try std.mem.concat(pane.gpa, u8, &.{ had, text });
+ if (pane.tag.own) |old| pane.gpa.free(old);
+ pane.tag.own = grown;
}
+
/// 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 {
@@ -313,33 +314,12 @@ pub const Pane = struct {
/// painter of the band reads.
pub fn promptText(p: *const Pane) ?[]const u8 {
return switch (p.prompt) {
- .none => null,
+ .none, .name => null,
.del_side => config.del_question,
else => p.input.own orelse "",
};
}
- pub fn appendTag(p: *Pane, text: []const u8) bool {
- if (text.len > p.tag_tail.len - p.tag_tail_len) return false;
- @memcpy(p.tag_tail[p.tag_tail_len..][0..text.len], text);
- p.tag_tail_len += text.len;
- return true;
- }
-
- pub fn insertTagByte(p: *Pane, at: usize, byte: u8) bool {
- if (at > p.tag_tail_len or p.tag_tail_len == p.tag_tail.len) return false;
- std.mem.copyBackwards(u8, p.tag_tail[at + 1 .. p.tag_tail_len + 1], p.tag_tail[at..p.tag_tail_len]);
- p.tag_tail[at] = byte;
- p.tag_tail_len += 1;
- return true;
- }
-
- pub fn removeTagByte(p: *Pane, at: usize) void {
- if (at >= p.tag_tail_len) return;
- std.mem.copyForwards(u8, p.tag_tail[at .. p.tag_tail_len - 1], p.tag_tail[at + 1 .. p.tag_tail_len]);
- p.tag_tail_len -= 1;
- }
-
pub fn cwdSlice(p: *const Pane) []const u8 {
return switch (p.cwd) {
.none => "",
diff --git a/src/pardes.zig b/src/pardes.zig
index a29973e7..ce7c7065 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -687,9 +687,8 @@ test "pane-tag Exec prefers Tty and argument builtins before shell fallback" {
// Argument-taking builtins use the same gesture path: a tag sweep is the
// complete command line, and Theme consumes its tail without a PTY write.
- pane.tag_tail_len = 0;
- try std.testing.expect(pane.appendTag(" Theme dark"));
- pane.tag_init = true;
+ if (pane.tag.own) |own| p.gpa.free(own);
+ pane.tag.own = try p.gpa.dupe(u8, " Theme dark");
p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .press, .col = tag_x + 1, .row = tag_y } });
p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .drag, .col = tag_x + 10, .row = tag_y } });
p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .release, .col = tag_x + 10, .row = tag_y } });
@@ -707,8 +706,8 @@ test "pane-tag Exec prefers Tty and argument builtins before shell fallback" {
// A word absent from the builtin vocabulary takes the fallback, including
// the terminating carriage return expected by the shell prompt.
- pane.tag_tail_len = 0;
- try std.testing.expect(pane.appendTag(" DefinitelyNotABuiltin"));
+ if (pane.tag.own) |own| p.gpa.free(own);
+ pane.tag.own = try p.gpa.dupe(u8, " DefinitelyNotABuiltin");
p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .press, .col = tag_x + 1, .row = tag_y } });
p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .release, .col = tag_x + 1, .row = tag_y } });
var sent: [256]u8 = undefined;
@@ -743,20 +742,20 @@ test "Collapse builtin keeps content and restores the pane from its default tag"
try std.testing.expectEqual(revision, pane.file.?.revision);
try std.testing.expectEqualStrings("keep this text\n", pane.file.?.content);
try std.testing.expect((try p.render(p.scratch.allocator())).cursor == null);
- p.enterTagEdit(pane, -1);
+ p.enterTag(pane);
const tag_cursor = (try p.render(p.scratch.allocator())).cursor orelse return error.MissingTagCursor;
try std.testing.expectEqual(p.rects[0].y, tag_cursor.y);
- Pardes.exitTagEdit(pane);
+ pane.focus = .body;
try std.testing.expect(p.executeBuiltinLine(0, "Collapse"));
p.sync();
try std.testing.expect(!pane.collapsed);
try std.testing.expectEqual(height, p.rects[0].h);
// Unedited defaults from the preceding version gain the new command.
- pane.tag_init = false;
- pane.tag_tail_len = 0;
+ if (pane.tag.own) |own| p.gpa.free(own);
+ pane.tag.own = null;
p.restoreTailAt(pane, pre_collapse_file_pane_tail, "", .file);
- try std.testing.expect(!pane.tag_init);
+ try std.testing.expect(pane.tag.own == null);
try std.testing.expectEqualStrings(file_pane_tail, Pardes.curTail(pane));
}
@@ -859,8 +858,8 @@ test "TreeContext command and custom tag survive a workspace round trip" {
try std.testing.expect(pane.file.?.tree_context);
try std.testing.expect(p.executeBuiltinLine(0, "TreeContext invalid"));
try std.testing.expect(pane.file.?.tree_context);
- try std.testing.expect(pane.appendTag(" Save Keep Del"));
- pane.tag_init = true;
+ if (pane.tag.own) |own| p.gpa.free(own);
+ pane.tag.own = try p.gpa.dupe(u8, " Save Keep Del");
try dump.dumpState(p);
const restored = try dump.initFromDump(p.gpa, .{ .tty_only = true }, p.dump_out.?);
defer restored.deinit();
@@ -874,22 +873,23 @@ test "Togglettymode builtin and keyboard chord share mode transitions including
defer p.deinit();
const pane = p.panes[0].?;
try std.testing.expect(std.mem.indexOf(u8, Pardes.curTail(pane), " Mode ") != null);
- p.seedTail(pane);
- try std.testing.expect(pane.appendTag(" Togglettymode"));
+ try pane.appendTag(" Togglettymode");
try std.testing.expectEqual(Text.Mode.tty, pane.body.mode);
try std.testing.expect(p.executeBuiltinLine(0, "Togglettymode"));
try std.testing.expectEqual(Text.Mode.normal, pane.body.mode);
p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } });
try std.testing.expectEqual(Text.Mode.tty, pane.body.mode);
- p.enterTagEdit(pane, -1);
- try std.testing.expectEqual(Text.Mode.insert, pane.body.mode);
+ // The tag has its own mode: the body keeps tty while the tag is typed into.
+ p.enterTag(pane);
+ try std.testing.expect(pane.focus == .tag);
+ try std.testing.expectEqual(Text.Mode.tty, pane.body.mode);
try std.testing.expect(p.executeBuiltinLine(0, "Togglettymode"));
try std.testing.expectEqual(Text.Mode.normal, pane.body.mode);
- try std.testing.expect(!pane.tag_edit);
- p.enterTagEdit(pane, -1);
+ try std.testing.expect(pane.focus == .body);
+ p.enterTag(pane);
p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } });
try std.testing.expectEqual(Text.Mode.tty, pane.body.mode);
- try std.testing.expect(!pane.tag_edit);
+ try std.testing.expect(pane.focus == .body);
p.acknowledgePanelPresentation(&.{});
const tag = try p.tagText(p.scratch.allocator(), pane);
@@ -974,10 +974,8 @@ test "compact tag commands stay beside their own prefix across layout changes" {
try std.testing.expectEqualStrings(above_tag, try p.tagText(p.scratch.allocator(), above));
p.update(.{ .resize = .{ .cols = 100, .rows = 30 } });
while (p.nextEffect()) |_| {}
-
- p.seedTail(below);
- try std.testing.expectEqualStrings(tail, below.tagSlice());
- try std.testing.expect(below.appendTag(" lots and lots of typing out here"));
+ try std.testing.expectEqualStrings(tail, Pardes.curTail(below));
+ try below.appendTag(" lots and lots of typing out here");
try std.testing.expectEqual(above_tag.len, (try p.tagText(p.scratch.allocator(), above)).len);
p.update(.{ .resize = .{ .cols = 100, .rows = 2 } });
@@ -997,14 +995,14 @@ test "legacy default tag tails upgrade while custom tails remain owned" {
legacy_pane_tail,
});
p.restoreTail(terminal, terminal_old);
- try std.testing.expect(!terminal.tag_init);
+ try std.testing.expect(terminal.tag.own == null);
try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal));
const terminal_previous = try std.fmt.allocPrint(p.scratch.allocator(), "{s} New Del", .{
try p.tagPrefix(terminal),
});
p.restoreTail(terminal, terminal_previous);
- try std.testing.expect(!terminal.tag_init);
+ try std.testing.expect(terminal.tag.own == null);
try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal));
const terminal_prefilter = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{
@@ -1012,60 +1010,60 @@ test "legacy default tag tails upgrade while custom tails remain owned" {
prev_terminal_pane_tail,
});
p.restoreTail(terminal, terminal_prefilter);
- try std.testing.expect(!terminal.tag_init);
+ try std.testing.expect(terminal.tag.own == null);
try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal));
for ([_][]const u8{ pre_mode_terminal_pane_tail, pre_tty_first_terminal_pane_tail, pre_collapse_terminal_pane_tail }) |old_tail| {
const old = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ try p.tagPrefix(terminal), old_tail });
p.restoreTail(terminal, old);
- try std.testing.expect(!terminal.tag_init);
+ try std.testing.expect(terminal.tag.own == null);
try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal));
}
const custom_mode = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s} Keep", .{ try p.tagPrefix(terminal), pre_mode_terminal_pane_tail });
p.restoreTail(terminal, custom_mode);
- try std.testing.expect(terminal.tag_init);
+ try std.testing.expect(terminal.tag.own != null);
try std.testing.expectEqualStrings(pre_mode_terminal_pane_tail ++ " Keep", Pardes.curTail(terminal));
- terminal.tag_tail_len = 0;
- terminal.tag_init = false;
+ if (terminal.tag.own) |own| p.gpa.free(own);
+ terminal.tag.own = null;
const terminal_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Keep Del", .{
try p.tagPrefix(terminal),
});
p.restoreTail(terminal, terminal_custom);
- try std.testing.expect(terminal.tag_init);
+ try std.testing.expect(terminal.tag.own != null);
try std.testing.expectEqualStrings(" Keep Del", Pardes.curTail(terminal));
- terminal.tag_tail_len = 0;
- terminal.tag_init = false;
+ if (terminal.tag.own) |own| p.gpa.free(own);
+ terminal.tag.own = null;
p.restoreDumpTail(terminal, .{
.kind = .terminal,
.tag = "TTY /historical/cwd Keep Del",
.body = "",
.terminal = .{ .cwd = "/historical/cwd" },
});
- try std.testing.expect(terminal.tag_init);
+ try std.testing.expect(terminal.tag.own != null);
try std.testing.expectEqualStrings(" Keep Del", Pardes.curTail(terminal));
- terminal.tag_tail_len = 0;
- terminal.tag_init = false;
+ if (terminal.tag.own) |own| p.gpa.free(own);
+ terminal.tag.own = null;
p.restoreDumpTail(terminal, .{
.kind = .terminal,
.tag = "TTY /historical/cwd New Del",
.body = "",
.terminal = .{ .cwd = "/historical/cwd" },
});
- try std.testing.expect(!terminal.tag_init);
+ try std.testing.expect(terminal.tag.own == null);
try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal));
// This happens to be the historical FILE default, but on a terminal it is
// user-owned text and must not be swallowed by migration.
- terminal.tag_tail_len = 0;
- terminal.tag_init = false;
+ if (terminal.tag.own) |own| p.gpa.free(own);
+ terminal.tag.own = null;
const terminal_fileish = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Save Del", .{
try p.tagPrefix(terminal),
});
p.restoreTail(terminal, terminal_fileish);
- try std.testing.expect(terminal.tag_init);
+ try std.testing.expect(terminal.tag.own != null);
try std.testing.expectEqualStrings(" Save Del", Pardes.curTail(terminal));
// A savable file has a distinct old default. Save remains first after the
@@ -1076,25 +1074,25 @@ test "legacy default tag tails upgrade while custom tails remain owned" {
legacy_file_pane_tail,
});
p.restoreTail(file, file_old);
- try std.testing.expect(!file.tag_init);
+ try std.testing.expect(file.tag.own == null);
try std.testing.expectEqualStrings(" Save Tty Collapse Del", Pardes.curTail(file));
const file_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Save Mine Del", .{
try p.tagPrefix(file),
});
p.restoreTail(file, file_custom);
- try std.testing.expect(file.tag_init);
+ try std.testing.expect(file.tag.own != null);
try std.testing.expectEqualStrings(" Save Mine Del", Pardes.curTail(file));
// Conversely the generic pane's current default is a custom edit on a
// savable file. Legacy recognition is scoped to the source pane class.
- file.tag_tail_len = 0;
- file.tag_init = false;
+ if (file.tag.own) |own| p.gpa.free(own);
+ file.tag.own = null;
const file_generic = try std.fmt.allocPrint(p.scratch.allocator(), "{s} New Del", .{
try p.tagPrefix(file),
});
p.restoreTail(file, file_generic);
- try std.testing.expect(file.tag_init);
+ try std.testing.expect(file.tag.own != null);
try std.testing.expectEqualStrings(" New Del", Pardes.curTail(file));
const image_doc = try panes.Image.create(p, 1, "/tmp/legacy image.ppm", &.{});
@@ -1105,8 +1103,8 @@ test "legacy default tag tails upgrade while custom tails remain owned" {
.body = "",
.image = .{ .path = "/tmp/legacy image.ppm" },
});
- try std.testing.expect(image_doc.tag_init);
- try std.testing.expectEqualStrings(" Keep Del", image_doc.tagSlice());
+ try std.testing.expect(image_doc.tag.own != null);
+ try std.testing.expectEqualStrings(" Keep Del", Pardes.curTail(image_doc));
}
test "Joincol folds the active column into its right neighbor, keeping its panes" {
@@ -1247,11 +1245,11 @@ test "pane defaults keep creation of scratch panes in column tags" {
try std.testing.expectEqualStrings("New Tty Find Grep Joincol Delcol", p.columnTag(0));
const terminal = p.panes[0].?;
p.restoreTailAt(terminal, pre_column_terminal_pane_tail, "", .terminal);
- try std.testing.expect(!terminal.tag_init);
+ try std.testing.expect(terminal.tag.own == null);
try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal));
const file = try p.setTestFile("unchanged\n");
p.restoreTailAt(file, pre_column_file_pane_tail, "", .file);
- try std.testing.expect(!file.tag_init);
+ try std.testing.expect(file.tag.own == null);
try std.testing.expectEqualStrings(file_pane_tail, Pardes.curTail(file));
}
@@ -1398,8 +1396,8 @@ test "image dump restores source bytes renderer choices and exact custom tail" {
pane.image.?.glyph_art = true;
pane.image.?.pmode = .terminal;
pane.image.?.ascii = false;
- try std.testing.expect(pane.appendTag(" Keep Del"));
- pane.tag_init = true;
+ if (pane.tag.own) |own| p.gpa.free(own);
+ pane.tag.own = try p.gpa.dupe(u8, " Keep Del");
try dump.dumpState(p);
var first_dump = try dump.readZon(gpa, p.dump_out.?, "image-first-dump");
@@ -1416,8 +1414,8 @@ test "image dump restores source bytes renderer choices and exact custom tail" {
try std.testing.expect(restored_pane.image.?.glyph_art);
try std.testing.expectEqual(image.PaletteMode.terminal, restored_pane.image.?.pmode);
try std.testing.expect(!restored_pane.image.?.ascii);
- try std.testing.expect(restored_pane.tag_init);
- try std.testing.expectEqualStrings(" Keep Del", restored_pane.tagSlice());
+ try std.testing.expect(restored_pane.tag.own != null);
+ try std.testing.expectEqualStrings(" Keep Del", Pardes.curTail(restored_pane));
try std.testing.expectEqualSlices(u8, source, restored_pane.image.?.raw);
try dump.dumpState(restored);
@@ -2095,20 +2093,20 @@ test "Save reaches every tagline with text behind it and no other" {
pane_tail,
});
p.restoreTail(out, dumped);
- try std.testing.expect(!out.tag_init);
+ try std.testing.expect(out.tag.own == null);
try std.testing.expectEqualStrings(locations_pane_tail, Pardes.curTail(out));
// A scratch is an output buffer that has worn the FILE tails all along,
// so its own older default upgrades from that family too.
out.file.?.output = .{ .from = .{ .cmd = std.meta.stringToEnum(Builtin, "New").? } };
- out.tag_tail_len = 0;
- out.tag_init = false;
+ if (out.tag.own) |own| p.gpa.free(own);
+ out.tag.own = null;
const scratch_dumped = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{
try p.tagPrefix(out),
prev_file_pane_tail,
});
p.restoreTail(out, scratch_dumped);
- try std.testing.expect(!out.tag_init);
+ try std.testing.expect(out.tag.own == null);
try std.testing.expectEqualStrings(" Save Tty Collapse Del", Pardes.curTail(out));
// An image's bytes on disk already are what they are: nothing of the
@@ -2629,24 +2627,25 @@ test "Unicode display cells map back to body and tag byte cursors" {
p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = text_x + 2, .row = body_y } });
try std.testing.expectEqual(@as(i32, 1), pane.body.cur_col);
- // A click in the wide path glyph likewise becomes a byte cursor at the
+ // A click in the wide path glyph drafts a name with its caret at the
// grapheme start; arrow motion then advances by the full UTF-8 cluster.
- p.enterTagEdit(pane, 1);
- try std.testing.expectEqual(@as(u16, 0), pane.tag_col);
- p.tagInsertKey(pane, .{ .cp = Key.right });
- try std.testing.expectEqual(@as(u16, 3), pane.tag_col);
- p.tagInsertKey(pane, .{ .cp = Key.right });
- try std.testing.expectEqual(@as(u16, 6), pane.tag_col);
+ p.draftName(pane, 1);
+ try std.testing.expectEqual(@as(i32, 0), pane.input.cur_col);
+ edit.insertKey(p, &pane.input, .{ .cp = Key.right });
+ try std.testing.expectEqual(@as(i32, 3), pane.input.cur_col);
+ edit.insertKey(p, &pane.input, .{ .cp = Key.right });
+ try std.testing.expectEqual(@as(i32, 6), pane.input.cur_col);
+ pane.closePrompt();
- const before = try gpa.dupe(u8, pane.tagSlice());
+ const before = try gpa.dupe(u8, Pardes.curTail(pane));
defer gpa.free(before);
- p.enterTagEdit(pane, -1);
- const insertion = pane.tag_col;
- p.tagInsertKey(pane, .{ .cp = 'λ', .text = "λ" });
- try std.testing.expectEqual(insertion + 2, pane.tag_col);
- p.tagInsertKey(pane, .{ .cp = Key.backspace });
- try std.testing.expectEqual(insertion, pane.tag_col);
- try std.testing.expectEqualStrings(before, pane.tagSlice());
+ p.enterTag(pane);
+ const insertion = pane.tag.cur_col;
+ edit.insertKey(p, &pane.tag, .{ .cp = 'λ', .text = "λ" });
+ try std.testing.expectEqual(insertion + 2, pane.tag.cur_col);
+ edit.insertKey(p, &pane.tag, .{ .cp = Key.backspace });
+ try std.testing.expectEqual(insertion, pane.tag.cur_col);
+ try std.testing.expectEqualStrings(before, Pardes.curTail(pane));
}
test "tabbed file aligns syntax cursor and mouse at the nearest insertion gap" {
@@ -4110,6 +4109,9 @@ pub const Pardes = struct {
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);
+ if (pane.tag.own) |text| p.gpa.free(text);
+ for (pane.tag.ed_undo[0..pane.tag.ed_undo_len]) |sn| if (sn.ovl) |o| p.gpa.free(o.text);
+ for (pane.tag.ed_redo[0..pane.tag.ed_redo_len]) |sn| if (sn.ovl) |o| p.gpa.free(o.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);
@@ -4762,7 +4764,7 @@ pub const Pardes = struct {
);
if (pane.image) |*state| return panes.Image.tagPrefix(arena, state);
if (pane.file) |f| {
- const name = if (pane.tag_name_len) |len| pane.tag_name[0..len] else f.path;
+ const name = if (pane.prompt == .name) pane.input.own orelse f.path else f.path;
if (panes.Output.fileTraits(f.output).saves and f.revision != f.saved_revision)
return std.fmt.allocPrint(arena, "{s}{s}", .{ name, dirty_marker });
return arena.dupe(u8, name);
@@ -4770,10 +4772,10 @@ pub const Pardes = struct {
return arena.dupe(u8, pane.cwdSlice());
}
- /// the editable tail: the user's edited buffer once touched, else defaults
- fn curTail(pane: *Pane) []const u8 {
- if (pane.tag_init) return pane.tagSlice();
- return defaultTail(pane);
+ /// The tag's own text: what the user made of it, or until then the
+ /// default a pane of its kind starts with.
+ pub fn curTail(pane: *Pane) []const u8 {
+ return pane.tag.own orelse defaultTail(pane);
}
pub fn defaultTail(pane: *const Pane) []const u8 {
@@ -4793,6 +4795,8 @@ pub const Pardes = struct {
return null;
}
+ /// The tag as it is shown, read and pointed at: its computed prefix and
+ /// its own text after it.
pub fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 {
const prefix = try p.tagPrefix(pane);
const tail = curTail(pane);
@@ -4814,7 +4818,7 @@ pub const Pardes = struct {
}
var start: usize = 0;
const name = if (pane.file) |file|
- if (pane.tag_name_len) |len| pane.tag_name[0..len] else file.path
+ if (pane.prompt == .name) pane.input.own orelse file.path else file.path
else if (pane.image) |state| blk: {
// Image tags put renderer controls before the path.
start = prefix_len -| state.path.len;
@@ -4827,88 +4831,76 @@ pub const Pardes = struct {
return .{ .lo = start + basename, .hi = start + name.len };
}
- pub fn seedTail(_: *Pardes, pane: *Pane) void {
- if (pane.tag_init) return;
+ /// Give the keyboard to the tag in normal mode (`:`), its cursor where
+ /// the tag last had it. The first time there is nothing to remember, and
+ /// it starts on Save, so that `:` and the execute key write the file.
+ pub fn enterTag(p: *Pardes, pane: *Pane) void {
+ const t = &pane.tag;
const tail = curTail(pane);
- if (tail.len > pane.tag_tail.len) return;
- @memcpy(pane.tag_tail[0..tail.len], tail);
- pane.tag_tail_len = tail.len;
- pane.tag_init = true;
- }
-
- pub fn enterTagEdit(p: *Pardes, pane: *Pane, col: i32) void {
- 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;
- if (!pane.tag_edit) pane.tag_mode = pane.body.mode;
- pane.tag_edit = true;
- pane.tag_sel = false;
- // a one-line tag has no use for normal mode: always insert
- pane.body.mode = .insert;
- pane.body.normal.clear();
- const end = edit0 + @as(i32, @intCast(pane.tag_tail_len));
- const tail = pane.tagSlice();
- const lead: i32 = @intCast(tail.len - std.mem.trimStart(u8, tail, " ").len);
- if (col < 0) {
- const text = p.tagText(p.scratch.allocator(), pane) catch return;
- if (pane.tag_saved_col) |saved| {
- pane.tag_col = @intCast(modal.graphemeStart(text, @min(saved, text.len)));
- } else {
- pane.tag_col = @intCast(@min(edit0 + lead, end));
- }
- if (pane.tag_saved_col == null) if (p.paneIdOf(pane)) |id| {
+ if (!t.cur_pinned) {
+ var words = std.mem.tokenizeAny(u8, tail, " \t\n");
+ const at = while (words.next()) |word| {
+ if (std.mem.eql(u8, word, "Save")) break @intFromPtr(word.ptr) - @intFromPtr(tail.ptr);
+ } else tail.len - std.mem.trimStart(u8, tail, " ").len;
+ const cursor = modal.positionAt(tail, @min(at, tail.len));
+ t.cur_row = @intCast(cursor.row);
+ t.cur_col = @intCast(cursor.col);
+ t.cur_pinned = true;
+ // Reveal the suffix together with its commands, not merely the
+ // first command letter at the viewport's far right edge.
+ if (p.paneIdOf(pane)) |id| {
+ const tag = p.tagText(p.scratch.allocator(), pane) catch return;
+ const line = tag[0 .. std.mem.indexOfScalar(u8, tag, '\n') orelse tag.len];
const width = p.tagCapacity(p.rects[id].w -| TAG_TEXT_INSET);
- // Reveal the suffix together with its commands, not merely the
- // first command letter at the viewport's far right edge.
- pane.tag_scroll = @intCast(panes.File.displayWidth(text) + 1 -| width);
- };
- } else {
- const text = p.tagText(p.scratch.allocator(), pane) catch return;
- pane.tag_col = @intCast(@min(text.len, panes.File.rawAtDisplay(text, @as(usize, @intCast(col)) + pane.tag_scroll)));
+ pane.tag_scroll = @intCast(panes.File.displayWidth(line) + 1 -| width);
+ }
}
+ // The text may have changed since (a 9P write, a rename), so the
+ // remembered cursor is pulled back inside it. It is the cursor that
+ // is remembered, not a selection.
+ const rows = modal.cursorLineCount(tail);
+ t.cur_row = @intCast(@min(@as(usize, @intCast(@max(0, t.cur_row))), rows - 1));
+ const line = modal.lineSlice(tail, @intCast(t.cur_row));
+ t.cur_col = @intCast(modal.graphemeStart(line, @min(line.len, @as(usize, @intCast(@max(0, t.cur_col))))));
+ t.vsel.active = false;
+ t.msel.active = false;
+ t.nsel = 0;
+ t.select = false;
+ t.append_at = null;
+ t.sticky_col = -1;
+ t.mode = .normal;
+ t.normal.clear();
+ pane.focus = .tag;
}
- pub fn exitTagEdit(pane: *Pane) void {
- 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;
- pane.tag_edit = false;
- pane.tag_sel = false;
- pane.tag_name_len = null;
- pane.tag_scroll = 0;
- }
-
- pub fn tagSelBounds(pane: *Pane) struct { lo: usize, hi: usize } {
- const a: usize = pane.tag_anchor;
- const c: usize = pane.tag_col;
- return .{ .lo = @min(a, c), .hi = @max(a, c) };
- }
-
- fn tagChordText(p: *Pardes, pane: *Pane) ?[]const u8 {
- const text = p.tagText(p.scratch.allocator(), pane) catch return null;
- if (pane.tag_sel) {
- const b = tagSelBounds(pane);
- const hi = modal.nextGrapheme(text, b.hi);
- return if (hi > b.lo) text[b.lo..hi] else null;
- }
- const b = config.wordBounds(text, @min(@as(usize, pane.tag_col), text.len));
- return if (b.hi > b.lo) text[b.lo..b.hi] else null;
+ /// Where display column `col` of the tagline (tag scroll included) lands
+ /// in the tag's own text, or null when it is on the prefix.
+ pub fn tagOffsetAt(p: *Pardes, pane: *Pane, col: usize) ?usize {
+ const prefix = p.tagPrefix(pane) catch return null;
+ const width = panes.File.displayWidth(prefix);
+ if (col < width) return null;
+ const tail = curTail(pane);
+ const line = tail[0 .. std.mem.indexOfScalar(u8, tail, '\n') orelse tail.len];
+ return modal.graphemeStart(line, panes.File.rawAtDisplay(line, col - width));
}
- fn tagNameEnd(pane: *const Pane) ?usize {
- const f = pane.file orelse return null;
- if (!panes.Output.fileTraits(f.output).saves or f.path.len > pane.tag_name.len) return null;
- return if (pane.tag_name_len) |len| len else f.path.len;
+ /// Start a name draft for a file that can take one, its caret at display
+ /// column `col` of the path. Nothing changes until it is typed into.
+ pub fn draftName(p: *Pardes, pane: *Pane, col: usize) void {
+ const f = pane.file orelse return;
+ if (!panes.Output.fileTraits(f.output).saves) return;
+ const name = p.gpa.dupe(u8, f.path) catch return;
+ pane.closePrompt();
+ pane.prompt = .name;
+ pane.input = .{ .what = .input, .own = name, .mode = .insert, .cur_pinned = true, .cur_col = @intCast(@min(name.len, panes.File.rawAtDisplay(name, col))) };
}
- /// Commit a staged buffer name, never a disk rename or write. Keep the
+ /// Commit a drafted buffer name, never a disk rename or write. Keep the
/// draft visible on failure so the user can correct it or cancel with Esc.
pub fn commitTagName(p: *Pardes, pane: *Pane) bool {
- const len = pane.tag_name_len orelse return true;
+ if (pane.prompt != .name) return true;
const id = p.paneIdOf(pane) orelse return false;
- const name = pane.tag_name[0..len];
+ const name = pane.input.own orelse "";
if (name.len == 0 or name[name.len - 1] == '/' or !std.unicode.utf8ValidateSlice(name)) {
p.reportError(id, "name", error.InvalidName);
return false;
@@ -4917,8 +4909,6 @@ pub const Pardes = struct {
p.reportError(id, "name", error.InvalidName);
return false;
};
- const old_prefix = (p.tagPrefix(pane) catch return false).len;
- const tail_col = @as(usize, pane.tag_col) -| old_prefix;
const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ paneDir(pane), name }) catch |err| {
p.reportError(id, "name", err);
return false;
@@ -4927,181 +4917,10 @@ pub const Pardes = struct {
p.reportError(id, "name", err);
return false;
};
- pane.tag_name_len = null;
- const prefix = (p.tagPrefix(pane) catch return false).len;
- pane.tag_col = @intCast(prefix + @min(tail_col, pane.tag_tail_len));
- pane.tag_sel = false;
- return true;
- }
-
- /// Replace one range in the rendered tag. The file name and command tail
- /// retain separate identities even when a selection crosses their boundary.
- pub fn replaceTagText(p: *Pardes, pane: *Pane, start: usize, finish: usize, input: []const u8) bool {
- const prefix_len = (p.tagPrefix(pane) catch return false).len;
- const end = prefix_len + pane.tag_tail_len;
- const lo = @min(start, end);
- const hi = @max(lo, @min(finish, end));
- const name_end = tagNameEnd(pane);
- const editing_name = if (name_end) |n| lo <= n else false;
- if (!editing_name and lo < prefix_len) return false;
- var bytes: [limits.max_tag_tail]u8 = undefined;
- var count: usize = 0;
- for (input) |byte| {
- if (byte == '\n' or byte == '\r') continue;
- if (count == bytes.len) return false;
- bytes[count] = byte;
- count += 1;
- }
- const tail_lo = lo -| prefix_len;
- const tail_hi = hi -| prefix_len;
- const tail_insert = if (editing_name) 0 else count;
- const tail_len = pane.tag_tail_len - (tail_hi - tail_lo) + tail_insert;
- if (tail_len > pane.tag_tail.len) return false;
- if (editing_name) {
- const n = name_end.?;
- const name_hi = @min(hi, n);
- const name_len = n - (name_hi - lo) + count;
- if (name_len > pane.tag_name.len) return false;
- if (pane.tag_name_len == null) @memcpy(pane.tag_name[0..n], pane.file.?.path);
- if (count > name_hi - lo)
- std.mem.copyBackwards(u8, pane.tag_name[lo + count .. name_len], pane.tag_name[name_hi..n])
- else
- std.mem.copyForwards(u8, pane.tag_name[lo + count .. name_len], pane.tag_name[name_hi..n]);
- @memcpy(pane.tag_name[lo..][0..count], bytes[0..count]);
- pane.tag_name_len = @intCast(name_len);
- }
- if (tail_insert > tail_hi - tail_lo)
- std.mem.copyBackwards(u8, pane.tag_tail[tail_lo + tail_insert .. tail_len], pane.tag_tail[tail_hi..pane.tag_tail_len])
- else
- std.mem.copyForwards(u8, pane.tag_tail[tail_lo + tail_insert .. tail_len], pane.tag_tail[tail_hi..pane.tag_tail_len]);
- if (!editing_name) @memcpy(pane.tag_tail[tail_lo..][0..count], bytes[0..count]);
- pane.tag_tail_len = tail_len;
- pane.tag_col = @intCast(lo + count);
- pane.tag_sel = false;
+ pane.closePrompt();
return true;
}
- fn tagInsertKey(p: *Pardes, pane: *Pane, key: Key) void {
- if (hit(key, config.escape)) {
- exitTagEdit(pane); // the tag is ALWAYS insert; Esc leaves it
- return;
- }
- // the acme chords on the selection or the word at the cursor
- if (hit(key, config.look_key) or hit(key, config.exec_key)) {
- if (pane.tag_name_len != null) {
- const in_name = pane.tag_col < (p.tagPrefix(pane) catch return).len;
- if (!p.commitTagName(pane)) return;
- if (hit(key, config.look_key) or in_name) {
- exitTagEdit(pane);
- return;
- }
- }
- if (p.tagChordText(pane)) |txt| {
- pane.tag_sel = false;
- exec.runBuiltin(p, if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd, p.active, "", txt);
- }
- return;
- }
- const text = p.tagText(p.scratch.allocator(), pane) catch return;
- const end = text.len;
- if (key.text.len > 0) {
- const range: @TypeOf(tagSelBounds(pane)) = if (pane.tag_sel) 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, key.text);
- return;
- }
- switch (key.cp) {
- Key.backspace, Key.delete => {
- if (pane.tag_sel) {
- const range = tagSelBounds(pane);
- _ = p.replaceTagText(pane, range.lo, modal.nextGrapheme(text, range.hi), "");
- } else if (key.cp == Key.backspace and pane.tag_col > 0) {
- _ = p.replaceTagText(pane, modal.prevGrapheme(text, pane.tag_col), pane.tag_col, "");
- } else if (key.cp == Key.delete and pane.tag_col < end) {
- _ = p.replaceTagText(pane, pane.tag_col, modal.nextGrapheme(text, pane.tag_col), "");
- }
- },
- Key.left => if (pane.tag_col > 0) {
- pane.tag_col = @intCast(modal.prevGrapheme(text, pane.tag_col));
- },
- Key.right => if (pane.tag_col < end) {
- pane.tag_col = @intCast(modal.nextGrapheme(text, pane.tag_col));
- },
- Key.home => pane.tag_col = 0,
- Key.end => pane.tag_col = @intCast(end),
- else => {},
- }
- }
-
- /// write a helix range back onto the tag cursor + selection: the rendered
- /// tag's one-line mirror of setPaneRange.
- fn setTagRange(pane: *Pane, text: []const u8, r: modal.Selection) void {
- const lo = @min(r.anchor, r.head);
- const hi = @max(r.anchor, r.head);
- pane.tag_col = @intCast(modal.selectionCursor(text, r));
- pane.tag_sel = modal.nextGrapheme(text, lo) < hi; // one grapheme IS the block cursor
- if (pane.tag_sel) pane.tag_anchor = @intCast(if (r.head > r.anchor) lo else modal.prevGrapheme(text, hi));
- }
-
- fn tagNormalKey(p: *Pardes, pane: *Pane, key: Key) void {
- const text = p.tagText(p.scratch.allocator(), pane) catch return;
- const cur: usize = @min(@as(usize, pane.tag_col), text.len);
- // Esc abandons the command line: back to the body, tail kept as text
- if (hit(key, config.escape) or hit(key, &.{.{ .cp = 'j' }})) return exitTagEdit(pane);
- if (hit(key, config.look_key) or hit(key, config.exec_key)) {
- const cmd = if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd;
- const txt = p.tagChordText(pane) orelse return;
- const id = p.active;
- const body_mode = pane.tag_mode;
- exitTagEdit(pane); // leave the tag FIRST: Del frees the pane
- pane.body.mode = body_mode;
- exec.runBuiltin(p, cmd, id, "", txt);
- return;
- }
- if (hit(key, config.tag_yank)) {
- if (p.tagChordText(pane)) |txt| edit.setClipboard(p, txt);
- return;
- }
- if (hit(key, config.insert) or hit(key, config.append) or
- hit(key, config.insert_line_start) or hit(key, config.insert_line_end))
- {
- const edit0: usize = if (tagNameEnd(pane) != null) 0 else (p.tagPrefix(pane) catch return).len;
- pane.tag_col = @intCast(@max(edit0, if (hit(key, config.append))
- modal.nextGrapheme(text, cur)
- else if (hit(key, config.insert_line_start))
- 0
- else if (hit(key, config.insert_line_end))
- text.len
- else
- cur));
- pane.tag_sel = false;
- pane.body.mode = .insert;
- return;
- }
- const dir: ?Builtin = for (config.window_keys) |wk| {
- var letter = wk.letter;
- letter.cp = std.ascii.toUpper(@intCast(letter.cp));
- if (hit(key, &.{letter})) break wk.cmd;
- } else null;
- if (dir) |d| {
- const from = p.active;
- exec.runBuiltin(p, d, from, "", null);
- if (p.active == from) {
- if (d == .Up) {
- exitTagEdit(pane);
- p.enterHeader(if (p.columnBarHeight() > 0) (layout.findPane(p, from) orelse return).col else null);
- }
- return;
- }
- const dest = p.panes[p.active] orelse return;
- exitTagEdit(pane); // the pane we left is fully out of its tag...
- p.enterTagEdit(dest, -1);
- if (dest.tag_edit) dest.body.mode = .normal;
- return;
- }
- if (lineMotion(text, cur, key)) |r| setTagRange(pane, text, r);
- }
-
fn lineMotion(text: []const u8, cur: usize, key: Key) ?modal.Selection {
const target: ?usize = if (hit(key, config.line_move_left))
modal.prevGrapheme(text, cur)
@@ -5243,8 +5062,7 @@ pub const Pardes = struct {
const pane = p.panes[d].?;
p.exitHeader();
p.active = d;
- p.enterTagEdit(pane, -1);
- if (pane.tag_edit) pane.body.mode = .normal;
+ p.enterTag(pane);
return;
}
if (hit(key, config.topbar_up)) {
@@ -5268,12 +5086,11 @@ pub const Pardes = struct {
pub fn cycleMode(p: *Pardes, id: usize) void {
const pane = p.panes[id] orelse return;
if (pane.file == null and !pane.isTerminal()) return;
- const previous = if (pane.tag_edit) pane.tag_mode else pane.body.mode;
+ const previous = pane.body.mode;
p.active = id;
p.exitHeader();
- if (pane.tag_edit) exitTagEdit(pane);
+ pane.focus = .body;
pane.closePrompt();
- pane.body.mode = previous;
switch (previous) {
.tty => {
pane.body.mode = .normal;
@@ -5293,7 +5110,7 @@ pub const Pardes = struct {
if (!pane.isTerminal()) return;
p.active = id;
p.exitHeader();
- if (pane.tag_edit) exitTagEdit(pane);
+ pane.focus = .body;
pane.closePrompt();
if (pane.body.mode == .tty) {
pane.body.mode = .normal;
@@ -5314,7 +5131,7 @@ pub const Pardes = struct {
}
if (p.topbar_col != null) return p.topbarKey(key);
const pane = p.panes[p.active] orelse return;
- if (pane.isTerminal() and pane.body.mode == .tty and !pane.tag_edit) {
+ if (pane.isTerminal() and pane.body.mode == .tty and pane.focus == .body and pane.prompt == .none) {
p.leader_on = false;
p.ctrl_w_pending = false;
if (key.ctrl and !key.alt and key.cp == p.opts.tty_toggle)
@@ -5352,7 +5169,8 @@ 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 and pane.prompt == .none) {
+ const t = if (pane.focus == .tag) &pane.tag else &pane.body;
+ if (hit(key, config.window_prefix) and t.mode != .insert and pane.prompt == .none) {
p.ctrl_w_pending = true;
return;
}
@@ -5385,6 +5203,21 @@ pub const Pardes = struct {
if (pane.isTerminal() and tty_toggle) {
return p.toggleTty(p.active);
}
+ if (pane.prompt == .name) {
+ // Enter or the execute key commits the draft and the keyboard
+ // goes back to the body; Esc drops it.
+ if (hit(key, config.look_key) or hit(key, config.exec_key)) {
+ if (p.commitTagName(pane)) pane.focus = .body;
+ return;
+ }
+ if (hit(key, config.escape)) {
+ pane.closePrompt();
+ pane.focus = .body;
+ return;
+ }
+ edit.handleInsert(p, &pane.input, key);
+ return;
+ }
if (pane.prompt != .none) {
if (hit(key, config.search_submit) or hit(key, config.escape)) {
const submit = hit(key, config.search_submit);
@@ -5395,7 +5228,7 @@ pub const Pardes = struct {
look.submitSearch(p, p.active)
else if (normal.selRegexArmed(pane)) |_|
normal.applySelRegex(p, pane, "", false),
- .none, .del_side => unreachable,
+ .none, .del_side, .name => unreachable,
}
pane.closePrompt();
pane.body.mode = .normal;
@@ -5406,12 +5239,27 @@ pub const Pardes = struct {
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);
- return;
- }
- if (pane.body.mode == .normal and (hit(key, config.look_key) or hit(key, config.exec_key))) {
+ if (t.mode == .normal and (hit(key, config.look_key) or hit(key, config.exec_key))) {
const cmd = if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd;
+ if (pane.focus == .tag) {
+ // An explicit selection, else the word under the cursor in the
+ // tag as it is shown: the prefix is part of what a word is.
+ const tag = p.tagText(p.scratch.allocator(), pane) catch return;
+ const tail = curTail(pane);
+ const range = t.primaryRange(tail, 0);
+ const lo = @min(range.anchor, range.head);
+ const hi = @max(range.anchor, range.head);
+ const txt = if ((t.vsel.active and t.vsel.explicit) or t.msel.active)
+ tail[lo..hi]
+ else word: {
+ const b = config.wordBounds(tag, tag.len - tail.len + modal.selectionCursor(tail, range));
+ break :word tag[b.lo..b.hi];
+ };
+ // Out of the tag FIRST: Del frees the pane.
+ pane.focus = .body;
+ if (txt.len > 0) exec.runBuiltin(p, cmd, p.active, "", txt);
+ return;
+ }
edit.pinPaneCursor(p, &pane.body);
const explicit = (p.native_images and pane.hasPdfSelection()) or
(pane.body.vsel.active and pane.body.vsel.explicit) or pane.body.msel.active;
@@ -5430,14 +5278,12 @@ pub const Pardes = struct {
exec.runBuiltin(p, cmd, p.active, "", word);
return;
}
- if (pane.body.mode == .normal and pane.hasPdf()) return normal.handlePdfNormal(p, pane, key);
- switch (pane.body.mode) {
- .normal => {
- normal.handleNormal(p, &pane.body, key);
- },
+ if (t.mode == .normal and t.what == .body and pane.hasPdf()) return normal.handlePdfNormal(p, pane, key);
+ switch (t.mode) {
+ .normal => normal.handleNormal(p, t, key),
.insert => {
- if (hit(key, config.escape)) return edit.exitInsert(p, &pane.body);
- edit.handleInsert(p, &pane.body, key);
+ if (hit(key, config.escape)) return edit.exitInsert(p, t);
+ edit.handleInsert(p, t, key);
},
.tty => panes.Terminal.forwardKey(p, p.active, key),
}
@@ -5507,7 +5353,7 @@ 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;
- if (pane.tag_edit) exitTagEdit(pane);
+ pane.focus = .body;
// 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) {
@@ -5806,8 +5652,7 @@ pub const Pardes = struct {
pub fn restoreDumpTail(p: *Pardes, pane: *Pane, src: dump.Pane) void {
if (src.tag_tail) |tail| {
- if (!pane.appendTag(tail)) return;
- pane.tag_init = true;
+ pane.tag.own = p.gpa.dupe(u8, tail) catch return;
return;
}
const tail_class = tailClass(pane);
@@ -5855,7 +5700,7 @@ pub const Pardes = struct {
}
fn restoreTailAt(
- _: *Pardes,
+ p: *Pardes,
pane: *Pane,
saved_tag: []const u8,
pfx: []const u8,
@@ -5886,8 +5731,7 @@ pub const Pardes = struct {
};
for (defaults) |default|
if (std.mem.eql(u8, said, std.mem.trimStart(u8, default, " "))) return;
- if (!pane.appendTag(rest)) return;
- pane.tag_init = true;
+ pane.tag.own = p.gpa.dupe(u8, rest) catch return;
}
/// Close a pane; its rows go to `to`, or where layout.absorbVWeight
@@ -6053,8 +5897,18 @@ pub const Pardes = struct {
p.trackJump();
for (&p.panes, 0..) |*slot, id| {
const pane = slot.* orelse continue;
- if (pane.tag_edit and (id != p.active or p.topbar_col != null))
- exitTagEdit(pane);
+ // The keyboard is on one pane, or on a header: a tag it has left
+ // hands it back to its body, and a name drafted there is dropped.
+ if (id != p.active or p.topbar_col != null) {
+ pane.focus = .body;
+ if (pane.prompt == .name) pane.closePrompt();
+ }
+ // A sweep over the tag is the tag's while it has the keyboard.
+ const swept = pane.sel[sel_slot];
+ if (pane.focus == .body and swept.state == .done and @min(swept.r0, swept.r1) < BOX_H) {
+ pane.clearPointerSelection(sel_slot);
+ pane.sel[sel_slot].state = .none;
+ }
if (comptime terminal_panes) if (pane.terminal) |state| {
var off: u16 = 0;
while (off < state.reply_len) {
@@ -6430,7 +6284,7 @@ pub const Pardes = struct {
s.set(d.cur_x, placement.row, "▌", .{ .fg = .{ .rgb = chrome.lineno } });
}
},
- .none, .select, .tag => if (p.pointer_inside) {
+ .none, .select => if (p.pointer_inside) {
// hover hints on a pane's own trailing edge
var c: usize = 0;
while (c + 1 < p.ncol) : (c += 1) {
@@ -6801,8 +6655,7 @@ pub const Pardes = struct {
.font_role = .tagline,
};
s.fill(r.x, tag_y, config.GUTTER, BOX_H, box_style);
- const box_mode = if (pane.tag_edit) pane.tag_mode else pane.body.mode;
- s.set(r.x, tag_y, switch (box_mode) {
+ s.set(r.x, tag_y, switch (pane.body.mode) {
.normal => config.tag_normal,
.insert => config.tag_insert,
.tty => config.tag_tty,
@@ -6856,9 +6709,24 @@ pub const Pardes = struct {
.bg = .{ .rgb = tag_bg },
.font_role = .tagline,
});
- const tag = try p.tagText(arena, pane);
- if (active and pane.tag_edit) {
- const caret = panes.File.rawDisplayCol(tag, pane.tag_col);
+ const whole = try p.tagText(arena, pane);
+ const tail = curTail(pane);
+ const prefix_len = whole.len - tail.len;
+ // The tagline shows the tag's first line: its prefix and the first
+ // line of its own text.
+ const tag = whole[0 .. std.mem.indexOfScalar(u8, whole, '\n') orelse whole.len];
+ // The keyboard's caret on it, as a byte offset: in its own text when
+ // the tag has the keyboard, in the path while a name is drafted.
+ const caret_at: ?usize = if (!active)
+ null
+ else if (pane.prompt == .name)
+ @min(tag.len, @as(usize, @intCast(@max(0, pane.input.cur_col))))
+ else if (pane.focus == .tag and pane.tag.cur_row == 0)
+ @min(tag.len, prefix_len + @as(usize, @intCast(@max(0, pane.tag.cur_col))))
+ else
+ null;
+ if (caret_at) |at| {
+ const caret = panes.File.rawDisplayCol(tag, at);
var scroll: usize = pane.tag_scroll;
if (caret < scroll) scroll = caret;
if (caret >= scroll + capacity) scroll = caret - capacity + 1;
@@ -6869,10 +6737,10 @@ pub const Pardes = struct {
scroll = panes.File.rawDisplayCol(tag, raw);
}
pane.tag_scroll = @intCast(scroll);
- } else pane.tag_scroll = 0;
+ } else if (pane.focus != .tag) 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| {
+ if (tagTintRange(pane, tag, prefix_len)) |name| {
const lo = @max(name.lo, tag_start);
const hi = @min(name.hi, tag.len);
if (lo < hi) {
@@ -6889,23 +6757,31 @@ pub const Pardes = struct {
body_layer.paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null);
};
}
- if (pane.tag_edit and pane.tag_sel) {
- const b = tagSelBounds(pane);
- var col = panes.File.rawDisplayCol(tag, b.lo) -| pane.tag_scroll;
- const hi = modal.nextGrapheme(tag, b.hi);
- const end = panes.File.rawDisplayCol(tag, hi);
- while (col + pane.tag_scroll < end and col < tw) : (col += 1) {
- const cell = s.at(tx + @as(u16, @intCast(col)), tag_y);
- cell.default = false;
- cell.style.bg = .{ .rgb = th.sel_bg };
- cell.style.fg = .{ .rgb = th.sel_fg };
+ // The tag's own selections, while it has the keyboard. A range of one
+ // grapheme is the block cursor, not a selection.
+ if (pane.focus == .tag) {
+ var ranges: [Text.max_selections]modal.Selection = undefined;
+ const got = pane.tag.ranges(tail, 0, &ranges);
+ const shown = tag.len - prefix_len;
+ for (ranges[0..got.n]) |range| {
+ const lo = @min(range.anchor, range.head);
+ const hi = @max(range.anchor, range.head);
+ if (modal.nextGrapheme(tail, lo) >= hi or lo >= shown) continue;
+ var col = panes.File.rawDisplayCol(tag, prefix_len + lo) -| pane.tag_scroll;
+ const end = panes.File.rawDisplayCol(tag, prefix_len + @min(hi, shown));
+ while (col + pane.tag_scroll < end and col < tw) : (col += 1) {
+ const cell = s.at(tx + @as(u16, @intCast(col)), tag_y);
+ cell.default = false;
+ cell.style.bg = .{ .rgb = th.sel_bg };
+ cell.style.fg = .{ .rgb = th.sel_fg };
+ }
}
}
- // cursor while editing the tag: byte offset mapped to its display cell
- if (active and pane.tag_edit) {
- // bar while typing, block for `:` normal mode (same rule as a body)
- const col = panes.File.rawDisplayCol(tag, pane.tag_col) -| pane.tag_scroll;
- if (col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(col)), .y = tag_y, .bar = pane.body.mode == .insert };
+ // the caret: bar while typing, block in normal mode (same rule as a body)
+ if (caret_at) |at| {
+ const col = panes.File.rawDisplayCol(tag, at) -| pane.tag_scroll;
+ const mode = if (pane.prompt == .name) pane.input.mode else pane.tag.mode;
+ if (col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(col)), .y = tag_y, .bar = mode == .insert };
}
for (pane.sel, 0..) |selection, button| {
@@ -7034,10 +6910,10 @@ test "raw terminal cursor obeys visibility without hiding modal and tag cursors"
const modal_cursor = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor;
try std.testing.expectEqual(rect.x + config.GUTTER + 1, modal_cursor.x);
try std.testing.expectEqual(body_y, modal_cursor.y);
- p.enterTagEdit(pane, 0);
+ p.enterTag(pane);
const tag_cursor = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor;
try std.testing.expectEqual(if (p.settings.tag_bottom) rect.y + rect.h - BOX_H else rect.y, tag_cursor.y);
- Pardes.exitTagEdit(pane);
+ pane.focus = .body;
pane.body.mode = .tty;
try std.testing.expect((try p.render(frame.allocator())).cursor == null);
@@ -7193,7 +7069,7 @@ test "a prompt is typed on its own line in the notice band and leaves the tag ro
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.expect(pane.prompt == .search and pane.focus == .body);
try std.testing.expectEqualStrings(config.search_marker ++ "tw", pane.promptText().?);
_ = frame.reset(.retain_capacity);
_ = try p.render(frame.allocator());
@@ -7243,8 +7119,7 @@ test "a stillborn pane gives its column to the first pane that joins it" {
try std.testing.expect(p.executeBuiltinLine(shell, "Newcol"));
const tagged = p.active;
p.sync();
- p.seedTail(p.panes[tagged].?);
- try std.testing.expect(p.panes[tagged].?.appendTag(" make"));
+ try p.panes[tagged].?.appendTag(" make");
try std.testing.expect(p.executeBuiltinLine(tagged, "Tty"));
p.update(.tick);
try std.testing.expect(p.panes[tagged] != null);
diff --git a/test/mode.zig b/test/mode.zig
index 19d59699..c429ea72 100644
--- a/test/mode.zig
+++ b/test/mode.zig
@@ -6,7 +6,7 @@ const Pardes = pardes.Pardes;
fn mode(p: *Pardes, id: usize, expected: panes.Text.Mode) !void {
try std.testing.expect(p.executeBuiltinLine(id, "Mode"));
try std.testing.expectEqual(expected, p.panes[id].?.body.mode);
- try std.testing.expect(!p.panes[id].?.tag_edit);
+ try std.testing.expect(p.panes[id].?.focus == .body);
}
fn clickModeTag(p: *Pardes) !void {
@@ -17,13 +17,12 @@ fn clickModeTag(p: *Pardes) !void {
const row = if (p.settings.tag_bottom) rect.y + rect.h - pardes.BOX_H else rect.y;
for ([_]pardes.Mouse.Kind{ .press, .release }) |kind|
p.update(.{ .mouse = .{ .button = .left, .kind = kind, .col = rect.x + pardes.TAG_TEXT_INSET + @as(u16, @intCast(col)), .row = row } });
- try std.testing.expect(pane.tag_edit);
+ try std.testing.expect(pane.focus == .tag);
}
-fn customTag(pane: *pardes.Pane, text: []const u8) void {
- @memcpy(pane.tag_tail[0..text.len], text);
- pane.tag_tail_len = text.len;
- pane.tag_init = true;
+fn customTag(pane: *pardes.Pane, text: []const u8) !void {
+ if (pane.tag.own) |own| pane.gpa.free(own);
+ pane.tag.own = try pane.gpa.dupe(u8, text);
}
test "Mode cycles terminal modes and keeps legacy toggle and custom tags" {
@@ -37,14 +36,18 @@ test "Mode cycles terminal modes and keeps legacy toggle and custom tags" {
try std.testing.expect(std.mem.indexOf(u8, try p.tagText(p.scratch.allocator(), pane), " Mode") != null);
try mode(p, 0, .normal);
try mode(p, 0, .insert);
+ // A clicked tag is typed into; its execute key is a normal-mode key, as
+ // a body's is, and the body keeps its own mode meanwhile.
try clickModeTag(p);
- try std.testing.expectEqual(panes.Text.Mode.insert, pane.tag_mode);
+ try std.testing.expectEqual(panes.Text.Mode.insert, pane.tag.mode);
+ try std.testing.expectEqual(panes.Text.Mode.insert, pane.body.mode);
+ p.update(.{ .key = .{ .cp = pardes.Key.escape } });
p.update(.{ .key = .{ .cp = pardes.Key.tab } });
try std.testing.expectEqual(panes.Text.Mode.tty, pane.body.mode);
- try std.testing.expect(!pane.tag_edit);
+ try std.testing.expect(pane.focus == .body);
while (p.nextEffect()) |effect| try std.testing.expect(effect != .write);
- customTag(pane, " Togglettymode Keep");
+ try customTag(pane, " Togglettymode Keep");
try mode(p, 0, .normal);
try mode(p, 0, .insert);
try std.testing.expect(p.executeBuiltinLine(0, "Togglettymode"));
@@ -53,7 +56,7 @@ test "Mode cycles terminal modes and keeps legacy toggle and custom tags" {
try std.testing.expectEqual(panes.Text.Mode.normal, pane.body.mode);
p.update(.{ .key = .{ .cp = 'b', .ctrl = true } });
try std.testing.expectEqual(panes.Text.Mode.tty, pane.body.mode);
- try std.testing.expectEqualStrings(" Togglettymode Keep", pane.tag_tail[0..pane.tag_tail_len]);
+ try std.testing.expectEqualStrings(" Togglettymode Keep", pane.tag.own.?);
}
test "Mode from actual tag Exec preserves parked file and output insert transactions" {
@@ -65,17 +68,17 @@ test "Mode from actual tag Exec preserves parked file and output insert transact
const pane = try p.setTestFile("ab\n");
if (output) pane.file.?.output = .{ .from = .search };
try std.testing.expect(std.mem.indexOf(u8, try p.tagText(p.scratch.allocator(), pane), " Mode") == null);
- customTag(pane, " Mode");
+ try customTag(pane, " Mode");
try mode(p, 0, .insert);
p.update(.{ .key = .{ .cp = '界', .text = "界" } });
p.update(.{ .key = .{ .cp = 'x', .text = "x" } });
try std.testing.expectEqualStrings("界xab\n", pane.file.?.content);
try clickModeTag(p);
- try std.testing.expectEqual(panes.Text.Mode.insert, pane.tag_mode);
- if (normal_tag) pane.body.mode = .normal;
+ try std.testing.expectEqual(panes.Text.Mode.insert, pane.body.mode);
+ if (normal_tag) pane.tag.mode = .normal else p.update(.{ .key = .{ .cp = pardes.Key.escape } });
p.update(.{ .key = .{ .cp = pardes.Key.tab } });
try std.testing.expectEqual(panes.Text.Mode.normal, pane.body.mode);
- try std.testing.expect(!pane.tag_edit);
+ try std.testing.expect(pane.focus == .body);
try std.testing.expect(pane.body.append_at == null);
p.update(.{ .key = .{ .cp = 'u' } });
try std.testing.expectEqualStrings("ab\n", pane.file.?.content);
diff --git a/test/panes.zig b/test/panes.zig
index f1adafee..a391dba2 100644
--- a/test/panes.zig
+++ b/test/panes.zig
@@ -12,6 +12,17 @@ const image = pardes.image;
const syntax = pardes.syntax;
const filesystem = pardes.filesystem;
+/// The tag's caret as a byte offset into the tag as it is shown, prefix
+/// included.
+fn tagCaret(p: *Pardes, pane: *Pane) !usize {
+ return (try p.tagPrefix(pane)).len + @as(usize, @intCast(pane.tag.cur_col));
+}
+
+fn setTag(pane: *Pane, text: []const u8) !void {
+ if (pane.tag.own) |own| pane.gpa.free(own);
+ pane.tag.own = try pane.gpa.dupe(u8, text);
+}
+
const TagLayerTests = struct {
const Kind = @FieldType(pardes.TagLayer, "kind");
fn findLayer(p: *Pardes, kind: Kind, id: usize) !*pardes.TagLayer {
@@ -36,10 +47,7 @@ const TagLayerTests = struct {
p.row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 5, .tagline_h = 10 };
p.gpa.free(pane.file.?.path);
pane.file.?.path = try p.gpa.dupe(u8, "/x");
- const tail = " " ++ "a" ** 38 ++ "\t界 END";
- @memcpy(pane.tag_tail[0..tail.len], tail);
- pane.tag_tail_len = tail.len;
- pane.tag_init = true;
+ try setTag(pane, " " ++ "a" ** 38 ++ "\t界 END");
var frame = std.heap.ArenaAllocator.init(p.gpa);
defer frame.deinit();
_ = try p.render(frame.allocator());
@@ -55,11 +63,11 @@ const TagLayerTests = struct {
var stale = mouse(layer, x, .press);
stale.tag_hit.?.serial +%= 1;
p.update(.{ .mouse = stale });
- try std.testing.expect(!pane.tag_edit);
+ try std.testing.expect(pane.focus == .body);
p.update(.{ .mouse = mouse(layer, x, .press) });
p.update(.{ .mouse = mouse(layer, x, .release) });
- try std.testing.expect(pane.tag_edit);
- try std.testing.expectEqual(@as(u16, @intCast(at)), pane.tag_col);
+ try std.testing.expect(pane.focus == .tag);
+ try std.testing.expectEqual(at, try tagCaret(p, pane));
p.update(.{ .key = .{ .cp = 'X', .text = "X" } });
_ = try p.render(frame.allocator());
const edited = try p.tagText(frame.allocator(), pane);
@@ -91,7 +99,7 @@ const TagLayerTests = struct {
event.row = @intFromFloat(painted_y);
p.update(.{ .mouse = event });
}
- try std.testing.expectEqual(@as(u16, @intCast(at)), pane.tag_col);
+ try std.testing.expectEqual(at, try tagCaret(p, pane));
}
}
@@ -119,19 +127,17 @@ const TagLayerTests = struct {
try std.testing.expectEqualStrings(" ", p.surface.at(layer.viewport.x - 1, layer.viewport.y).grapheme());
p.update(.{ .mouse = mouse(layer, edge - 0.1, .press) });
try std.testing.expect(p.drag != .move);
- try std.testing.expect(!p.panes[second].?.tag_edit);
+ try std.testing.expect(p.panes[second].?.focus == .body);
p.update(.{ .mouse = mouse(layer, edge - 0.1, .release) });
p.update(.{ .mouse = mouse(layer, edge + 0.1, .press) });
p.update(.{ .mouse = mouse(layer, edge + 0.1, .release) });
try std.testing.expectEqual(second, p.active);
- try std.testing.expect(p.panes[second].?.tag_edit);
- try std.testing.expectEqual(@as(u16, 0), p.panes[second].?.tag_col);
+ // The first cell of the tag is its path: a click there drafts a name.
+ try std.testing.expect(p.panes[second].?.prompt == .name);
+ try std.testing.expectEqual(@as(i32, 0), p.panes[second].?.input.cur_col);
p.update(.{ .key = .{ .cp = Key.escape } });
const left = p.panes[0].?;
- const tail = " " ++ "a" ** 110;
- @memcpy(left.tag_tail[0..tail.len], tail);
- left.tag_tail_len = tail.len;
- left.tag_init = true;
+ try setTag(left, " " ++ "a" ** 110);
_ = try p.render(frame.allocator());
const left_layer = try findLayer(p, .pane, 0);
const left_text = try p.tagText(frame.allocator(), left);
@@ -142,8 +148,8 @@ const TagLayerTests = struct {
p.update(.{ .mouse = mouse(layer, beyond_x, .release) });
try std.testing.expectEqual(@as(usize, 0), p.active);
const end_col = left_layer.capacity(p.row_metrics) - 1;
- try std.testing.expectEqual(@as(u16, @intCast(panes.File.rawAtDisplay(left_text, end_col))), left.tag_col);
- try std.testing.expect(left.tag_sel);
+ try std.testing.expectEqual(panes.File.rawAtDisplay(left_text, end_col), try tagCaret(p, left));
+ try std.testing.expect(left.sel[0].state == .done and left.sel[0].r0 == 0);
p.update(.{ .key = .{ .cp = Key.escape } });
const long = "a" ** 60 ++ "界END";
try std.testing.expect(p.global_tag.set(long));
@@ -167,12 +173,12 @@ const TagNavigationTests = struct {
p.update(.{ .key = .{ .cp = cp } });
}
- fn enterNormal(p: *Pardes) *Pane {
- key(p, ':');
- return p.panes[p.active].?;
+ fn window(p: *Pardes, letter: u21) void {
+ p.update(.{ .key = .{ .cp = 'w', .ctrl = true } });
+ key(p, letter);
}
- test "tag navigation retains each pane cursor and mouse placement overrides memory" {
+ test "a tag remembers its cursor, starts on Save, and a click moves it" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 20 });
defer p.deinit();
const first = try p.setTestFile("body\n");
@@ -183,65 +189,97 @@ const TagNavigationTests = struct {
try std.testing.expect(layout.splitColumn(p, second_id, second_id, false));
p.update(.tick);
p.active = 0;
+ // Nothing to remember yet: `:` lands on Save, in normal mode.
key(p, ':');
+ try std.testing.expect(first.focus == .tag and first.tag.mode == .normal);
+ const tail = Pardes.curTail(first);
+ try std.testing.expectEqualStrings("Save", tail[@intCast(first.tag.cur_col)..][0..4]);
key(p, Key.home);
for (0..3) |_| key(p, Key.right);
- const first_col = first.tag_col;
- key(p, Key.escape);
+ try std.testing.expectEqual(@as(i32, 3), first.tag.cur_col);
+ // `:` in the tag's normal mode is the way back to the body.
+ key(p, ':');
+ try std.testing.expect(first.focus == .body);
p.active = second_id;
key(p, ':');
key(p, Key.home);
for (0..5) |_| key(p, Key.right);
- const second_col = second.tag_col;
- key(p, Key.escape);
+ key(p, ':');
p.active = 0;
key(p, ':');
- try std.testing.expectEqual(first_col, first.tag_col);
- key(p, Key.escape);
+ try std.testing.expectEqual(@as(i32, 3), first.tag.cur_col);
+ key(p, ':');
p.active = second_id;
key(p, ':');
- try std.testing.expectEqual(second_col, second.tag_col);
- key(p, Key.escape);
+ try std.testing.expectEqual(@as(i32, 5), second.tag.cur_col);
+ key(p, ':');
var frame = std.heap.ArenaAllocator.init(p.gpa);
defer frame.deinit();
_ = try p.render(frame.allocator());
const rect = p.rects[0];
const y = if (p.settings.tag_bottom) rect.y + rect.h - pardes.BOX_H else rect.y;
- ClickCursorTests.click(p, .left, rect.x + pardes.TAG_TEXT_INSET + 6, y);
+ const prefix: u16 = @intCast(panes.File.displayWidth(try p.tagPrefix(first)));
+ ClickCursorTests.click(p, .left, rect.x + pardes.TAG_TEXT_INSET + prefix + 6, y);
try std.testing.expectEqual(@as(usize, 0), p.active);
- try std.testing.expectEqual(@as(u16, 6), first.tag_col);
+ try std.testing.expect(first.focus == .tag and first.tag.mode == .insert);
+ try std.testing.expectEqual(@as(i32, 6), first.tag.cur_col);
key(p, Key.escape);
+ key(p, ':');
p.startPrompt(first, .{ .search = config.search_marker });
key(p, Key.escape);
key(p, ':');
- try std.testing.expectEqual(@as(u16, 6), first.tag_col);
- key(p, Key.escape);
+ try std.testing.expectEqual(@as(i32, 6), first.tag.cur_col);
+ key(p, ':');
try std.testing.expectEqualStrings("body\n", first.file.?.content);
}
- test "tag navigation clamps remembered columns to renamed graphemes and shorter tags" {
+ test "executing a word from the tag runs it and gives the keyboard back to the body" {
+ for ([_]u21{ Key.tab, Key.enter }) |chord| {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 20 });
+ defer p.deinit();
+ const pane = try p.setTestFile("body\n");
+ p.update(.{ .key = .{ .cp = 'i' } });
+ p.update(.{ .key = .{ .cp = 'x', .text = "x" } });
+ p.update(.{ .key = .{ .cp = Key.escape } });
+ while (p.nextEffect()) |_| {}
+ // `:` lands on Save; the execute key runs it, the look key looks
+ // it up. Either way the tag lets go of the keyboard first.
+ key(p, ':');
+ try std.testing.expect(pane.focus == .tag);
+ key(p, chord);
+ try std.testing.expect(pane.focus == .body);
+ try std.testing.expectEqual(.normal, pane.body.mode);
+ var saved = false;
+ while (p.nextEffect()) |effect| {
+ if (effect == .save_file) saved = true;
+ }
+ try std.testing.expectEqual(chord == Key.tab, saved);
+ // ...and the tag still knows where it was.
+ key(p, ':');
+ try std.testing.expectEqualStrings("Save", Pardes.curTail(pane)[@intCast(pane.tag.cur_col)..][0..4]);
+ }
+ }
+
+ test "a renamed path never moves the tag's cursor and a shorter tag pulls it back" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 14 });
defer p.deinit();
const pane = try p.setTestFile("body\n");
key(p, ':');
key(p, Key.home);
for (0..3) |_| key(p, Key.right);
- key(p, Key.escape);
+ key(p, ':');
p.gpa.free(pane.file.?.path);
pane.file.?.path = try p.gpa.dupe(u8, "/界.zig");
key(p, ':');
- try std.testing.expectEqual(@as(u16, 1), pane.tag_col);
- key(p, Key.end);
- key(p, Key.escape);
- p.gpa.free(pane.file.?.path);
- pane.file.?.path = try p.gpa.dupe(u8, "/é");
- pane.tag_tail_len = 0;
+ try std.testing.expectEqual(@as(i32, 3), pane.tag.cur_col);
+ key(p, ':');
+ try setTag(pane, "é");
key(p, ':');
- try std.testing.expectEqual(@as(u16, 3), pane.tag_col);
+ try std.testing.expectEqual(@as(i32, 2), pane.tag.cur_col);
try std.testing.expectEqualStrings("body\n", pane.file.?.content);
}
- test "tag navigation J and K restore cursors in stacked panes" {
+ test "window keys move between panes from a tag and each tag keeps its cursor" {
for ([_]bool{ false, true }) |bottom| {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
defer p.deinit();
@@ -253,88 +291,67 @@ const TagNavigationTests = struct {
const second = p.panes[second_id].?;
p.update(.tick);
p.active = 0;
- _ = enterNormal(p);
+ key(p, ':');
key(p, Key.home);
key(p, 'l');
key(p, 'l');
- key(p, 'J');
+ window(p, 'j');
try std.testing.expectEqual(second_id, p.active);
- try std.testing.expect(!first.tag_edit and second.tag_edit);
+ try std.testing.expect(first.focus == .body and second.focus == .body);
+ key(p, ':');
key(p, Key.home);
for (0..4) |_| key(p, 'l');
- key(p, 'K');
+ window(p, 'k');
try std.testing.expectEqual(@as(usize, 0), p.active);
- try std.testing.expectEqual(@as(u16, 2), first.tag_col);
- key(p, 'J');
- try std.testing.expectEqual(second_id, p.active);
- try std.testing.expectEqual(@as(u16, 4), second.tag_col);
+ try std.testing.expect(second.focus == .body);
+ key(p, ':');
+ try std.testing.expectEqual(@as(i32, 2), first.tag.cur_col);
+ window(p, 'j');
+ key(p, ':');
+ try std.testing.expectEqual(@as(i32, 4), second.tag.cur_col);
}
}
- test "tag navigation uses uppercase pane and header keys while lowercase stays local" {
+ test "a tag's normal mode is the body's: motions stay in the tag and insert types into it" {
for ([_]bool{ false, true }) |bottom| {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 20 });
defer p.deinit();
_ = try p.setTestFile("body\n");
p.presentation.enabled = false;
p.settings.tag_bottom = bottom;
- p.settings.column_tags = true;
try std.testing.expect(p.executeBuiltinLine(0, "New"));
const right = p.active;
try std.testing.expect(layout.splitColumn(p, right, right, false));
p.update(.tick);
p.active = 0;
- const pane = enterNormal(p);
+ key(p, ':');
+ const pane = p.panes[0].?;
key(p, Key.home);
key(p, 'l');
- try std.testing.expectEqual(@as(u16, 1), pane.tag_col);
+ try std.testing.expectEqual(@as(i32, 1), pane.tag.cur_col);
key(p, 'h');
key(p, 'k');
- try std.testing.expectEqual(@as(u16, 0), pane.tag_col);
+ key(p, 'j');
+ try std.testing.expectEqual(@as(i32, 0), pane.tag.cur_col);
try std.testing.expectEqual(@as(usize, 0), p.active);
- try std.testing.expect(pane.tag_edit and p.topbar_col == null);
- key(p, 'L');
+ try std.testing.expect(pane.focus == .tag and p.topbar_col == null);
+ window(p, 'l');
try std.testing.expectEqual(right, p.active);
- try std.testing.expect(!pane.tag_edit and p.panes[right].?.tag_edit);
- key(p, 'H');
+ try std.testing.expect(pane.focus == .body and p.panes[right].?.focus == .body);
+ window(p, 'h');
try std.testing.expectEqual(@as(usize, 0), p.active);
- key(p, 'K');
- try std.testing.expect(p.topbar_col != null and p.header_column != null);
- try std.testing.expect(!pane.tag_edit);
- key(p, 'l');
- const column_cursor = p.topbar_col.?;
- key(p, 'K');
- try std.testing.expect(p.topbar_col != null and p.header_column == null);
- key(p, 'l');
- key(p, 'l');
- const workspace_cursor = p.topbar_col.?;
- key(p, 'J');
- try std.testing.expect(p.topbar_col != null and p.header_column != null);
- try std.testing.expectEqual(column_cursor, p.topbar_col.?);
- key(p, 'K');
- try std.testing.expectEqual(workspace_cursor, p.topbar_col.?);
- key(p, 'J');
- key(p, 'J');
- try std.testing.expect(p.topbar_col == null and pane.tag_edit);
- key(p, 'j');
- try std.testing.expect(!pane.tag_edit and p.topbar_col == null);
- try std.testing.expectEqual(@as(usize, 0), p.active);
- _ = enterNormal(p);
- key(p, 'K');
- key(p, 'j');
- try std.testing.expect(p.topbar_col == null and !pane.tag_edit);
key(p, ':');
key(p, 'i');
key(p, Key.end);
- const before = pane.tag_tail_len;
- try std.testing.expect(pane.tag_edit and pane.body.mode == .insert);
- for ("hjklHJKL") |letter| {
+ const before = Pardes.curTail(pane).len;
+ try std.testing.expect(pane.focus == .tag and pane.tag.mode == .insert and pane.body.mode == .normal);
+ for ("hjklHJKL:") |letter| {
const text = [_]u8{letter};
p.update(.{ .key = .{ .cp = letter, .text = &text } });
- try std.testing.expect(pane.tag_edit and pane.body.mode == .insert);
+ try std.testing.expect(pane.focus == .tag and pane.tag.mode == .insert);
}
- try std.testing.expectEqualStrings("hjklHJKL", pane.tag_tail[before..pane.tag_tail_len]);
- try std.testing.expect(pane.tag_edit and pane.body.mode == .insert and p.topbar_col == null);
+ try std.testing.expectEqualStrings("hjklHJKL:", Pardes.curTail(pane)[before..]);
+ try std.testing.expectEqualStrings("body\n", pane.file.?.content);
try std.testing.expectEqual(@as(usize, 0), p.active);
}
}
@@ -1151,32 +1168,26 @@ const TagNameTintTests = struct {
}
}
- test "tag filename tint follows scrolled Unicode staged names and yields to selection" {
+ test "tag filename tint follows scrolled Unicode drafted names and yields to selection" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 38, .rows = 14 });
defer p.deinit();
const pane = try p.setTestFile("body\n");
- const old = pane.file.?.path.len;
configure(p);
defer p.custom_theme = null;
- p.update(.{ .key = .{ .cp = ':' } });
- p.update(.{ .key = .{ .cp = 'i' } });
- pane.tag_anchor = 0;
- pane.tag_col = @intCast(old - 1);
- pane.tag_sel = true;
const name = "/a/very/long/directory/that/extends/past/the/tag/界e\u{301}name-with-a-long-tail.zig";
- p.update(.{ .paste = name });
- try std.testing.expectEqualStrings(name, pane.tag_name[0..pane.tag_name_len.?]);
- pane.tag_col = @intCast(std.mem.indexOf(u8, name, "界").?);
- pane.tag_sel = false;
+ p.draftName(pane, 0);
+ redraft(pane, name);
+ pane.input.cur_col = @intCast(std.mem.indexOf(u8, name, "界").?);
try std.testing.expect((try check(p, 0, name)) > 0);
try std.testing.expect(pane.tag_scroll > 0);
- pane.tag_anchor = pane.tag_col;
- pane.tag_sel = true;
+ // A sweep over the wide glyph wears the selection over the tint.
+ const col: i32 = @intCast(panes.File.rawDisplayCol(name, @intCast(pane.input.cur_col)));
+ pane.sel[0] = .{ .state = .done, .c0 = col, .c1 = col + 1, .r0 = 0, .r1 = 0 };
pane.tag_scroll += 4; // bring both cells of the selected wide glyph into view
var arena = std.heap.ArenaAllocator.init(p.gpa);
defer arena.deinit();
const surface = try p.render(arena.allocator());
- const x = p.rects[0].x + pardes.TAG_TEXT_INSET + @as(u16, @intCast(panes.File.rawDisplayCol(name, pane.tag_col) - pane.tag_scroll));
+ const x = p.rects[0].x + pardes.TAG_TEXT_INSET + @as(u16, @intCast(col - pane.tag_scroll));
const y = if (p.settings.tag_bottom) p.rects[0].y + p.rects[0].h - pardes.BOX_H else p.rects[0].y;
try std.testing.expectEqual(pardes.Color{ .rgb = p.theme().sel_fg }, surface.at(x, y).style.fg);
try std.testing.expectEqual(pardes.Color{ .rgb = p.theme().sel_fg }, surface.at(x + 1, y).style.fg);
@@ -1208,32 +1219,46 @@ const TagNameTintTests = struct {
}
};
-test "pane filename edits stage commit and cancel without changing body or command tail" {
+/// Replace a name draft's text, the caret after it, as clearing it and
+/// typing would.
+fn redraft(pane: *Pane, name: []const u8) void {
+ if (pane.input.own) |own| pane.gpa.free(own);
+ pane.input.own = pane.gpa.dupe(u8, name) catch unreachable;
+ pane.input.cur_col = @intCast(name.len);
+}
+
+/// Click the tagline at the end of the path: a name draft, caret there.
+fn clickPathEnd(p: *Pardes, pane: *Pane) void {
+ const rect = p.rects[p.active];
+ const y = if (p.settings.tag_bottom) rect.y + rect.h - pardes.BOX_H else rect.y;
+ const x = rect.x + pardes.TAG_TEXT_INSET + @as(u16, @intCast(panes.File.displayWidth(pane.file.?.path) - 1));
+ ClickCursorTests.click(p, .left, x, y);
+ std.debug.assert(pane.prompt == .name);
+ pane.input.cur_col = @intCast(pane.input.own.?.len);
+}
+
+test "pane filename drafts commit and cancel without changing body or command tail" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 70, .rows = 12 });
defer p.deinit();
const pane = try p.setTestFile("original body\n");
const original = try p.gpa.dupe(u8, pane.file.?.path);
defer p.gpa.free(original);
- p.seedTail(pane);
- try std.testing.expect(pane.appendTag(" SyntaxBold"));
- const tail = try p.gpa.dupe(u8, pane.tagSlice());
+ try pane.appendTag(" SyntaxBold");
+ const tail = try p.gpa.dupe(u8, Pardes.curTail(pane));
defer p.gpa.free(tail);
for ([_]bool{ false, true }) |commit| {
- p.update(.{ .key = .{ .cp = ':' } });
- p.update(.{ .key = .{ .cp = 'i' } });
- pane.tag_anchor = 0;
- pane.tag_col = @intCast(original.len - 1);
- pane.tag_sel = true;
+ clickPathEnd(p, pane);
+ p.update(.{ .key = .{ .cp = 'u', .ctrl = true } });
p.update(.{ .paste = "/new name.zig" });
try std.testing.expectEqualStrings(original, pane.file.?.path);
- try std.testing.expectEqualStrings("/new name.zig", pane.tag_name[0..pane.tag_name_len.?]);
- try std.testing.expectEqualStrings(tail, pane.tagSlice());
+ try std.testing.expectEqualStrings("/new name.zig", pane.input.own.?);
+ try std.testing.expectEqualStrings(tail, Pardes.curTail(pane));
try std.testing.expectEqualStrings("original body\n", pane.file.?.content);
p.update(.{ .key = .{ .cp = if (commit) Key.enter else Key.escape } });
- try std.testing.expect(!pane.tag_edit);
- try std.testing.expect(pane.tag_name_len == null);
+ try std.testing.expect(pane.focus == .body);
+ try std.testing.expect(pane.prompt == .none);
try std.testing.expectEqualStrings(if (commit) "/new name.zig" else original, pane.file.?.path);
- try std.testing.expectEqualStrings(tail, pane.tagSlice());
+ try std.testing.expectEqualStrings(tail, Pardes.curTail(pane));
while (p.nextEffect()) |effect| try std.testing.expect(std.meta.activeTag(effect) != .save_file);
}
try std.testing.expect(pane.file.?.revision != pane.file.?.saved_revision);
@@ -1243,27 +1268,21 @@ test "pane filename grapheme edits and invalid empty commits preserve the buffer
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
defer p.deinit();
const pane = try p.setTestFile("untouched\n");
- p.update(.{ .key = .{ .cp = ':' } });
- p.update(.{ .key = .{ .cp = 'i' } });
- pane.tag_anchor = 0;
- pane.tag_col = @intCast(pane.file.?.path.len - 1);
- pane.tag_sel = true;
+ clickPathEnd(p, pane);
+ p.update(.{ .key = .{ .cp = 'u', .ctrl = true } });
p.update(.{ .paste = "/café界" });
p.update(.{ .key = .{ .cp = Key.backspace } });
- try std.testing.expectEqualStrings("/café", pane.tag_name[0..pane.tag_name_len.?]);
+ try std.testing.expectEqualStrings("/café", pane.input.own.?);
p.update(.{ .key = .{ .cp = Key.backspace } });
- try std.testing.expectEqualStrings("/caf", pane.tag_name[0..pane.tag_name_len.?]);
- pane.tag_anchor = 0;
- pane.tag_col = 3;
- pane.tag_sel = true;
- p.update(.{ .key = .{ .cp = Key.delete } });
- try std.testing.expectEqual(@as(u16, 0), pane.tag_name_len.?);
+ try std.testing.expectEqualStrings("/caf", pane.input.own.?);
+ p.update(.{ .key = .{ .cp = 'u', .ctrl = true } });
+ try std.testing.expectEqualStrings("", pane.input.own.?);
p.update(.{ .key = .{ .cp = Key.enter } });
- try std.testing.expect(pane.tag_edit);
+ try std.testing.expect(pane.prompt == .name);
try std.testing.expectEqualStrings("/test.txt", pane.file.?.path);
try std.testing.expectEqualStrings("untouched\n", pane.file.?.content);
p.update(.{ .key = .{ .cp = Key.escape } });
- try std.testing.expect(pane.tag_name_len == null);
+ try std.testing.expect(pane.prompt == .none);
}
test "leaving a pane or entering a header cancels an uncommitted filename" {
@@ -1273,11 +1292,10 @@ test "leaving a pane or entering a header cancels an uncommitted filename" {
for ([_]bool{ false, true }) |header| {
p.active = 0;
p.topbar_col = null;
- p.update(.{ .key = .{ .cp = ':' } });
- p.update(.{ .key = .{ .cp = 'i' } });
- p.update(.{ .key = .{ .cp = Key.home } });
+ p.update(.tick);
+ clickPathEnd(p, pane);
p.update(.{ .paste = "/draft" });
- try std.testing.expect(pane.tag_name_len != null);
+ try std.testing.expect(pane.prompt == .name);
if (header) {
p.topbar_col = 0;
p.update(.tick);
@@ -1285,21 +1303,18 @@ test "leaving a pane or entering a header cancels an uncommitted filename" {
try std.testing.expect(p.executeBuiltinLine(0, "New"));
p.update(.tick);
}
- try std.testing.expect(pane.tag_name_len == null);
- try std.testing.expect(!pane.tag_edit);
+ try std.testing.expect(pane.prompt == .none);
+ try std.testing.expect(pane.focus == .body);
try std.testing.expectEqualStrings("/test.txt", pane.file.?.path);
}
}
-test "middle clicking Save commits the staged filename before issuing a save" {
+test "middle clicking Save commits the drafted filename before issuing a save" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 70, .rows = 12 });
defer p.deinit();
const pane = try p.setTestFile("untouched\n");
- p.update(.{ .key = .{ .cp = ':' } });
- p.update(.{ .key = .{ .cp = 'i' } });
- pane.tag_anchor = 0;
- pane.tag_col = @intCast(pane.file.?.path.len - 1);
- pane.tag_sel = true;
+ clickPathEnd(p, pane);
+ p.update(.{ .key = .{ .cp = 'u', .ctrl = true } });
p.update(.{ .paste = "/renamed.txt" });
var arena = std.heap.ArenaAllocator.init(p.gpa);
defer arena.deinit();
@@ -1312,7 +1327,7 @@ test "middle clicking Save commits the staged filename before issuing a save" {
p.update(.{ .mouse = .{ .col = x, .row = y, .button = config.exec_button, .kind = .press } });
p.update(.{ .mouse = .{ .col = x, .row = y, .button = config.exec_button, .kind = .release } });
try std.testing.expectEqualStrings("/renamed.txt", pane.file.?.path);
- try std.testing.expect(pane.tag_name_len == null);
+ try std.testing.expect(pane.prompt == .none);
try std.testing.expectEqualStrings("untouched\n", pane.file.?.content);
var saves: usize = 0;
while (p.nextEffect()) |effect| if (effect == .save_file) {
@@ -1322,24 +1337,19 @@ test "middle clicking Save commits the staged filename before issuing a save" {
try std.testing.expectEqual(@as(usize, 1), saves);
}
-test "oversized filename paste refuses atomically across a selected tag boundary" {
+test "an oversized drafted name is refused at commit and the draft stays to correct" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
defer p.deinit();
const pane = try p.setTestFile("untouched\n");
- p.update(.{ .key = .{ .cp = ':' } });
- p.update(.{ .key = .{ .cp = 'i' } });
- const before = try p.tagText(p.gpa, pane);
- defer p.gpa.free(before);
- pane.tag_anchor = 0;
- pane.tag_col = @intCast(before.len - 1);
- pane.tag_sel = true;
- const excess = try p.gpa.alloc(u8, pane.tag_name.len + 1);
+ clickPathEnd(p, pane);
+ const excess = try p.gpa.alloc(u8, 5000);
defer p.gpa.free(excess);
@memset(excess, 'x');
+ excess[0] = '/';
p.update(.{ .paste = excess });
- try std.testing.expect(pane.tag_name_len == null);
- try std.testing.expect(pane.tag_sel);
- try std.testing.expectEqualStrings(before, try p.tagText(p.scratch.allocator(), pane));
+ p.update(.{ .key = .{ .cp = Key.enter } });
+ try std.testing.expect(pane.prompt == .name);
+ try std.testing.expectEqualStrings("/test.txt", pane.file.?.path);
try std.testing.expectEqualStrings("untouched\n", pane.file.?.content);
}
@@ -1349,16 +1359,17 @@ test "paste into pane commands and prompts never inserts into the body" {
const pane = try p.setTestFile("one two one\n");
p.update(.{ .key = .{ .cp = ':' } });
p.update(.{ .key = .{ .cp = 'A' } });
- p.update(.{ .paste = " Custom\r\nCommand" });
- try std.testing.expect(std.mem.endsWith(u8, pane.tagSlice(), " CustomCommand"));
+ // A tag takes a paste as a body does, newline and all.
+ p.update(.{ .paste = " Custom\nCommand" });
+ try std.testing.expect(std.mem.indexOf(u8, Pardes.curTail(pane), " Custom\nCommand") != null);
p.update(.{ .key = .{ .cp = Key.escape } });
p.startPrompt(pane, .{ .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.endsWith(u8, pane.tagSlice(), " CustomCommand"));
+ try std.testing.expect(std.mem.indexOf(u8, Pardes.curTail(pane), " Custom\nCommand") != null);
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"));
+ try std.testing.expect(std.mem.indexOf(u8, Pardes.curTail(pane), " Custom\nCommand") != null);
}
test "long pane tags reveal commands and map visible clicks to whole tag addresses" {
@@ -1367,8 +1378,7 @@ test "long pane tags reveal commands and map visible clicks to whole tag address
const pane = try p.setTestFile("body\n");
p.gpa.free(pane.file.?.path);
pane.file.?.path = try p.gpa.dupe(u8, "/an/especially/deep/project/path/with/a/long/component/filename.zig");
- p.seedTail(pane);
- try std.testing.expect(pane.appendTag(" SyntaxBold"));
+ try pane.appendTag(" SyntaxBold");
p.update(.{ .key = .{ .cp = ':' } });
p.update(.{ .key = .{ .cp = 'A' } });
var arena = std.heap.ArenaAllocator.init(p.gpa);
@@ -1549,7 +1559,13 @@ test "terminal edits and undo survive partial history clearing and whole-screen
pane.body.cur_pinned = true;
pane.body.vsel = .{ .active = true, .row = row, .col = 1 };
panes.Terminal.pushUndo(p, pane);
- pane.body.ed_redo[0] = panes.Terminal.snap(p, pane) orelse return error.SnapshotFailed;
+ const overlay = pane.ovl.?;
+ pane.body.ed_redo[0] = .{
+ .ovl = .{ .row = overlay.row, .rows = overlay.rows, .text = try gpa.dupe(u8, overlay.text) },
+ .cur_row = pane.body.cur_row,
+ .cur_col = pane.body.cur_col,
+ .vsel = pane.body.vsel,
+ };
pane.body.ed_redo_len = 1;
if (action == .hidden_undo) {
gpa.free(pane.ovl.?.text);
@@ -1663,9 +1679,7 @@ test "a prompt's entry and cancellation leave the tag, the body and its cursor a
try pane.setOwnedCwd("/dír space");
const tail = " keep";
for (cases) |case| {
- @memcpy(pane.tag_tail[0..tail.len], tail);
- pane.tag_tail_len = tail.len;
- pane.tag_init = true;
+ try setTag(pane, tail);
pane.body.cur_row = 1;
pane.body.cur_col = 2;
pane.body.cur_pinned = true;
@@ -1677,17 +1691,17 @@ test "a prompt's entry and cancellation leave the tag, the body and its cursor a
p.startPrompt(pane, .{ .search = config.search_marker });
}
// The answer is typed into the prompt's own line, never the tag.
- try std.testing.expect(!pane.tag_edit);
+ try std.testing.expect(pane.focus == .body);
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.expectEqualStrings(tail, pane.tag.own.?);
try std.testing.expectEqualDeep(modal.Normal.State{}, pane.body.normal);
p.update(.{ .key = .{ .cp = Key.escape } });
try std.testing.expect(pane.prompt == .none);
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.expectEqualStrings(tail, pane.tag.own.?);
try std.testing.expectEqual(@as(i32, 1), pane.body.cur_row);
try std.testing.expectEqual(@as(i32, 2), pane.body.cur_col);
try std.testing.expectEqualStrings("one\ntwo\n", pane.file.?.content);
@@ -1698,9 +1712,10 @@ 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;
- pane.tag_init = true;
+ const full = try p.gpa.alloc(u8, pardes.memory.limits.max_tag_tail);
+ defer p.gpa.free(full);
+ @memset(full, 'x');
+ try setTag(pane, full);
for (0..3) |case| {
switch (case) {
0 => p.startPrompt(pane, .{ .search = config.search_marker }),
@@ -1708,8 +1723,7 @@ test "a prompt opens whatever the tag already holds" {
else => p.update(.{ .key = .{ .cp = '|' } }),
}
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);
+ try std.testing.expectEqualStrings(full, pane.tag.own.?);
p.update(.{ .key = .{ .cp = Key.escape } });
}
}
@@ -1723,8 +1737,8 @@ test "generated output refuses every pipe prompt before seeding the tag" {
for ([_]Key{ .{ .cp = '|' }, .{ .cp = '|', .alt = true }, .{ .cp = '!' }, .{ .cp = '!', .alt = true } }) |key| {
p.update(.{ .key = key });
try std.testing.expect(pane.prompt == .none);
- try std.testing.expect(!pane.tag_init);
- try std.testing.expect(!pane.tag_edit);
+ try std.testing.expect(pane.tag.own == null);
+ try std.testing.expect(pane.focus == .body);
try std.testing.expectEqualStrings("generated\n", pane.file.?.content);
}
}
@@ -1798,7 +1812,7 @@ test "a prompt that cannot be opened leaves the open one as it was" {
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.expect(!pane.tag_init and !pane.tag_edit);
+ try std.testing.expect(pane.tag.own == null and pane.focus == .body);
}
}
@@ -1830,7 +1844,7 @@ test "a prompt opens with one allocation" {
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.input.mode == .insert);
+ try std.testing.expect(pane.focus == .body and pane.input.mode == .insert);
}
}
@@ -4080,14 +4094,18 @@ test "cancelled normal gestures leave no count or subprefix after mouse and focu
const rect = p.rects[0];
const tag_y = if (p.settings.tag_bottom) rect.y + rect.h - pardes.BOX_H else rect.y;
const body_y = p.bodyTop(rect);
- const x = rect.x + config.GUTTER + @as(u16, @intCast(panes.File.gutterWidth(pane))) + 3;
+ const x = if (gesture == .tag)
+ rect.x + pardes.TAG_TEXT_INSET + @as(u16, @intCast(panes.File.displayWidth(try p.tagPrefix(pane)))) + 3
+ else
+ rect.x + config.GUTTER + @as(u16, @intCast(panes.File.gutterWidth(pane))) + 3;
const y = if (gesture == .tag) tag_y else body_y;
p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = x, .row = y } });
p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = x, .row = y } });
if (gesture == .tag) {
- try std.testing.expect(pane.tag_edit);
+ try std.testing.expect(pane.focus == .tag);
p.update(.{ .key = .{ .cp = Key.escape } });
- try std.testing.expect(!pane.tag_edit);
+ p.update(.{ .key = .{ .cp = ':' } });
+ try std.testing.expect(pane.focus == .body);
}
},
.focus, .last => {
diff --git a/test/pdf.zig b/test/pdf.zig
index 5bc271d4..745ecb1a 100644
--- a/test/pdf.zig
+++ b/test/pdf.zig
@@ -309,8 +309,8 @@ test "PDF dump fallback remains a byte-preserving file" {
try std.testing.expect(!hasPdf(pane));
try std.testing.expectEqualStrings(path, pane.file.?.path);
try std.testing.expectEqualSlices(u8, source, pane.file.?.content);
- try std.testing.expect(pane.tag_init);
- try std.testing.expectEqualStrings(" Keep Del", pane.tag_tail[0..pane.tag_tail_len]);
+ try std.testing.expect(pane.tag.own != null);
+ try std.testing.expectEqualStrings(" Keep Del", pane.tag.own.?);
}
test "PdfSections Look follows the exact owning PDF, not an equal path" {
@@ -918,9 +918,8 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" {
try std.testing.expectEqual(PdfTintMode.full, pane.pdf.?.tint);
pane.pdf.?.fit = .height;
const custom_tail = " Keep Del";
- @memcpy(pane.tag_tail[0..custom_tail.len], custom_tail);
- pane.tag_tail_len = custom_tail.len;
- pane.tag_init = true;
+ if (pane.tag.own) |own| p.gpa.free(own);
+ pane.tag.own = try p.gpa.dupe(u8, custom_tail);
try pardes.dump.dumpState(p);
const restored = try pardes.dump.initFromDump(gpa, .{}, p.dump_out.?);
defer restored.deinit();
@@ -929,10 +928,10 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" {
try std.testing.expectEqual(PdfFitMode.width, restored.panes[0].?.pdf.?.fit);
try std.testing.expectEqual(PdfTintMode.filtered, restored.panes[0].?.pdf.?.tint);
const restored_pane = restored.panes[0].?;
- try std.testing.expect(restored_pane.tag_init);
+ try std.testing.expect(restored_pane.tag.own != null);
try std.testing.expectEqualStrings(
custom_tail,
- restored_pane.tag_tail[0..restored_pane.tag_tail_len],
+ restored_pane.tag.own.?,
);
}
@@ -1100,20 +1099,20 @@ test "PDF normal adapter consumes unsupported actions and navigates page fallbac
try std.testing.expectEqual(@as(u32, 0), pane.body.normal.count);
try std.testing.expectEqual(.none, pane.body.normal.prefix);
try std.testing.expect(!pane.body.vsel.active and !pane.body.msel.active and pane.body.nsel == 0);
- try std.testing.expect(!pane.tag_edit);
+ try std.testing.expect(pane.focus == .body);
// Cross-pane BODY-NORMAL actions keep their established shared paths.
p.update(.{ .key = .{ .cp = ':' } });
- try std.testing.expect(pane.tag_edit);
+ try std.testing.expect(pane.focus == .tag);
try std.testing.expectEqual(Mode.normal, pane.body.mode);
- p.update(.{ .key = .{ .cp = Key.escape } });
- try std.testing.expect(!pane.tag_edit);
+ p.update(.{ .key = .{ .cp = ':' } });
+ try std.testing.expect(pane.focus == .body);
p.update(.{ .key = .{ .cp = ' ' } });
try std.testing.expect(p.leader_on);
p.update(.{ .key = .{ .cp = '!' } });
try std.testing.expect(!p.leader_on);
p.update(.{ .key = .{ .cp = '/' } });
- try std.testing.expect(pane.prompt == .search and !pane.tag_edit);
+ try std.testing.expect(pane.prompt == .search and pane.focus == .body);
p.update(.{ .key = .{ .cp = Key.escape } });
try std.testing.expect(pane.prompt == .none);
}
diff --git a/test/snapshots/cmdword.golden b/test/snapshots/cmdword.golden
index e6f470fa..f099464f 100644
--- a/test/snapshots/cmdword.golden
+++ b/test/snapshots/cmdword.golden
@@ -16,9 +16,9 @@
== snap nested grid=120x31 cursor=4,24
|22: ls
|23: cmds.txt p.txt zzmark.txt
-== snap execword grid=120x31 cursor=69,17
+== snap execword grid=120x31 cursor=4,24
|17: /tmp/pardes-snap/cmdword/cwd Tty Save Mode Filter Collapse Del Exec
-== snap tagcmd grid=120x31 cursor=83,17
+== snap tagcmd grid=120x31 cursor=4,24
|17: /tmp/pardes-snap/cmdword/cwd Tty Save Mode Filter Collapse Del Exec @`echo ZZ''T`
|24: echo ZZ''T
|25: ZZT
diff --git a/test/snapshots/cmdword.snap b/test/snapshots/cmdword.snap
index eb0e0ea6..361e37ba 100644
--- a/test/snapshots/cmdword.snap
+++ b/test/snapshots/cmdword.snap
@@ -57,25 +57,31 @@ wait 10000 zzmark.txt
stable 700 15000
snap nested
# the same syntax typed into a TAG — the other place the expansion has to know
-# about backticks (tagChordText). The look above left the shell focused, so `:`
-# opens ITS tag; `i` then End walks to the end of the tail, and the chord runs
-# the word under the cursor exactly as the body's does.
+# about backticks. The look above left the shell focused, so `:` opens ITS
+# tag; `A` appends at the end of it, Esc and `h` put the normal-mode cursor on
+# the last character typed, and the execute key runs the word under it
+# exactly as the body's does.
key :
-key i
-key end
+key A
key space
text Exec
-key left
+key esc
+settle 100
+key h
key tab
stable 700 8000
# ...except that word is `Exec` ITSELF, with nothing to execute. This is the
# case that must not hang a re-entrant dispatcher: it runs out of argument
-# immediately and does nothing at all.
+# immediately and does nothing at all. Executing gave the keyboard back to the
+# body, and `:` finds the tag's cursor where it was left.
snap execword
-key end
+key :
+key A
key space
text @`echo ZZ''T`
-key left
+key esc
+settle 100
+key h
key tab
wait 10000 ZZT
stable 700 15000
diff --git a/test/snapshots/collapse.golden b/test/snapshots/collapse.golden
index 18d24d6b..eaf9bbc3 100644
--- a/test/snapshots/collapse.golden
+++ b/test/snapshots/collapse.golden
@@ -68,8 +68,8 @@
|3:
|4:
|5:
-== 9p tab-collapsed-cursor grid=160x25 cursor=51,2,bar
-== snap tab-restored grid=160x25 cursor=51,2
+== 9p tab-collapsed-cursor grid=160x25 cursor=none
+== snap tab-restored grid=160x25 cursor=7,3
|3: 1 KEEP-FIRST Collapse
|4: 2 KEEP-SECOND
|5: 3 KEEP-THIRD
diff --git a/test/snapshots/collapse.snap b/test/snapshots/collapse.snap
index 64f6bdb8..83872855 100644
--- a/test/snapshots/collapse.snap
+++ b/test/snapshots/collapse.snap
@@ -1,5 +1,6 @@
# A default pane-tag command collapses to one row and restores the body.
-# Exercise real middle clicks twice, then tag editing's Tab execution twice.
+# Exercise real middle clicks twice, then the tag's own execute key twice: a
+# click types into the tag, Esc is its normal mode, and Tab executes there.
file kept.txt KEEP-FIRST\nKEEP-SECOND\nKEEP-THIRD
start 25 160 kept.txt
wait 8000 KEEP-THIRD
@@ -19,12 +20,16 @@ stable 500 8000
snap middle-restored
press left @Collapse 3
release left @Collapse 3
+key esc
+settle 100
key tab
stable 500 8000
snap tab-collapsed
snap9p tab-collapsed-cursor
press left @Collapse 3
release left @Collapse 3
+key esc
+settle 100
key tab
wait 8000 KEEP-THIRD
stable 500 8000
diff --git a/test/snapshots/tag.snap b/test/snapshots/tag.snap
index a545ff9f..4faf6062 100644
--- a/test/snapshots/tag.snap
+++ b/test/snapshots/tag.snap
@@ -1,7 +1,8 @@
-# a CLICKED tag is insert mode: type straight in (no 'i'; `:` is the normal-
-# mode door, see tagcmd); backspace edits; Esc leaves back to the body; Enter
-# looks the word at the cursor (Tab executes it). Multi-word execution from
-# the tag stays the mouse's job (middle-drag), like anywhere else.
+# a CLICKED tag is typed straight into (no 'i'; `:` is the normal-mode door,
+# see tagcmd); backspace edits; Esc drops to the tag's normal mode, which is
+# the body's normal mode, and `:` there hands the keyboard back to the body;
+# Enter looks the word at the cursor (Tab executes it). Multi-word execution
+# from the tag stays the mouse's job (middle-drag), like anywhere else.
file t.txt ZZLOOKED
start 31 150 -n 3
wait 8000 t.txt
@@ -29,16 +30,21 @@ snap tag-del
send .txt
stable 600 8000
stable 400 5000
-# Esc: out of the tag, body back in normal mode, typed tail stays as text
+# Esc: the tag's normal mode; `:` then gives the keyboard back to the body,
+# which is in normal mode, and the typed text stays
key esc
settle 100
+key :
stable 400 5000
snap tag-exit
-# re-enter the tag and look the typed word: Enter opens t.txt as a file pane.
-# Click the inserted word itself; filename, separator and commands are compact.
+# re-enter the tag and look the typed word: Enter in normal mode opens t.txt
+# as a file pane. Click the inserted word itself; filename, separator and
+# commands are compact.
press left @t.txt 3
release left @t.txt 3
stable 400 5000
+key esc
+settle 100
key enter
wait 10000 ZZLOOKED
stable 700 15000
diff --git a/test/snapshots/tagalign.snap b/test/snapshots/tagalign.snap
index 9df22f57..a77415d9 100644
--- a/test/snapshots/tagalign.snap
+++ b/test/snapshots/tagalign.snap
@@ -22,9 +22,14 @@ press left 12 3
release left 12 3
stable 400 5000
snap clicked
-# End reveals the complete command suffix; a scrolled Del still executes.
+# The click was on the path, so it drafted a name; Esc drops the draft. `:`
+# then `A` puts the caret after the tag's end, which reveals the complete
+# command suffix; a scrolled Del still executes.
resize 31 70
-key end
+key esc
+settle 100
+key :
+key A
stable 400 5000
snap revealed
press middle @Del 3
diff --git a/test/snapshots/tagcmd.snap b/test/snapshots/tagcmd.snap
index 305ed521..0e9047fb 100644
--- a/test/snapshots/tagcmd.snap
+++ b/test/snapshots/tagcmd.snap
@@ -1,11 +1,12 @@
# `:` — vim's command line with acme's vocabulary. It focuses the pane's OWN
# tag in NORMAL mode (block cursor; the layout box shows the pane's BODY mode,
-# which for this file pane is normal either way), parked at the tail's
-# START — always the start, so `:w` is the same two keystrokes every time.
-# Motions walk the tag's words (w selects the "Save" span, exactly like a body
-# word motion), the execute chord runs the one under the cursor and hands the
-# body back in normal mode — same keys as the body, so Tab by default (see
-# config.look_key/exec_key). Esc backs out instead, leaving the tail as it was.
+# which for this file pane is normal either way). The tag remembers where its
+# cursor was; the first time there is nothing to remember and it starts on
+# Save. The tag's normal mode is the body's: w selects the "Save" span exactly
+# as a body word motion does, and the execute key runs the word under the
+# cursor and hands the body back — same keys as the body, so Tab by default
+# (see config.look_key/exec_key). `:` again backs out instead, leaving the tag
+# as it was.
lines c.txt 6
start 31 100 c.txt
wait 8000 Newcol Joincol
@@ -18,7 +19,7 @@ key esc
settle 100
stable 400 5000
snap edited
-# `:` -> the tag, in normal mode, cursor on the tail's first cell
+# `:` -> the tag, in normal mode, cursor on Save
key :
stable 400 5000
snap colon
@@ -32,10 +33,9 @@ stable 600 8000
stable 400 5000
snap saved
snapfile written /tmp/pardes-snap/tagcmd/cwd/c.txt
-# `:` then Esc: back to the body with the tail untouched, nothing run
+# `:` then `:`: back to the body with the tag untouched, nothing run
key :
stable 400 5000
-key esc
-settle 100
+key :
stable 400 5000
snap escaped
diff --git a/test/snapshots/tagnav.golden b/test/snapshots/tagnav.golden
index 52915940..3fdba9f2 100644
--- a/test/snapshots/tagnav.golden
+++ b/test/snapshots/tagnav.golden
@@ -22,39 +22,20 @@
== snap colon grid=100x31 cursor=28,2
|2: es-snap/tagnav/cwd/w.txt Save Tty Collapse Del /tmp/pardes-snap/tagnav/cwd Tty Save Mode Filte
|18: ls
-== snap right-tag grid=100x31 cursor=29,2
-== snap left-tag grid=100x31 cursor=28,2
-== snap down-tag grid=100x31 cursor=7,3
+== snap word grid=100x31 cursor=32,2
+== snap in-tag grid=100x31 cursor=34,2
+== snap right grid=100x31 cursor=54,5
|2: /tmp/pardes-snap/tagnav/cwd/w.txt Save Tty Coll /tmp/pardes-snap/tagnav/cwd Tty Save Mode Filte
-== snap up-tag grid=100x31 cursor=7,3
-== snap column-tag grid=100x31 cursor=7,3
-== snap up-edge grid=100x31 cursor=7,3
-== snap topbar-word grid=100x31 cursor=11,3
-== snap topbar-graphemes grid=100x31 cursor=7,4
-== snap column-down grid=100x31 cursor=7,5
-== snap back-down grid=100x31 cursor=7,5
-== snap arrows-still-motion grid=100x31 cursor=9,5
-== snap back-to-body grid=100x31 cursor=4,20
-|3: 1 alpha Last ls
+|3: 1 alpha Right ls
+== snap right-tag grid=100x31 cursor=70,2
+|2: /tmp/pardes-snap/tagnav/cwd/w.txt Save Tty Coll p/tagnav/cwd Tty Save Mode Filter Collapse Del
+|3: 1 alpha ls
+== snap left-tag grid=100x31 cursor=43,2
+|2: /tmp/pardes-snap/tagnav/cwd/w.txt Save Tty Coll /tmp/pardes-snap/tagnav/cwd Tty Save Mode Filte
+== snap back-to-body grid=100x31 cursor=7,3
== snap tty-on grid=100x31 cursor=54,5
|2: /tmp/pardes-snap/tagnav/cwd/w.txt Save Tty Coll$ /tmp/pardes-snap/tagnav/cwd Tty Save Mode Filte
|3: 1 alpha $ ls
|5: 3 gamma $
-== snap tty-tag grid=100x31 cursor=57,5
-|5: 3 gamma $ h:l
-== snap tty-restored grid=100x31 cursor=4,20
-|3: 1 alpha $ ls Last
-== snap topbar-dollar grid=100x31 cursor=48,17
-|3: 1 alpha $ ls
-|17: p/tagnav/cwd Tty Save Mode Filter Collapse Del
-== snap topbar-dump-word grid=100x31 cursor=25,17
-== snap topbar-exec grid=100x31 cursor=4,20
-|17: /tmp/pardes-snap/tagnav/cwd Tty Save Mode Filte
-|29: /tmp/pardes-snap/tagnav/cwd/+Search Save Tty Co
-|30: 1
-== snap topbar-restore-word grid=100x31 cursor=49,17
-|17: p/pardes-snap/tagnav/cwd Tty Save Mode Filter C
-== snap topbar-restored grid=100x31 cursor=4,20
-|17: /tmp/pardes-snap/tagnav/cwd Tty Save Mode Filte
-|27: /tmp/pardes-snap/tagnav/cwd/+Search Save Tty Co
-|28: 1
+== snap tty-tag grid=100x31 cursor=90,2
+== snap tty-restored grid=100x31 cursor=54,5
diff --git a/test/snapshots/tagnav.snap b/test/snapshots/tagnav.snap
index c375e644..eed808de 100644
--- a/test/snapshots/tagnav.snap
+++ b/test/snapshots/tagnav.snap
@@ -1,25 +1,22 @@
-# h/j/k/l ON A TAGLINE move focus BETWEEN taglines. A tag is a place in the
-# LAYOUT, not just a line of text, so the four letters walk the layout and land
-# on the neighbour's TAG (top row of the window), still in normal mode — you
-# walk the taglines and never drop through a body. They run the SAME
-# Left/Down/Up/Right builtins `SPC w h/j/k/l` and `Ctrl-w h/j/k/l` run: one
-# focusDir, three routes to it. The ARROWS keep the in-tag grapheme motion, so
-# no capability was traded away.
+# A tag's normal mode is the body's: h/l/w move in the tag's own text, and
+# moving between panes is the window keys' job from a tag as from a body --
+# there are no tag-only hops. `:` is the one key a tag and a body do not
+# share: from the body it focuses the pane's own tag, from the tag it hands the
+# keyboard back to the body. Each tag remembers its cursor; the first time
+# there is nothing to remember and it starts on Save.
#
# Evidence is the `cursor=` header (the ACTIVE pane's cursor, which while a tag
# is focused sits on THAT pane's tag row) plus each pane's layout box, which
-# carries its BODY mode as one character: `•` normal, `^` insert, `$` tty. A
-# tag edit parks the body mode rather than overwriting it, so the box keeps
-# saying what the pane we left will be when we hop back off its tagline.
+# carries its BODY mode as one character: ` ` normal, `^` insert, `$` tty. The
+# tag has a mode of its own, so the box never changes while a tag is used.
file w.txt alpha\nbeta\ngamma
start 31 100 w.txt
wait 8000 Newcol Joincol
wait 8000 w.txt
stable 700 20000
-# A FILE boots alone now, so the shell this script calls "the launch shell" is
-# opened here: Alt-n opens it and focuses it, Alt-c puts it in a column of its
-# own on the right, SPC w h comes back to the file. (`New`/`Newcol` open an
-# empty +New scratch now, not a shell.)
+# A FILE boots alone, so the launch shell is opened here: Alt-n opens it and
+# focuses it, Alt-c puts it in a column of its own on the right, SPC w h comes
+# back to the file.
key a-n
key a-c
stable 700 15000
@@ -32,110 +29,51 @@ stable 700 15000
key space w k
stable 400 5000
snap start
-# `:` -> the file's OWN tagline, normal mode, parked at the tail's start
+# `:` -> the file's OWN tagline, normal mode, on Save
key :
stable 400 5000
snap colon
-# l -> right, onto the launch shell's TAGLINE (not its body)
-key l
-stable 400 5000
-snap right-tag
-# h -> back left onto the file's tagline
-key h
-stable 400 5000
-snap left-tag
-# j -> down onto the tagline of the shell under the file
-key j
-stable 400 5000
-snap down-tag
-# k -> back up onto the file's
-key k
-stable 400 5000
-snap up-tag
-# k visits the column tag first, then the workspace tag. Both stay in normal
-# navigation mode; neither hop drops into a pane body.
-key k
-stable 400 5000
-snap column-tag
-key k
-stable 400 5000
-snap up-edge
-# the bar speaks the tag's own one-line vocabulary: w to the next word start
+# w selects in the tag the way it selects in a body
key w
stable 400 5000
-snap topbar-word
-# h/l are plain graphemes up here — row 0 has no window to its left or right to
-# walk to — and the arrows still mean what they always did
-key l right
+snap word
+# l is a motion in the tag, not a hop to the next pane
+key l l
stable 400 5000
-snap topbar-graphemes
-# j is the mirror of the k that got you here: back DOWN onto the topmost pane's
-# tagline, still in normal mode, so the hop is a round trip
-key j
+snap in-tag
+# the window keys move between panes from a tag as from a body: the tag lets
+# go and the neighbour's BODY takes the keyboard
+key c-w l
+stable 400 5000
+snap right
+# `:` there -> that pane's own tag, on its Save the first time
+key :
stable 400 5000
-snap column-down
-key j
+snap right-tag
+# back left, and `:` finds the file's tag cursor where l left it
+key c-w h
stable 400 5000
-snap back-down
-# the arrows are still in-tag motion: two rights walk two columns of the tail
-key right right
+key :
stable 400 5000
-snap arrows-still-motion
-key esc
-settle 100
+snap left-tag
+# `:` in the tag's normal mode: back to the body
+key :
stable 400 5000
snap back-to-body
-# A TERMINAL MUST NOT LOSE ITS TTY MODE. Put the launch shell in raw tty mode
-# (Ctrl-b), come back, then hop onto its tagline and off again: exitTagEdit
-# restores the body mode the tag hijacked, and its box reads `$` throughout —
-# during the hop too, because the box shows the PARKED mode, not the tag's.
+# A TERMINAL KEEPS ITS TTY MODE. Put the launch shell in raw tty mode (Ctrl-b),
+# click its tagline and come back: its box reads `$` throughout, because the
+# tag's mode is the tag's own.
key c-w l
stable 400 5000
key c-b
stable 700 15000
snap tty-on
-key c-w h
-stable 400 5000
-key :
-key l
+press left @Mode 3
+release left @Mode 3
stable 400 5000
snap tty-tag
key esc
settle 100
-stable 400 5000
-snap tty-restored
-# Enter/Tab on the BAR runs the word under the cursor through the very dispatch
-# a middle click on it uses, and leaves the bar FIRST — `Kill` lives up here, so
-# a builtin that frees the session must never return into a focused topbar (the
-# same hazard the pane-tag chord has with `Del`). `$` proves the destructive
-# word is exactly last; four long-word steps back land on `Dump`, the visible
-# non-destructive action used here. It grows a `Restore <path>` word, which is
-# also the case where the line gets longer than the column you were parked on.
-key c-w h
-stable 400 5000
-key :
-key k k
-stable 400 5000
-key $
-stable 400 5000
-snap topbar-dollar
-key B B B B
-stable 400 5000
-snap topbar-dump-word
-key enter
-stable 700 15000
-snap topbar-exec
-# THE DELETION HAZARD, from the bar itself: `Restore` frees every pane and
-# swaps the core out from under the caller. Walk back onto the word the Dump
-# just grew — B twice off the tail, because the LONG word motions are the ones
-# that step over a path in one go — and run it. The bar drops focus BEFORE the
-# builtin, so nothing reaches for state the swap already freed.
key :
-key k k
stable 400 5000
-key $ B B
-stable 400 5000
-snap topbar-restore-word
-key enter
-stable 900 15000
-snap topbar-restored
+snap tty-restored
diff --git a/test/snapshots/tagpath.golden b/test/snapshots/tagpath.golden
index 1b798445..2ba1a2e6 100644
--- a/test/snapshots/tagpath.golden
+++ b/test/snapshots/tagpath.golden
@@ -8,12 +8,12 @@
| 4 line 4
| 5
== snap colon grid=100x31 cursor=38,2
-== snap col0 grid=100x31 cursor=3,2
-== snap sel-path grid=100x31 cursor=36,2
-== style sel-path grid=100x31
+== snap col0 grid=100x31 cursor=37,2
+== snap drag-cross grid=100x31 cursor=41,2
+== style drag-cross grid=100x31
|0: 0-70 #b2c3b8,#1d2824, 71-99 d,#1d2824,
|1: 0-1 #ffffff,#887455, 2-2 d,#26362d, 3-34 #becfc4,#26362d, 35-99 d,#26362d,
-|2: 0-1 #000000,#add38a, 2-2 d,#26362d, 3-36 #f5f7ee,#3c614a, 37-58 #becfc4,#26362d, 59-99 d,#26362d,
+|2: 0-1 #000000,#add38a, 2-2 d,#26362d, 3-10 #becfc4,#26362d, 11-41 #f5f7ee,#3c614a, 42-58 #becfc4,#26362d, 59-99 d,#26362d,
|3: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-12 #e5eee5,#0d1410, 13-99 d,#0d1410,
|4: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-12 #e5eee5,#0d1410, 13-99 d,#0d1410,
|5: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-12 #e5eee5,#0d1410, 13-99 d,#0d1410,
@@ -42,16 +42,9 @@
|28: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-99 d,#0d1410,
|29: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-99 d,#0d1410,
|30: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-99 d,#0d1410,
-== snap yanked-path grid=100x31 cursor=41,3
-|2: /tmp/pardes-snap/tagpath/cwd/c.txt * Save Tty Collapse Del
-|3: 1 l/tmp/pardes-snap/tagpath/cwd/c.txtine 1
-== snap drag-cross grid=100x31 cursor=41,2
-== style drag-cross grid=100x31
-|2: 0-1 #000000,#add38a, 2-2 d,#26362d, 3-10 #becfc4,#26362d, 11-41 #f5f7ee,#3c614a, 42-60 #becfc4,#26362d, 61-99 d,#26362d,
-|3: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-46 #e5eee5,#0d1410, 47-99 d,#0d1410,
== snap name-draft grid=100x31 cursor=11,2
-|2: /renamed/tmp/pardes-snap/tagpath/cwd/c.txt * Save Tty Collapse Del
-== snap name-cancelled grid=100x31 cursor=41,3
-|2: /tmp/pardes-snap/tagpath/cwd/c.txt * Save Tty Collapse Del
-== snap name-committed grid=100x31 cursor=41,3
+|2: /renamed/tmp/pardes-snap/tagpath/cwd/c.txt Save Tty Collapse Del
+== snap name-cancelled grid=100x31 cursor=7,3
+|2: /tmp/pardes-snap/tagpath/cwd/c.txt Save Tty Collapse Del
+== snap name-committed grid=100x31 cursor=7,3
|2: /renamed/tmp/pardes-snap/tagpath/cwd/c.txt * Save Tty Collapse Del
diff --git a/test/snapshots/tagpath.snap b/test/snapshots/tagpath.snap
index cf431fa1..b9ef80e6 100644
--- a/test/snapshots/tagpath.snap
+++ b/test/snapshots/tagpath.snap
@@ -1,5 +1,8 @@
-# Filename drafts share the selectable tag coordinate space with commands.
-# Escape cancels; Enter changes buffer identity, never renames a disk file.
+# The path is part of the tag's text for the mouse -- a sweep selects across
+# the path and the commands after it -- but never for the keyboard: the tag's
+# cursor lives in its own text, so `0` goes to the start of that, not of the
+# path. A click on the path drafts a new name there instead. Escape cancels;
+# Enter changes buffer identity, never renames a disk file.
lines c.txt 4
start 31 100 c.txt
wait 8000 Newcol Joincol
@@ -12,16 +15,6 @@ snap colon
key 0
stable 400 5000
snap col0
-key E
-stable 400 5000
-snap sel-path
-snapstyle sel-path
-key y
-key esc
-settle 100
-key p
-stable 400 5000
-snap yanked-path
# Sweep across the path / command boundary without injected padding.
press left 12 3
drag left 30 3
@@ -33,17 +26,18 @@ snapstyle drag-cross
key esc
settle 100
key :
-key 0
-key i
+stable 400 5000
+# a click on the path is a name draft, the caret where it was clicked
+press left 4 3
+release left 4 3
text /renamed
stable 400 5000
snap name-draft
key esc
stable 400 5000
snap name-cancelled
-key :
-key 0
-key i
+press left 4 3
+release left 4 3
text /renamed
key enter
stable 400 5000
diff --git a/test/snapshots/ttyclick.golden b/test/snapshots/ttyclick.golden
index 719e3955..14344371 100644
--- a/test/snapshots/ttyclick.golden
+++ b/test/snapshots/ttyclick.golden
@@ -20,11 +20,7 @@
| ls
| probe.txt
== snap tty-tagedit grid=100x31 cursor=33,2
-|3: ls ls
-|5:
== snap tty-tagexit grid=100x31 cursor=4,5
-|3: $ ls ls
-|5: $
== snap ins-bodyclick grid=100x31 cursor=11,4
|2: ^ /tmp/pardes-snap/ttyclick/cwd Tty Save Mode Fil /tmp/pardes-snap/ttyclick/cwd Tty Save Mode Fil
|3: ls ls
diff --git a/test/snapshots/ttyclick.snap b/test/snapshots/ttyclick.snap
index 89ed5300..c125a231 100644
--- a/test/snapshots/ttyclick.snap
+++ b/test/snapshots/ttyclick.snap
@@ -1,7 +1,7 @@
# clicks in a shell pane never change its mode: a body click in raw tty
-# keeps tty (before: the press dropped it to normal), a tag-edit round trip
-# restores the mode it hijacked, and an insert-mode body click re-pins the
-# cursor but stays insert. File panes keep the old behavior (click -> normal).
+# keeps tty (before: the press dropped it to normal), a round trip through the
+# tag leaves it alone (the tag has a mode of its own), and an insert-mode body
+# click re-pins the cursor but stays insert. File panes keep the old behavior (click -> normal).
# The mode is the character in each pane's layout box: `•` normal, `^` insert,
# `$` tty.
file probe.txt marker
@@ -15,14 +15,16 @@ press left 10 5
release left 10 5
stable 400 5000
snap tty-bodyclick
-# tag edit from tty: the box still reads `$` while the tag is being edited (the
-# body mode is PARKED, which is the invariant), and Esc hands it back — a bug
-# that lost it would show `•` in one snap or the other
+# tag edit from tty: the box still reads `$` while the tag is typed into (the
+# tag's mode is its own), and Esc then `:` hands the keyboard back — a bug
+# that lost the body's mode would show `•` in one snap or the other
press left @Tty 3
release left @Tty 3
stable 400 5000
snap tty-tagedit
key esc
+settle 100
+key :
stable 400 5000
snap tty-tagexit
# out of tty, into insert: a body click moves the cursor but keeps INS