From 13d0d107a06d7ac5fb5f8e86c747e54603f51b4e Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 15 Sep 2026 13:39:27 -0300 Subject: Remember tag cursors and reserve uppercase pane navigation --- docs/helix-keys.md | 4 +- docs/tags.md | 15 +++-- src/config.zig | 7 ++- src/layout.zig | 6 +- src/panes.zig | 1 + src/pardes.zig | 109 ++++++++++++++++++-------------- src/tag_line.zig | 1 + test/panes.zig | 179 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 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 ` 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 ` 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; -- cgit v1.3