summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/helix-keys.md4
-rw-r--r--docs/tags.md15
-rw-r--r--src/config.zig7
-rw-r--r--src/layout.zig6
-rw-r--r--src/panes.zig1
-rw-r--r--src/pardes.zig109
-rw-r--r--src/tag_line.zig1
-rw-r--r--test/panes.zig179
8 files changed, 263 insertions, 59 deletions
diff --git a/docs/helix-keys.md b/docs/helix-keys.md
index a2b597d2..7675e73b 100644
--- a/docs/helix-keys.md
+++ b/docs/helix-keys.md
@@ -117,8 +117,8 @@ 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) | focuses the pane's OWN tag as a one-line editor in **normal** mode, parked at the first EDITABLE column: motions (`w` `b` `e` `W` `B` `E`, `0` `$` `^`, arrows, `Home`/`End`) walk the whole rendered tag, `y` yanks the selection, `Enter`/`Tab` look/execute it (else the file-ish word under the cursor), `i`/`a`/`I`/`A` enter insert, `Esc` hands the body back. `h`/`j`/`k`/`l` are NOT motion here — a tagline is a place in the LAYOUT, so they run the same `Left`/`Down`/`Up`/`Right` builtins and land on the neighbouring pane's TAGLINE, still in normal mode (nothing that way = stay put, EXCEPT `k` off the topmost tagline — see the next row); the arrows keep the in-tag motion | helix `:` is command mode (section C); pardes' commands are acme words that live in the tag. `tag_col`/`tag_anchor` are columns of the RENDERED tag (prefix ++ tail) — one coordinate space, so the live mode+path prefix is selectable, yankable and executable, while every edit op (typing, `Backspace`, `i`/`a`/`I`/`A`) measures from the first editable column and is inert inside it | pardes-specific |
-| `k` (tag normal, topmost tagline) | focuses the column tag, then another `k` reaches the workspace tag at row 0 (`Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill`, plus `Restore <path>` once a dump exists). With `ColumnTags` disabled it goes directly to the workspace. `j` walks back down. Header arrows and `h`/`l` move by grapheme; word motions and `0`/`$`/`^` work too. In normal mode `Enter`/`Tab` executes; `i`/`a`/`I`/`A` enter editing, where Enter is Look and Tab is Exec. Left-click, drag selection, typing, and paste edit either header; Esc leaves | 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 |
+| `:` (normal, body) | focuses the pane's tag in normal mode at its remembered cursor position (first entry starts at the commands). `h`/`l`, arrows, word motions and `0`/`$`/`^` move within the tag; `k` stays on its single line; `j` or `Esc` returns to the body. Uppercase `H`/`J`/`K`/`L` run `Left`/`Down`/`Up`/`Right` and enter the neighboring pane's tag. `K` beyond the top pane reaches the headers below. `y` yanks; Enter/Tab Look/Exec; `i`/`a`/`I`/`A` enter insert mode, where letters type normally. Explicit mouse clicks choose a new cursor position. | Each tag keeps its own cursor position during the session. Positions are UTF-8 byte offsets in the rendered prefix plus editable tail; file-name changes are staged as described in [editable tags](tags.md). | pardes-specific |
+| `K` (tag normal, topmost tagline) | focuses the column tag, then another `K` reaches the workspace tag. With `ColumnTags` disabled it goes directly to the workspace. `J` walks back down. Header arrows and `h`/`l` move by grapheme; word motions and `0`/`$`/`^` work too. `j`/`Esc` returns to the panel; lowercase `k` stays put. Enter/Tab executes in normal mode; `i`/`a`/`I`/`A` enter editing, where Enter is Look and Tab is Exec. | 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, while the bare letters `h/j/k/l` there move to its TAGLINE (next row) | pardes-specific |
| `Alt-n` | new terminal below (outside raw tty) | shadows helix `Alt-n` TS sibling-select — skipped anyway (tree-sitter) | pardes-specific |
| `Alt-c` | move active terminal to a fresh column (outside raw tty) | helix `Alt-c` is change-noyank; the pardes window op wins (do-not-touch contract). `Alt-d` + `i` covers the behavior | waived (`alt-c-window-op`) |
diff --git a/docs/tags.md b/docs/tags.md
index c159e9cf..1d874448 100644
--- a/docs/tags.md
+++ b/docs/tags.md
@@ -31,11 +31,18 @@ and Look gestures still apply; the workspace tag is no longer an inert
left-click target. Pasted text goes to the focused tag, not to the file or
embedded shell beneath it.
-Normal tag navigation now visits pane, column, and workspace tags in order:
-from the top pane's `:` tag, `k` reaches the column and another `k` reaches
-the workspace; `j` walks back down. Enter or Tab on a header command in
+Keyboard entry returns to the tag's last cursor position. The first entry into
+a pane tag starts at its commands; clicking always chooses a new position.
+Each pane, column and workspace tag remembers its own position during the
+session. Shortened text clamps the saved cursor to a valid character boundary.
+
+In normal mode, `h` and `l` move within the tag, `k` stays on the single line,
+and `j` returns to the panel like Escape. Uppercase `HJKL` move between pane
+tags. From the top pane's `:` tag, `K` reaches the column and another `K` reaches
+the workspace; `J` walks back down. Enter or Tab on a header command in
normal mode executes it, as before. `i`/`a`/`I`/`A` switch to editing the
-header. In insert mode, Enter is Look and Tab is Exec, as in pane tags.
+header. In insert mode, letters type normally, Enter is Look and Tab is Exec,
+as in pane tags.
Pane filenames and commands now have a single separator space rather than
generated right-alignment padding. Intentionally customized spacing is kept.
diff --git a/src/config.zig b/src/config.zig
index 555173e4..fea22609 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -203,7 +203,8 @@ pub const jump_keys = [_]struct { chord: Chord, cmd: Builtin }{
.{ .chord = .{ .cp = 'i', .ctrl = true }, .cmd = .Forward },
};
-pub const topbar_down: []const Chord = &.{.{ .cp = 'j' }};
+pub const topbar_down: []const Chord = &.{ .{ .cp = 'J' }, .{ .cp = Key.down } };
+pub const topbar_up: []const Chord = &.{ .{ .cp = 'K' }, .{ .cp = Key.up } };
pub const topbar_left: []const Chord = &.{.{ .cp = 'h' }};
pub const topbar_right: []const Chord = &.{.{ .cp = 'l' }};
@@ -423,8 +424,8 @@ pub const line_end: []const Chord = &.{ .{ .cp = '$' }, .{ .cp = Key.end } };
pub const line_first_nonws: []const Chord = &.{.{ .cp = '^' }};
// Bare G does nothing; ge reaches the last line.
pub const goto_line: []const Chord = &.{.{ .cp = 'G' }};
-pub const line_move_left: []const Chord = &.{.{ .cp = Key.left }};
-pub const line_move_right: []const Chord = &.{.{ .cp = Key.right }};
+pub const line_move_left: []const Chord = move_left;
+pub const line_move_right: []const Chord = move_right;
pub const half_page_down: []const Chord = &.{.{ .cp = 'd', .ctrl = true }};
pub const half_page_up: []const Chord = &.{.{ .cp = 'u', .ctrl = true }};
diff --git a/src/layout.zig b/src/layout.zig
index 6c5f4474..2df64594 100644
--- a/src/layout.zig
+++ b/src/layout.zig
@@ -777,6 +777,7 @@ pub fn removePane(p: *Pardes, id: usize) void {
while (k + 1 < p.col_n[c]) : (k += 1) p.col_panes[c][k] = p.col_panes[c][k + 1];
p.col_n[c] -= 1;
if (p.col_n[c] == 0) {
+ p.exitHeader();
if (p.ncol > 1) p.col_weight[if (c > 0) c - 1 else c + 1] +|= p.col_weight[c];
var j = c;
while (j + 1 < p.ncol) : (j += 1) {
@@ -787,7 +788,6 @@ pub fn removePane(p: *Pardes, id: usize) void {
}
p.ncol -= 1;
p.col_tags[p.ncol] = .{};
- p.topbar_col = null;
}
}
@@ -795,6 +795,7 @@ pub fn joinCol(p: *Pardes) void {
const f = findPane(p, p.active) orelse return;
const c = f.col;
if (c + 1 >= p.ncol) return;
+ p.exitHeader();
const dst = c + 1;
p.col_weight[dst] +|= p.col_weight[c];
for (0..p.col_n[c]) |k| p.col_panes[dst][p.col_n[dst] + k] = p.col_panes[c][k];
@@ -808,7 +809,6 @@ pub fn joinCol(p: *Pardes) void {
}
p.ncol -= 1;
p.col_tags[p.ncol] = .{};
- p.topbar_col = null;
}
pub fn canSplitColumn(p: *Pardes, source_id: usize) bool {
@@ -847,6 +847,7 @@ pub fn splitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool {
const new_weight = old_weight - source_weight;
p.col_weight[source_col] = source_weight;
const c = source_col + @intFromBool(!before);
+ p.exitHeader();
var j = p.ncol;
while (j > c) : (j -= 1) {
p.col_panes[j] = p.col_panes[j - 1];
@@ -856,7 +857,6 @@ pub fn splitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool {
}
p.col_weight[c] = new_weight;
p.col_tags[c] = .{};
- p.topbar_col = null;
p.col_panes[c][0] = id;
p.col_n[c] = 1;
p.ncol += 1;
diff --git a/src/panes.zig b/src/panes.zig
index 1487e0ef..ea733ea1 100644
--- a/src/panes.zig
+++ b/src/panes.zig
@@ -147,6 +147,7 @@ pub const Pane = struct {
/// restore it on exit so clicking the tag never changes the pane's mode
tag_mode: 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.
diff --git a/src/pardes.zig b/src/pardes.zig
index 3c0205d2..dd7fc5ed 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -7128,6 +7128,7 @@ pub const Pardes = struct {
}
fn enterTagEdit(p: *Pardes, pane: *Pane, col: i32) void {
+ if (pane.tag_edit and pane.prompt == .none) 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;
@@ -7141,14 +7142,18 @@ pub const Pardes = struct {
const tail = pane.tagSlice();
const lead: i32 = @intCast(tail.len - std.mem.trimStart(u8, tail, " ").len);
if (col < 0) {
- pane.tag_col = @intCast(@min(edit0 + lead, end));
const text = p.tagText(p.scratch.allocator(), pane) catch return;
- if (p.paneIdOf(pane)) |id| {
+ 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| {
const width = @max(1, p.rects[id].w -| config.GUTTER);
// 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)));
@@ -7156,6 +7161,7 @@ pub const Pardes = struct {
}
fn exitTagEdit(pane: *Pane) void {
+ if (pane.tag_edit and pane.prompt == .none) pane.tag_saved_col = pane.tag_col;
if (pane.isTerminal()) {
if (pane.tag_edit) pane.mode = pane.tag_mode;
} else pane.mode = .normal;
@@ -7334,7 +7340,7 @@ pub const Pardes = struct {
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)) return exitTagEdit(pane);
+ 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;
@@ -7364,7 +7370,9 @@ pub const Pardes = struct {
return;
}
const dir: ?Builtin = for (config.window_keys) |wk| {
- if (hit(key, &.{wk.letter})) break wk.cmd;
+ 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;
@@ -7372,17 +7380,13 @@ pub const Pardes = struct {
if (p.active == from) {
if (d == .Up) {
exitTagEdit(pane);
- p.topbar_col = 0;
- p.header_column = if (p.columnBarHeight() > 0) (layout.findPane(p, from) orelse return).col else null;
- p.header_insert = false;
- p.header_anchor = null;
- p.header_drag = false;
+ 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); // ...and the tail's start is the new park
+ p.enterTagEdit(dest, -1);
if (dest.tag_edit) dest.mode = .normal;
return;
}
@@ -7421,23 +7425,46 @@ pub const Pardes = struct {
return modal.moveWord(text, .{ .anchor = cur, .head = modal.nextGrapheme(text, cur) }, 1, t);
}
+ fn rememberHeader(p: *Pardes) void {
+ const col = p.topbar_col orelse return;
+ const line = if (p.header_column) |c| &p.col_tags[c] else &p.global_tag;
+ line.saved_col = col;
+ }
+
+ pub fn exitHeader(p: *Pardes) void {
+ p.rememberHeader();
+ p.topbar_col = null;
+ p.header_column = null;
+ p.header_anchor = null;
+ p.header_drag = false;
+ p.header_scroll = 0;
+ }
+
+ fn enterHeader(p: *Pardes, column: ?usize) void {
+ p.rememberHeader();
+ p.header_column = column;
+ const line = if (column) |c| &p.col_tags[c] else &p.global_tag;
+ const text = p.headerText();
+ p.topbar_col = @intCast(modal.graphemeStart(text, @min(line.saved_col orelse 0, text.len)));
+ p.header_insert = false;
+ p.header_anchor = null;
+ p.header_drag = false;
+ p.header_scroll = 0;
+ }
+
fn topbarKey(p: *Pardes, key: Key) void {
const bar = p.headerText();
// the line GROWS a `Restore <path>` word the moment a dump lands, so
// the column is clamped against the live line on every use
const cur: usize = @min(@as(usize, p.topbar_col orelse return), bar.len);
- if (hit(key, config.escape)) {
- p.topbar_col = null; // the active pane still has its body focus
- p.header_column = null;
- p.header_anchor = null;
- p.header_drag = false;
- p.header_scroll = 0;
+ if (hit(key, config.escape) or (!p.header_insert and hit(key, &.{.{ .cp = 'j' }}))) {
+ p.exitHeader();
return;
}
if (hit(key, config.look_key) or hit(key, config.exec_key)) {
const a = p.header_anchor orelse @as(u16, @intCast(cur));
const word = if (a != cur) bar[@min(a, cur)..@max(a, cur)] else wordAtCol(bar, cur);
- p.topbar_col = null;
+ p.exitHeader();
if (word.len > 0) {
if (!p.header_insert) {
// Preserve the workspace tag's keyboard command semantics.
@@ -7490,9 +7517,7 @@ pub const Pardes = struct {
}
if (hit(key, config.topbar_down)) {
if (p.header_column == null and p.columnBarHeight() > 0) {
- p.header_column = if (layout.findPane(p, p.active)) |f| f.col else 0;
- p.header_scroll = 0;
- p.topbar_col = 0;
+ p.enterHeader(if (layout.findPane(p, p.active)) |f| f.col else 0);
return;
}
var dest: ?usize = null;
@@ -7507,17 +7532,14 @@ pub const Pardes = struct {
}
const d = dest orelse return;
const pane = p.panes[d].?;
- p.topbar_col = null;
- p.header_column = null;
+ p.exitHeader();
p.active = d;
p.enterTagEdit(pane, -1);
if (pane.tag_edit) pane.mode = .normal;
return;
}
- if (key.cp == 'k' or key.cp == Key.up) {
- p.header_column = null;
- p.header_anchor = null;
- p.topbar_col = 0;
+ if (hit(key, config.topbar_up)) {
+ p.enterHeader(null);
return;
}
const col: ?usize = if (hit(key, config.topbar_left))
@@ -7905,7 +7927,7 @@ pub const Pardes = struct {
const pane = p.panes[id] orelse return;
if (!pane.isTerminal()) return;
p.active = id;
- p.topbar_col = null;
+ p.exitHeader();
if (pane.tag_edit) exitTagEdit(pane);
if (pane.mode == .tty) {
pane.mode = .normal;
@@ -10948,6 +10970,9 @@ pub const Pardes = struct {
if (mrow < TOPBAR_H + p.columnBarHeight() and m.kind == .press and
(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) exitTagEdit(pane);
+ };
var column: ?usize = null;
var x: u16 = 0;
if (mrow >= TOPBAR_H) {
@@ -10962,6 +10987,7 @@ pub const Pardes = struct {
if (column == null) return;
}
if (p.header_column != column) {
+ p.rememberHeader();
p.topbar_col = null;
p.header_anchor = null;
p.header_scroll = 0;
@@ -10970,7 +10996,6 @@ pub const Pardes = struct {
const bar = p.headerText();
const at = @min(bar.len, panes.File.rawAtDisplay(bar, (mcol -| x) + p.header_scroll));
if (m.button == config.select_button) {
- if (p.panes[p.active]) |pane| exitTagEdit(pane);
p.topbar_col = @intCast(at);
p.header_anchor = @intCast(at);
p.header_insert = true;
@@ -10981,7 +11006,7 @@ pub const Pardes = struct {
break :blk if (at >= @min(a, cur) and at < @max(a, cur)) bar[@min(a, cur)..@max(a, cur)] else wordAtCol(bar, at);
} else wordAtCol(bar, at);
const word = p.scratch.allocator().dupe(u8, selected) catch return;
- p.topbar_col = null;
+ p.exitHeader();
if (word.len > 0) {
if (m.button == config.exec_button) {
const named = std.meta.stringToEnum(Builtin, word);
@@ -10993,10 +11018,7 @@ pub const Pardes = struct {
return;
}
if (m.kind == .press) {
- p.topbar_col = null;
- p.header_column = null;
- p.header_anchor = null;
- p.header_drag = false;
+ p.exitHeader();
}
switch (m.button) {
@@ -11083,6 +11105,9 @@ pub const Pardes = struct {
const id = hovered orelse return;
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) exitTagEdit(previous);
+ };
const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y;
const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H;
if (mcol < r.x + config.GUTTER) {
@@ -11992,13 +12017,7 @@ pub const Pardes = struct {
const tagline_metrics_changed = setting.action == .tagline_size and
p.settings.font.tagline_percent != previous_tagline_percent;
if (p.settings.column_tags != previous_column_tags) {
- if (p.header_column != null) {
- p.topbar_col = null;
- p.header_column = null;
- p.header_anchor = null;
- p.header_drag = false;
- p.header_scroll = 0;
- }
+ if (p.header_column != null) p.exitHeader();
}
if (transition_changed or tagline_metrics_changed or p.settings.column_tags != previous_column_tags) {
p.abandonPanelAnimations();
@@ -12474,11 +12493,7 @@ pub const Pardes = struct {
/// deferred greetings. The mirror of the prototype's loop epilogue.
fn sync(p: *Pardes) void {
if (p.header_column != null and p.columnBarHeight() == 0) {
- p.topbar_col = null;
- p.header_column = null;
- p.header_anchor = null;
- p.header_drag = false;
- p.header_scroll = 0;
+ p.exitHeader();
}
p.reapPanes();
layout.compute(p);
@@ -12486,7 +12501,7 @@ pub const Pardes = struct {
p.trackJump();
for (&p.panes, 0..) |*slot, id| {
const pane = slot.* orelse continue;
- if (pane.tag_name_len != null and (id != p.active or p.topbar_col != null))
+ if (pane.tag_edit and pane.prompt == .none and (id != p.active or p.topbar_col != null))
exitTagEdit(pane);
if (comptime terminal_panes) if (pane.terminal) |state| {
var off: u16 = 0;
diff --git a/src/tag_line.zig b/src/tag_line.zig
index d1a9d166..d3bdf59c 100644
--- a/src/tag_line.zig
+++ b/src/tag_line.zig
@@ -5,6 +5,7 @@ pub const Line = struct {
text: [@import("memory.zig").limits.max_tag_tail]u8 = undefined,
len: usize = 0,
custom: bool = false,
+ saved_col: ?u16 = null,
pub fn slice(self: *const Line, fallback: []const u8) []const u8 {
return if (self.custom) self.text[0..self.len] else fallback;
diff --git a/test/panes.zig b/test/panes.zig
index a7e59923..a452bcfb 100644
--- a/test/panes.zig
+++ b/test/panes.zig
@@ -12,6 +12,184 @@ const image = pardes.image;
const syntax = pardes.syntax;
const filesystem = pardes.filesystem;
+const TagNavigationTests = struct {
+ fn key(p: *Pardes, cp: u21) void {
+ p.update(.{ .key = .{ .cp = cp } });
+ }
+
+ fn enterNormal(p: *Pardes) *Pane {
+ key(p, ':');
+ return p.panes[p.active].?;
+ }
+
+ test "tag navigation retains each pane cursor and mouse placement overrides memory" {
+ 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");
+ p.presentation.enabled = false;
+ try std.testing.expect(p.executeBuiltinLine(0, "New"));
+ const second_id = p.active;
+ const second = p.panes[second_id].?;
+ try std.testing.expect(layout.splitColumn(p, second_id, second_id, false));
+ p.update(.tick);
+ p.active = 0;
+ key(p, ':');
+ key(p, Key.home);
+ for (0..3) |_| key(p, Key.right);
+ const first_col = first.tag_col;
+ key(p, Key.escape);
+ 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);
+ p.active = 0;
+ key(p, ':');
+ try std.testing.expectEqual(first_col, first.tag_col);
+ key(p, Key.escape);
+ p.active = second_id;
+ key(p, ':');
+ try std.testing.expectEqual(second_col, second.tag_col);
+ key(p, Key.escape);
+ 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 + config.GUTTER + 6, y);
+ try std.testing.expectEqual(@as(usize, 0), p.active);
+ try std.testing.expectEqual(@as(u16, 6), first.tag_col);
+ key(p, Key.escape);
+ 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.expectEqualStrings("body\n", first.file.?.content);
+ }
+
+ test "tag navigation clamps remembered columns to renamed graphemes and shorter tags" {
+ 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);
+ 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;
+ key(p, ':');
+ try std.testing.expectEqual(@as(u16, 3), pane.tag_col);
+ try std.testing.expectEqualStrings("body\n", pane.file.?.content);
+ }
+
+ test "tag navigation J and K restore cursors in stacked panes" {
+ for ([_]bool{ false, true }) |bottom| {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ const first = try p.setTestFile("body\n");
+ p.presentation.enabled = false;
+ p.settings.tag_bottom = bottom;
+ try std.testing.expect(p.executeBuiltinLine(0, "New"));
+ const second_id = p.active;
+ const second = p.panes[second_id].?;
+ p.update(.tick);
+ p.active = 0;
+ _ = enterNormal(p);
+ key(p, Key.home);
+ key(p, 'l');
+ key(p, 'l');
+ key(p, 'J');
+ try std.testing.expectEqual(second_id, p.active);
+ try std.testing.expect(!first.tag_edit and second.tag_edit);
+ key(p, Key.home);
+ for (0..4) |_| key(p, 'l');
+ key(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);
+ }
+ }
+
+ test "tag navigation uses uppercase pane and header keys while lowercase stays local" {
+ 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, Key.home);
+ key(p, 'l');
+ try std.testing.expectEqual(@as(u16, 1), pane.tag_col);
+ key(p, 'h');
+ key(p, 'k');
+ try std.testing.expectEqual(@as(u16, 0), pane.tag_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.expectEqual(right, p.active);
+ try std.testing.expect(!pane.tag_edit and p.panes[right].?.tag_edit);
+ key(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.mode == .insert);
+ for ("hjklHJKL") |letter| {
+ const text = [_]u8{letter};
+ p.update(.{ .key = .{ .cp = letter, .text = &text } });
+ try std.testing.expect(pane.tag_edit and pane.mode == .insert);
+ }
+ try std.testing.expectEqualStrings("hjklHJKL", pane.tag_tail[before..pane.tag_tail_len]);
+ try std.testing.expect(pane.tag_edit and pane.mode == .insert and p.topbar_col == null);
+ try std.testing.expectEqual(@as(usize, 0), p.active);
+ }
+ }
+};
+
const TreeContextTests = struct {
const source = "const Box = struct {\n" ++
" pub fn work(\n" ++
@@ -3375,6 +3553,7 @@ const TerminalTests = struct {
};
test {
+ _ = TagNavigationTests;
_ = TagNameTintTests;
_ = TreeContextTests;
_ = TtySelectionTests;