summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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