summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 09:16:36 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commita9de2b51ab76a43250a4d6c41a0ddd54e6970049 (patch)
treea2138ead27f434026c224fd8b926f66b7fd7fe0c
parent0d04bc9214cccce42d74862b31376d49bdda3956 (diff)
downloadpardes-a9de2b51ab76a43250a4d6c41a0ddd54e6970049.tar.gz
pardes-a9de2b51ab76a43250a4d6c41a0ddd54e6970049.zip
Let the keyboard reach a tag's path, read-only
The tag's computed path, dirty marker and page were out of the keyboard's reach: its cursor lived in the text after them. It now lives in the tag as shown, as in acme where the name is ordinary text at the start of the tag, so `0` goes to the line's start and motions select and yank the path. The path stays read-only: an edit that would change it is refused and leaves the cursor where it was, and typing into a file's path drafts a new name as a click on it does. Sync moves the tag's positions when the prefix changes length, so a rename or the dirty marker still never moves the cursor off the text after it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--docs/design.typ15
-rw-r--r--docs/helix-keys.md2
-rw-r--r--docs/tags.md15
-rw-r--r--src/Text.zig4
-rw-r--r--src/body_layer.zig16
-rw-r--r--src/edit.zig30
-rw-r--r--src/mouse.zig21
-rw-r--r--src/ninep/pane.zig7
-rw-r--r--src/panes.zig5
-rw-r--r--src/pardes.zig35
-rw-r--r--src/tagline.zig90
-rw-r--r--test/panes.zig75
-rw-r--r--test/snapshots/tagpath.golden23
-rw-r--r--test/snapshots/tagpath.snap33
14 files changed, 263 insertions, 108 deletions
diff --git a/docs/design.typ b/docs/design.typ
index 8e5f7e6e..234c3692 100644
--- a/docs/design.typ
+++ b/docs/design.typ
@@ -790,12 +790,15 @@ A tag holds any number of lines; a pane's tag takes a row per line up to
A pane's tag shows a live prefix (path, dirty marker, PDF page) before the text
it owns, and that prefix is computed at every read, never stored
-(`tagline.tagPrefix`). The Text's coordinates are offsets into its own text
-alone, so a rename or a save never moves its cursor. The one translation
-point is the whole line as shown: render, the mouse, Look, Exec and the 9P
-`tag` file see prefix ++ text, while keyboard motions and edits see only the
-text. Editing the path is a separate draft (`Pane.prompt = .name`) committed
-by Enter, since a buffer's name is not text it owns.
+(`tagline.tagPrefix`). Everything sees the whole line as shown, prefix ++
+text: render, the mouse, Look, Exec, the 9P `tag` file and the keyboard,
+whose motions reach the path as acme's do. The prefix is read-only: an edit
+installs only what follows it, and one that would change it is refused
+(`edit.setEditText`, `Text.refused`). Because the prefix is computed, sync
+moves the tag's positions with it when its length changes (`Pane.tag_lead`).
+Editing the path is a separate draft (`Pane.prompt = .name`) committed by
+Enter, since a buffer's name is not text it owns; a click or a key typed
+into it starts one.
The tag's text is bounded by `limits.max_tag_tail`: the 9P `tag` file and a
dump reader refuse input that does not fit rather than truncating it.
diff --git a/docs/helix-keys.md b/docs/helix-keys.md
index 5edaccce..f46f5234 100644
--- a/docs/helix-keys.md
+++ b/docs/helix-keys.md
@@ -117,7 +117,7 @@ language-backend queries, and the shell pipe.
| `Esc` (insert) | back to normal mode, cursor right after the insertion (no vim left-step) | | helix-verified |
| `Enter` (normal) | acme **look** chord: EXPLICIT selection, else file-ish word under cursor | pardes-specific, keep (helix normal-mode Enter unbound). Covers helix `gf`. Implicit motion residue falls back to the cursor word | pardes-specific |
| `Tab` (normal) | acme **execute** chord | pardes-specific, keep; explicit-selection rule as Enter | pardes-specific |
-| `:` (normal, body or tag) | in the body, focuses the pane's tag in normal mode at its remembered cursor (the first time, on `Save`); in the tag, goes back to the body; in a column or workspace tag, back to the active pane. The tag's normal and insert modes ARE the body's: every motion, selection, edit and undo works there, `0` meaning the start of the editable text. Tab runs the word under the cursor or the selection and Enter looks it up (in a column or workspace tag Enter runs it too), and either hands the keyboard back to the body first. Clicks choose a new cursor position and type into the tag. | Each tag keeps its own cursor during the session. Cursor, selections and undo are offsets in the tag's own text; the computed path/marker/page before it is seen by the mouse, Look and Exec but not by keyboard motions. File-name changes are staged as described in [editable tags](tags.md). | pardes-specific |
+| `:` (normal, body or tag) | in the body, focuses the pane's tag in normal mode at its remembered cursor (the first time, on `Save`); in the tag, goes back to the body; in a column or workspace tag, back to the active pane. The tag's normal and insert modes ARE the body's: every motion, selection, edit and undo works there, and `0` goes to the line's start, the path's. Tab runs the word under the cursor or the selection and Enter looks it up (in a column or workspace tag Enter runs it too), and either hands the keyboard back to the body first. Clicks choose a new cursor position and type into the tag. | Each tag keeps its own cursor during the session. The computed path/marker/page is reachable and yankable but read-only: an edit into it is refused, and typing into a file's path drafts a new name. File-name changes are staged as described in [editable tags](tags.md). | pardes-specific |
| `Ctrl-w k` / `SPC w k` (pane with nothing above) | focuses its column's tag, then the workspace tag; with `ColumnTags` disabled it goes directly to the workspace. `Ctrl-w j` walks back to the panes, `Ctrl-w h`/`l` walk the column tags. Headers edit exactly as a pane tag does. | Column commands target that column's active pane, or its first pane when coming from elsewhere. Workspace and column text are independently editable and persist in dumps. See [editable tags](tags.md). | pardes-specific |
| `Ctrl-w` + `h/j/k/l`/arrows | directional pane focus prefix — editor normal mode only | pardes' own window handling (helix window mode skipped, section C). Runs the SAME `Left`/`Down`/`Up`/`Right` builtins `SPC w h/j/k/l` runs; Raw **tty** mode forwards Ctrl-w to the child. Insert mode owns `Ctrl-w` = delete-word-back, so a tag being TYPED into swallows it; from a tag in normal mode it moves focus to the neighbour's BODY, as from a body, and from the top pane `Up` reaches the column and workspace tags | pardes-specific |
| `Alt-n` | new terminal below (outside raw tty) | shadows helix `Alt-n` TS sibling-select — skipped anyway (tree-sitter) | pardes-specific |
diff --git a/docs/tags.md b/docs/tags.md
index 7c385c08..810ae010 100644
--- a/docs/tags.md
+++ b/docs/tags.md
@@ -51,11 +51,13 @@ a row per line, up to eight: a pane's tag leaves its body at least one row
and a collapsed pane shows only the first line; the column and workspace
tags take no more than a third of the screen, and the panes below move down
to make room. The path, the dirty marker and a PDF's page at the start of a
-pane tag are computed, never stored: the tag's cursor, selections and undo
-live in the text after them, so a rename or a save never moves the cursor.
-The mouse still sees the whole line -- a sweep selects across the path and
-the commands, and Look and Exec work on either -- but the keyboard moves only
-in the tag's own text: `0` goes to its start, not the path's.
+pane tag are computed, never stored, and read-only. The keyboard reaches
+them as the mouse does: `0` goes to the line's start, the path's, as in
+acme, and motions select and yank across the path and the commands. An edit
+that would change them is refused and leaves the cursor where it was; typing
+into a file's path instead drafts a new name, as clicking it does. When they
+grow or shrink (a rename, the dirty marker, a PDF's page) the cursor keeps
+its place in the text after them.
Left-click a tag or a header to type into it at the click, in insert mode;
tags reveal the caret horizontally when text is wider than their column.
@@ -93,7 +95,8 @@ generated right-alignment padding. Intentionally customized spacing is kept.
## File names
-Clicking a file pane's name starts a draft of it, typed into at the click.
+Clicking a file pane's name, or typing into it from the tag, starts a draft
+of it, typed into where it was clicked or typed.
Enter or Tab confirms the new buffer name; Escape or leaving the pane
cancels the draft. Confirmation changes the
buffer's save target and marks it unsaved. It does **not** rename, create or
diff --git a/src/Text.zig b/src/Text.zig
index 61f238ae..43dd8a95 100644
--- a/src/Text.zig
+++ b/src/Text.zig
@@ -74,6 +74,10 @@ ed_undo: [Terminal.history_max]Terminal.Snapshot = undefined,
ed_undo_len: usize = 0,
ed_redo: [Terminal.history_max]Terminal.Snapshot = undefined,
ed_redo_len: usize = 0,
+/// Set when an edit would have changed characters this text does not own
+/// (a tag's computed prefix) and was refused; the key that tried it puts
+/// the cursor back.
+refused: bool = false,
/// The window this text belongs to (acme's t->w). A column's or the
/// workspace's tag belongs to none, and the optional makes every caller
diff --git a/src/body_layer.zig b/src/body_layer.zig
index ff1f903c..f9e26b10 100644
--- a/src/body_layer.zig
+++ b/src/body_layer.zig
@@ -925,8 +925,7 @@ pub fn paintPaneTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect,
.{ .row = 0, .at = @min(lines[0].len, @as(usize, @intCast(@max(0, pane.input.cur_col)))) }
else if (pane.focus == .tag and pane.prompt == .none and pane.tag.cur_row >= top and pane.tag.cur_row < top + rows) caret: {
const row: usize = @intCast(pane.tag.cur_row - top);
- const lead: usize = if (row + top == 0) prefix_len else 0;
- break :caret .{ .row = row, .at = @min(lines[row].len, lead + @as(usize, @intCast(@max(0, pane.tag.cur_col)))) };
+ break :caret .{ .row = row, .at = @min(lines[row].len, @as(usize, @intCast(@max(0, pane.tag.cur_col)))) };
} else null;
if (caret) |at| {
const line = lines[at.row];
@@ -968,19 +967,18 @@ pub fn paintPaneTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect,
// time. 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 got = pane.tag.ranges(whole, 0, &ranges);
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) continue;
- const first = modal.positionAt(tail, lo);
- const last = modal.positionAt(tail, hi);
+ if (modal.nextGrapheme(whole, lo) >= hi) continue;
+ const first = modal.positionAt(whole, lo);
+ const last = modal.positionAt(whole, hi);
var row = @max(first.row, top);
while (row <= last.row and row < top + rows) : (row += 1) {
const line = lines[row - top];
- const lead: usize = if (row == 0) prefix_len else 0;
- const from = lead + if (row == first.row) first.col else 0;
- const to = @min(line.len, lead + if (row == last.row) last.col else modal.lineSlice(tail, row).len);
+ const from = if (row == first.row) first.col else 0;
+ const to = @min(line.len, if (row == last.row) last.col else line.len);
var col = panes.File.rawDisplayCol(line, from) -| pane.tag_scroll;
const end = panes.File.rawDisplayCol(line, to);
const y = tag_y + @as(u16, @intCast(row - top));
diff --git a/src/edit.zig b/src/edit.zig
index e21352e1..b2d71ed2 100644
--- a/src/edit.zig
+++ b/src/edit.zig
@@ -112,8 +112,12 @@ pub fn applyPaste(p: *Pardes, bytes: []const u8) void {
/// into, after the cursor's cell in normal mode, as a body's paste is.
fn pasteInto(p: *Pardes, t: *Text, bytes: []const u8) void {
if (bytes.len == 0) return;
- if (t.mode == .insert) return insertKey(p, t, .{ .cp = bytes[0], .text = bytes });
- pasteText(p, t, bytes, false, 1);
+ const typed: pardes.Key = .{ .cp = bytes[0], .text = bytes };
+ if (t.what == .tag and t.mode == .insert) if (t.pane()) |pane| if (tagline.typedIntoName(p, pane, typed)) return;
+ const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel };
+ t.refused = false;
+ if (t.mode == .insert) insertKey(p, t, typed) else pasteText(p, t, bytes, false, 1);
+ if (t.refused) t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel = kept;
}
pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void {
@@ -358,7 +362,8 @@ pub fn pinPaneCursor(p: *Pardes, t: *Text) void {
pub fn flatSurface(p: *Pardes, t: *Text) ![]const u8 {
switch (t.what) {
.body => {},
- .tag => return if (t.pane()) |pane| tagline.curTail(pane) else "",
+ // the tag as it is shown: the keyboard reaches its prefix too
+ .tag => return if (t.pane()) |pane| try tagline.tagText(p, p.scratch.allocator(), pane) else "",
.input => return t.own orelse "",
.column, .workspace => return t.own orelse tagline.headerDefault(p, t.what),
}
@@ -384,6 +389,10 @@ pub fn paneWrapWidth(p: *const Pardes, t: *Text) usize {
fn editText(p: *Pardes, t: *Text, lo: i32, hi: i32, col: i32) ?panes.EditText {
// A tag or header nobody has changed shows its default; the first edit
// makes that default its own text.
+ if (t.what == .tag) {
+ const pane = t.pane() orelse return null;
+ return .{ .text = tagline.tagText(p, p.scratch.allocator(), pane) catch return null, .row0 = 0 };
+ }
if (t.what != .body and t.what != .input and t.own == null)
t.own = p.gpa.dupe(u8, flatSurface(p, t) catch return null) catch return null;
if (t.what != .body) return .{ .text = t.own orelse &.{}, .row0 = 0 };
@@ -403,6 +412,21 @@ fn editTextEol(p: *Pardes, t: *Text, b: Bounds) ?panes.EditText {
/// install a rewritten editable text (frees the old one)
pub fn setEditText(p: *Pardes, t: *Text, new: []u8) void {
+ if (t.what == .tag) {
+ // A tag is edited as it is shown, but only what follows its
+ // prefix is its own: an edit that changed the prefix is refused.
+ defer p.gpa.free(new);
+ const pane = t.pane() orelse return;
+ const prefix = tagline.pathPrefix(p.scratch.allocator(), pane) catch return;
+ if (!std.mem.startsWith(u8, new, prefix)) {
+ t.refused = true;
+ return;
+ }
+ const tail = p.gpa.dupe(u8, new[prefix.len..]) catch return;
+ if (t.own) |old| p.gpa.free(old);
+ t.own = tail;
+ return;
+ }
if (t.what != .body) {
if (t.own) |old| p.gpa.free(old);
t.own = new;
diff --git a/src/mouse.zig b/src/mouse.zig
index 69feea29..beb2e624 100644
--- a/src/mouse.zig
+++ b/src/mouse.zig
@@ -827,11 +827,14 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void {
if (sl.r0 < pane.tag_rows) {
// 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 = tagline.tagOffsetAt(p, pane, @intCast(@max(0, sl.r1)), @intCast(@max(0, sl.c1)));
- if (at) |cursor| {
+ const row: usize = @intCast(@max(0, sl.r1));
+ const col: usize = @intCast(@max(0, sl.c1));
+ if (!dragged and tagline.onName(pane, row, col)) {
+ tagline.draftName(p, pane, col);
+ } else if (tagline.tagOffsetAt(p, pane, row, col)) |cursor| {
pane.tag.cur_row = @intCast(cursor.row);
pane.tag.cur_col = @intCast(cursor.col);
- } else if (!dragged) tagline.draftName(p, pane, @intCast(@max(0, sl.c1)));
+ }
return;
}
if (dragged) edit.capturePointerSelection(p, pane, sel_slot) catch {};
@@ -932,7 +935,8 @@ fn chordCutPaste(p: *Pardes, cut: bool) void {
};
if (@min(pane.sel[sel_slot].r0, pane.sel[sel_slot].r1) < pane.tag_rows) {
// 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.
+ // the part of it over the prefix is not the tag's to cut, so the
+ // sweep starts after it.
const t = &pane.tag;
if (!s.chorded) {
s.chorded = true;
@@ -941,9 +945,12 @@ fn chordCutPaste(p: *Pardes, cut: bool) void {
const forward = sl.r0 < sl.r1 or (sl.r0 == sl.r1 and sl.c0 <= sl.c1);
const start = if (forward) .{ sl.r0, sl.c0 } else .{ sl.r1, sl.c1 };
const end = if (forward) .{ sl.r1, sl.c1 } else .{ sl.r0, sl.c0 };
- const origin: modal.Cursor = .{ .row = 0, .col = 0 };
- const from = tagline.tagOffsetAt(p, pane, @intCast(@max(0, start[0])), @intCast(@max(0, start[1]))) orelse origin;
- const to = tagline.tagOffsetAt(p, pane, @intCast(@max(0, end[0])), @intCast(@max(0, end[1]))) orelse origin;
+ const lead = pane.tag_lead;
+ const origin: modal.Cursor = .{ .row = 0, .col = lead };
+ var from = tagline.tagOffsetAt(p, pane, @intCast(@max(0, start[0])), @intCast(@max(0, start[1]))) orelse origin;
+ var to = tagline.tagOffsetAt(p, pane, @intCast(@max(0, end[0])), @intCast(@max(0, end[1]))) orelse origin;
+ if (from.row == 0) from.col = @max(from.col, lead);
+ if (to.row == 0) to.col = @max(to.col, lead);
t.cur_row = @intCast(to.row);
t.cur_col = @intCast(to.col);
t.cur_pinned = true;
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index 80dd97cd..64355e0e 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -613,8 +613,9 @@ pub fn truncate(p: *Pardes, pane: *Pane, file: PaneFile) tree.Status {
pane.tag.remember(p.gpa, if (pane.tag.own) |own| .{ .text = own } else null);
if (pane.tag.own) |own| p.gpa.free(own);
pane.tag.own = empty;
+ // what is left is the prefix; the cursor goes after it
pane.tag.cur_row = 0;
- pane.tag.cur_col = 0;
+ pane.tag.cur_col = if (tagline.pathPrefix(p.scratch.allocator(), pane)) |prefix| @intCast(prefix.len) else |_| 0;
pane.tag.vsel.active = false;
pane.tag.nsel = 0;
},
@@ -910,9 +911,9 @@ test "truncating the tag clears its editable tail" {
try testing.expectEqualStrings("/test.txt Get", whole);
const word = config.wordBounds(whole, whole.len - "Get".len);
try testing.expectEqualStrings("Get", whole[word.lo..word.hi]);
- // and the keyboard's first column of the text is the G
+ // and a click on the G puts the tag's cursor on it
const at = tagline.tagOffsetAt(p, pane, 0, "/test.txt ".len).?;
- try testing.expectEqual(@as(usize, 0), at.col);
+ try testing.expectEqualStrings("Get", whole[at.col..]);
}
test "name reads the file name and writing it promotes a scratch without touching its body" {
diff --git a/src/panes.zig b/src/panes.zig
index a67aa8ac..ab4f5072 100644
--- a/src/panes.zig
+++ b/src/panes.zig
@@ -133,6 +133,11 @@ pub const Pane = struct {
/// scrolls to keep its caret in view while it has the keyboard; pointer
/// rows count from this line, not the tag's first.
tag_top: u16 = 0,
+ /// How long the tag's computed prefix was when its positions were last
+ /// placed. They count from the start of the prefix, so when it grows or
+ /// shrinks (a rename, the dirty marker, a PDF's page) what follows it
+ /// moves with it.
+ tag_lead: usize = 0,
cwd: Cwd = .none,
hscroll: i32 = 0,
// Visible rows map to source lines and raw/display column origins.
diff --git a/src/pardes.zig b/src/pardes.zig
index 65f3aebb..321a6b3c 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -4976,16 +4976,14 @@ pub const Pardes = struct {
if (t.what != .body) {
// An explicit selection, else the word under the cursor in the
// text as it is shown: a tag's prefix is part of what a word is.
- const arena = p.scratch.allocator();
- const own = arena.dupe(u8, edit.flatSurface(p, t) catch return) catch return;
- const shown = if (t.what == .tag) tagline.tagText(p, arena, pane) catch return else own;
- const range = t.primaryRange(own, 0);
+ const shown = p.scratch.allocator().dupe(u8, edit.flatSurface(p, t) catch return) catch return;
+ const range = t.primaryRange(shown, 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)
- own[lo..hi]
+ shown[lo..hi]
else word: {
- const b = config.wordBounds(shown, shown.len - own.len + modal.selectionCursor(own, range));
+ const b = config.wordBounds(shown, modal.selectionCursor(shown, range));
break :word shown[b.lo..b.hi];
};
// Out of the tag or header FIRST: Del frees the pane, Kill
@@ -5013,6 +5011,14 @@ pub const Pardes = struct {
return;
}
if (t.mode == .normal and t.what == .body and pane.hasPdf()) return normal.handlePdfNormal(p, pane, key);
+ if (t.what == .tag and t.mode == .insert and tagline.typedIntoName(p, pane, key)) return;
+ // A tag's prefix is read-only: a key whose edit reached into it
+ // changes nothing, not even where the cursor is.
+ const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel };
+ t.refused = false;
+ defer if (t.refused) {
+ t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel = kept;
+ };
switch (t.mode) {
.normal => normal.handleNormal(p, t, key),
.insert => {
@@ -5597,6 +5603,23 @@ pub const Pardes = struct {
const r = p.rects[id];
// The tag takes a row per line, leaving the body one row if it
// had any; a folded pane shows its first line only.
+ // Measured on the stack: this runs on every event, and not
+ // every event may take from the scratch arena.
+ var room: [2 * limits.host_path_cap + 256]u8 = undefined;
+ var measure = std.heap.FixedBufferAllocator.init(&room);
+ const lead = if (tagline.prefixIn(measure.allocator(), pane)) |prefix| prefix.len else |_| pane.tag_lead;
+ if (lead != pane.tag_lead) {
+ const t = &pane.tag;
+ const old: i32 = @intCast(pane.tag_lead);
+ const new: i32 = @intCast(lead);
+ if (t.cur_row == 0 and t.cur_col >= old) t.cur_col += new - old;
+ if (t.vsel.row == 0 and t.vsel.col >= old) t.vsel.col += new - old;
+ for (t.sels[0..t.nsel]) |*sel| {
+ if (sel.row == 0 and sel.col >= old) sel.col += new - old;
+ if (sel.arow == 0 and sel.acol >= old) sel.acol += new - old;
+ }
+ pane.tag_lead = lead;
+ }
const lines = std.mem.count(u8, tagline.curTail(pane), "\n") + 1;
pane.tag_rows = @intCast(@min(lines, MAX_TAG_ROWS, @max(BOX_H, r.h -| 1)));
if (pane.collapsed) pane.tag_rows = BOX_H;
diff --git a/src/tagline.zig b/src/tagline.zig
index 8bd8000a..92c18510 100644
--- a/src/tagline.zig
+++ b/src/tagline.zig
@@ -102,20 +102,29 @@ pub fn revealHeader(p: *Pardes) void {
}
}
+/// The computed start of a pane's tag -- its path and dirty marker, a
+/// PDF's page, a terminal's directory -- without the space that parts it
+/// from the tag's own text. It is never stored, and never edited.
+pub fn pathPrefix(arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
+ if (comptime pdf_enabled) if (pane.pdf) |pv|
+ return std.fmt.allocPrint(arena, "{s} [{d}/{d}]", .{ pv.path, pv.page + 1, pv.page_count });
+ if (pane.image) |*state| return panes.Image.tagPrefix(arena, state);
+ if (pane.file) |f| {
+ 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 name;
+ }
+ return pane.cwdSlice();
+}
+
pub fn tagPrefix(p: *Pardes, pane: *Pane) ![]u8 {
- const arena = p.scratch.allocator();
- const prefix = prefix: {
- if (comptime pdf_enabled) if (pane.pdf) |pv|
- break :prefix try std.fmt.allocPrint(arena, "{s} [{d}/{d}]", .{ pv.path, pv.page + 1, pv.page_count });
- if (pane.image) |*state| break :prefix try panes.Image.tagPrefix(arena, state);
- if (pane.file) |f| {
- 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)
- break :prefix try std.fmt.allocPrint(arena, "{s}{s}", .{ name, dirty_marker });
- break :prefix name;
- }
- break :prefix pane.cwdSlice();
- };
+ return prefixIn(p.scratch.allocator(), pane);
+}
+
+/// The prefix as the tag shows it, built in `arena`.
+pub fn prefixIn(arena: std.mem.Allocator, pane: *Pane) ![]u8 {
+ const prefix = try pathPrefix(arena, pane);
// The space between the prefix and the tag's own text is the text's
// (every default starts with one), but the prefix is computed, so it
// cannot trust the text to keep it: with it deleted, Look and Exec
@@ -159,21 +168,23 @@ pub fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 {
/// 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);
+ const whole = tagText(p, p.scratch.allocator(), pane) catch return;
if (!t.cur_pinned) {
+ // Save is looked for in the tag's own text, after the prefix.
+ const lead = whole.len - curTail(pane).len;
+ const tail = whole[lead..];
var words = std.mem.tokenizeAny(u8, tail, " \t\n");
- const at = while (words.next()) |word| {
+ const at = lead + 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));
+ const cursor = modal.positionAt(whole, @min(at, whole.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 = tagText(p, p.scratch.allocator(), pane) catch return;
- const line = tag[0 .. std.mem.indexOfScalar(u8, tag, '\n') orelse tag.len];
+ const line = whole[0 .. std.mem.indexOfScalar(u8, whole, '\n') orelse whole.len];
const width = p.tagCapacity(p.rects[id].w -| TAG_TEXT_INSET);
pane.tag_scroll = @intCast(panes.File.displayWidth(line) + 1 -| width);
}
@@ -181,9 +192,9 @@ pub fn enterTag(p: *Pardes, pane: *Pane) void {
// 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);
+ const rows = modal.cursorLineCount(whole);
t.cur_row = @intCast(@min(@as(usize, @intCast(@max(0, t.cur_row))), rows - 1));
- const line = modal.lineSlice(tail, @intCast(t.cur_row));
+ const line = modal.lineSlice(whole, @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;
@@ -196,15 +207,40 @@ pub fn enterTag(p: *Pardes, pane: *Pane) void {
pane.focus = .tag;
}
-/// Where display column `col` of tag line `row` (tag scroll included)
-/// lands in the tag's own text, or null when it is on the prefix, which
-/// only the first line has.
+/// Where display column `col` of the tag's shown row `row` (tag scroll
+/// included) puts its cursor: the tag as shown, prefix and all.
pub fn tagOffsetAt(p: *Pardes, pane: *Pane, row: usize, col: usize) ?modal.Cursor {
const at = row + pane.tag_top;
- const width = if (at == 0) panes.File.displayWidth(tagPrefix(p, pane) catch return null) else 0;
- if (col < width) return null;
- const line = modal.lineSlice(curTail(pane), at);
- return .{ .row = at, .col = modal.graphemeStart(line, panes.File.rawAtDisplay(line, col - width)) };
+ const line = modal.lineSlice(tagText(p, p.scratch.allocator(), pane) catch return null, at);
+ return .{ .row = at, .col = modal.graphemeStart(line, panes.File.rawAtDisplay(line, col)) };
+}
+
+/// Whether display column `col` of the tag's shown row `row` is on a name
+/// a click drafts: a saving file's path.
+pub fn onName(pane: *Pane, row: usize, col: usize) bool {
+ if (row + pane.tag_top != 0) return false;
+ const f = pane.file orelse return false;
+ if (!panes.Output.fileTraits(f.output).saves) return false;
+ const name = if (pane.prompt == .name) pane.input.own orelse f.path else f.path;
+ return col < panes.File.displayWidth(name);
+}
+
+/// A key typed into a saving file's path, with the tag's cursor there,
+/// drafts a new name as a click on it does: the path is computed, so it is
+/// never the tag's own text to edit, but a buffer's name can be drafted.
+/// True when it did.
+pub fn typedIntoName(p: *Pardes, pane: *Pane, key: pardes.Key) bool {
+ const f = pane.file orelse return false;
+ if (!panes.Output.fileTraits(f.output).saves or pane.tag.cur_row != 0) return false;
+ const at: usize = @intCast(@max(0, pane.tag.cur_col));
+ const back = key.cp == pardes.Key.backspace or pardes.hit(key, config.insert_backspace_alias);
+ const forward = key.cp == pardes.Key.delete or pardes.hit(key, config.insert_delete_alias);
+ const typed = key.text.len > 0 and !key.ctrl and !key.alt;
+ const inside = if (back) at > 0 and at <= f.path.len else if (forward or typed) at < f.path.len else false;
+ if (!inside) return false;
+ draftName(p, pane, panes.File.displayWidth(f.path[0..at]));
+ edit.handleInsert(p, &pane.input, key);
+ return true;
}
/// Start a name draft for a file that can take one, its caret at display
diff --git a/test/panes.zig b/test/panes.zig
index d8ed517f..d0551cfc 100644
--- a/test/panes.zig
+++ b/test/panes.zig
@@ -12,12 +12,6 @@ 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 pardes.tagline.tagPrefix(p, 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);
@@ -67,7 +61,7 @@ const TagLayerTests = struct {
p.update(.{ .mouse = mouse(layer, x, .press) });
p.update(.{ .mouse = mouse(layer, x, .release) });
try std.testing.expect(pane.focus == .tag);
- try std.testing.expectEqual(at, try tagCaret(p, pane));
+ try std.testing.expectEqual(at, @as(usize, @intCast(pane.tag.cur_col)));
p.update(.{ .key = .{ .cp = 'X', .text = "X" } });
_ = try p.render(frame.allocator());
const edited = try pardes.tagline.tagText(p, frame.allocator(), pane);
@@ -99,7 +93,7 @@ const TagLayerTests = struct {
event.row = @intFromFloat(painted_y);
p.update(.{ .mouse = event });
}
- try std.testing.expectEqual(at, try tagCaret(p, pane));
+ try std.testing.expectEqual(at, @as(usize, @intCast(pane.tag.cur_col)));
}
}
@@ -148,7 +142,7 @@ 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(panes.File.rawAtDisplay(left_text, end_col), try tagCaret(p, left));
+ try std.testing.expectEqual(panes.File.rawAtDisplay(left_text, end_col), @as(usize, @intCast(left.tag.cur_col)));
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";
@@ -320,6 +314,44 @@ const TagNavigationTests = struct {
try std.testing.expect(pane.prompt == .none and pane.focus == .body);
}
+ test "the keyboard reaches a tag's path, which it can select and yank but not edit" {
+ 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(.tick);
+ key(p, ':');
+ // `0` is the line's start, as in acme, where the name is ordinary text
+ key(p, '0');
+ try std.testing.expectEqual(@as(i32, 0), pane.tag.cur_col);
+ key(p, 'E');
+ key(p, 'y');
+ try std.testing.expectEqualStrings("/test.txt", p.yank.?);
+ // an edit that reaches into it is refused, the cursor with it
+ const before = try std.testing.allocator.dupe(u8, try pardes.edit.flatSurface(p, &pane.tag));
+ defer std.testing.allocator.free(before);
+ const cursor = pane.tag.cur_col;
+ key(p, 'd');
+ try std.testing.expectEqualStrings(before, try pardes.edit.flatSurface(p, &pane.tag));
+ try std.testing.expectEqual(cursor, pane.tag.cur_col);
+ try std.testing.expect(pane.tag.vsel.active);
+ // typed into, the path drafts a name there, as a click on it does
+ key(p, ';');
+ key(p, '0');
+ key(p, 'i');
+ p.update(.{ .key = .{ .cp = 'x', .text = "x" } });
+ try std.testing.expect(pane.prompt == .name);
+ try std.testing.expectEqualStrings("x/test.txt", pane.input.own.?);
+ key(p, Key.escape);
+ try std.testing.expect(pane.prompt == .none);
+ try std.testing.expectEqualStrings("/test.txt", pane.file.?.path);
+ // after the path, the text is the tag's own to edit
+ key(p, ':');
+ key(p, 'A');
+ p.update(.{ .key = .{ .cp = '!', .text = "!" } });
+ try std.testing.expect(std.mem.endsWith(u8, pardes.tagline.curTail(pane), "!"));
+ try std.testing.expectEqualStrings("body\n", pane.file.?.content);
+ }
+
fn window(p: *Pardes, letter: u21) void {
p.update(.{ .key = .{ .cp = 'w', .ctrl = true } });
key(p, letter);
@@ -339,8 +371,9 @@ const TagNavigationTests = struct {
// 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.tagline.curTail(first);
- try std.testing.expectEqualStrings("Save", tail[@intCast(first.tag.cur_col)..][0..4]);
+ const shown = try pardes.edit.flatSurface(p, &first.tag);
+ try std.testing.expectEqualStrings("Save", shown[@intCast(first.tag.cur_col)..][0..4]);
+ // Home is the line's start, the path's: the keyboard reaches it.
key(p, Key.home);
for (0..3) |_| key(p, Key.right);
try std.testing.expectEqual(@as(i32, 3), first.tag.cur_col);
@@ -369,13 +402,13 @@ const TagNavigationTests = struct {
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.expect(first.focus == .tag and first.tag.mode == .insert);
- try std.testing.expectEqual(@as(i32, 6), first.tag.cur_col);
+ try std.testing.expectEqual(@as(i32, prefix + 6), first.tag.cur_col);
key(p, Key.escape);
key(p, ':');
p.startPrompt(first, &first.body, .{ .search = config.search_marker });
key(p, Key.escape);
key(p, ':');
- try std.testing.expectEqual(@as(i32, 6), first.tag.cur_col);
+ try std.testing.expectEqual(@as(i32, prefix + 6), first.tag.cur_col);
key(p, ':');
try std.testing.expectEqualStrings("body\n", first.file.?.content);
}
@@ -403,26 +436,28 @@ const TagNavigationTests = struct {
try std.testing.expectEqual(chord == Key.tab, saved);
// ...and the tag still knows where it was.
key(p, ':');
- try std.testing.expectEqualStrings("Save", pardes.tagline.curTail(pane)[@intCast(pane.tag.cur_col)..][0..4]);
+ try std.testing.expectEqualStrings("Save", (try pardes.edit.flatSurface(p, &pane.tag))[@intCast(pane.tag.cur_col)..][0..4]);
}
}
- test "a renamed path never moves the tag's cursor and a shorter tag pulls it back" {
+ test "a renamed path moves the tag's cursor with the text after it 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, ':'); // on Save
key(p, ':');
p.gpa.free(pane.file.?.path);
pane.file.?.path = try p.gpa.dupe(u8, "/界.zig");
+ p.update(.tick);
key(p, ':');
- try std.testing.expectEqual(@as(i32, 3), pane.tag.cur_col);
+ const shown = try pardes.edit.flatSurface(p, &pane.tag);
+ try std.testing.expectEqualStrings("Save", shown[@intCast(pane.tag.cur_col)..][0..4]);
key(p, ':');
try setTag(pane, "é");
+ p.update(.tick);
key(p, ':');
- try std.testing.expectEqual(@as(i32, 2), pane.tag.cur_col);
+ // `/界.zig é`: the cursor is pulled back onto the last character
+ try std.testing.expectEqual(@as(i32, "/界.zig ".len), pane.tag.cur_col);
try std.testing.expectEqualStrings("body\n", pane.file.?.content);
}
diff --git a/test/snapshots/tagpath.golden b/test/snapshots/tagpath.golden
index 2ba1a2e6..a2ea32fc 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=37,2
-== snap drag-cross grid=100x31 cursor=41,2
-== style drag-cross grid=100x31
+== snap col0 grid=100x31 cursor=3,2
+== snap sel-path grid=100x31 cursor=36,2
+== style sel-path 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-10 #becfc4,#26362d, 11-41 #f5f7ee,#3c614a, 42-58 #becfc4,#26362d, 59-99 d,#26362d,
+|2: 0-1 #000000,#add38a, 2-2 d,#26362d, 3-36 #f5f7ee,#3c614a, 37-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,9 +42,16 @@
|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-7 #e5eee5,#0d1410, 8-41 #f5f7ee,#3c614a, 42-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=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
+== 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
diff --git a/test/snapshots/tagpath.snap b/test/snapshots/tagpath.snap
index b9ef80e6..f02769d9 100644
--- a/test/snapshots/tagpath.snap
+++ b/test/snapshots/tagpath.snap
@@ -1,8 +1,7 @@
-# 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.
+# The path is part of the tag's text for the keyboard as for the mouse: `0`
+# goes to its start, `E` selects it and `y` yanks it. It is read-only, but
+# typing into it drafts a new name there, as a click on it does. Escape
+# cancels; Enter changes buffer identity, never renames a disk file.
lines c.txt 4
start 31 100 c.txt
wait 8000 Newcol Joincol
@@ -15,6 +14,16 @@ snap colon
key 0
stable 400 5000
snap col0
+key E
+stable 400 5000
+snap sel-path
+snapstyle sel-path
+key y
+# `:` gives the keyboard back to the body, and `p` puts the path there
+key :
+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
@@ -23,21 +32,21 @@ release left 42 3
stable 400 5000
snap drag-cross
snapstyle drag-cross
+# Esc is the tag's normal mode; typed at the path's start, `/renamed` drafts
+# a name
key esc
settle 100
-key :
-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
+key 0
+key i
text /renamed
stable 400 5000
snap name-draft
key esc
stable 400 5000
snap name-cancelled
-press left 4 3
-release left 4 3
+key :
+key 0
+key i
text /renamed
key enter
stable 400 5000