From 4b53a39743dc5962e985fcf059a1222538d57b3f Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 27 Jul 2026 12:34:24 -0300 Subject: k off the topmost tagline focuses the TOPBAR: row 0 is a tagline too --- docs/helix-keys.md | 3 +- src/pardes.zig | 114 ++++++++++++++++++++-- src/tutor.txt | 19 +++- test/snapshots/tagnav.golden | 219 ++++++++++++++++++++++++++++++++++++++++++- test/snapshots/tagnav.snap | 52 +++++++++- 5 files changed, 391 insertions(+), 16 deletions(-) diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 6a77a28d..6f5fe0e7 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -87,7 +87,8 @@ with a reason in `test/hxcases/waivers.jsonl`. | `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); 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 | +| `:` (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 TOPBAR — row 0, the global tagline (`Kill Newcol Tutor Debug NextColor Dump`, plus `Restore ` once a dump exists). It is its own one-line normal mode: `h`/`l` and the arrows by grapheme (row 0 has no window left or right to walk to), `w`/`b`/`e`/`W`/`B`/`E` and `0`/`$`/`^` by word, `Enter`/`Tab` runs the word under the cursor through the same dispatch a MIDDLE click on it uses, `j` drops back onto the topmost pane's tagline, `Esc` leaves. No insert mode and no selection — the bar is chrome with no tail to own | pardes-specific. The topbar is not a pane, so `focusDir` can never reach it: this is a fallback on the `.Up` branch of the tagline hop, from a TAGLINE only (a body's `SPC w k`/`Ctrl-w k` keep their pane-to-pane meaning). Its whole state is one global `topbar_col: ?u16` (row 0 has no pane to hang it on), cleared by any mouse press and BEFORE the chord dispatch, because `Kill` up here frees the session the way `Del` frees a pane. The motion vocabulary is literally the tag's — both call `lineMotion` | pardes-specific | | `Ctrl-w` + `h/j/k/l`/arrows | directional pane focus prefix — normal/tty modes 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; kept alongside the leader because a pane in raw **tty** mode never sees `SPC` (the shell owns it), so this is the only keyboard way out of one. 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 (any mode) | shadows helix `Alt-n` TS sibling-select — skipped anyway (tree-sitter) | pardes-specific | | `Alt-c` | move active terminal to a fresh column (any mode) | 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/src/pardes.zig b/src/pardes.zig index 6df520ce..8a947065 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -920,6 +920,11 @@ pub const Pardes = struct { leader_on: bool = false, leader_keys: [4]u8 = undefined, leader_n: u8 = 0, + /// the TOPBAR holds the keyboard, parked at this column of the rendered + /// row-0 line. Global like leader_on for the same reason: row 0 is not a + /// pane and never will be, so its one piece of focus state cannot live on + /// one. `null` = the panes have the keyboard, which is every other frame. + topbar_col: ?u16 = null, ov_pinch_scale: f32 = 1.0, ov_touch_scroll_delta: f32 = 0.0, /// middle+left chord argument: a kept left selection captured by a left @@ -1390,7 +1395,8 @@ pub const Pardes = struct { // Left/Down/Up/Right builtins `SPC w h/j/k/l` and `Ctrl-w` run — one // focusDir. The ARROWS below keep the in-tag grapheme motion, so the // letters cost nothing. Nothing in that direction = stay put, tag and - // all: focusDir left `active` alone, so there is nothing to undo. + // all: focusDir left `active` alone, so there is nothing to undo — + // EXCEPT upwards, where "nothing" is still something (see below). const dir: ?Builtin = if (is(key, 'h')) .Left else if (is(key, 'j')) @@ -1404,13 +1410,35 @@ pub const Pardes = struct { if (dir) |d| { const from = p.active; p.runBuiltin(d, from, ""); - if (p.active == from) return; + if (p.active == from) { + // above the topmost tagline is the TOPBAR — row 0, the global + // one. It is not a pane, so focusDir can never reach it; this + // one fallback is what makes `k` walk off the top of the + // layout instead of dying there. Only from a tagline: a body's + // `SPC w k`/`Ctrl-w k` keeps its pane-to-pane meaning. + if (d == .Up) { + exitTagEdit(pane); + p.topbar_col = 0; + } + 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 if (dest.tag_edit) dest.mode = .normal; return; } + if (lineMotion(text, cur, key)) |r| setTagRange(pane, text, r); + } + + /// the one-line normal-mode motion vocabulary as a helix range over `text`, + /// or null when `key` is not one of them: arrows by grapheme, `0`/`$`/`^` + /// (Home/End) to the ends, and the word motions, which select the span they + /// traverse exactly like the body's. A pane's tag and the TOPBAR are the + /// same one-line normal mode over different bytes, so the keys are read in + /// one place; only `h`/`l` differ — a tagline spends them on the layout, + /// the topbar has no layout — and each caller answers those itself. + fn lineMotion(text: []const u8, cur: usize, key: Key) ?modal.HxRange { const target: ?usize = if (is(key, Key.left)) modal.prevGrapheme(text, cur) else if (is(key, Key.right)) @@ -1423,8 +1451,7 @@ pub const Pardes = struct { modal.firstNonWs(text) else null; - if (target) |t| return setTagRange(pane, text, .{ .anchor = t, .head = t }); - // word motions select their traversed span, exactly like the body's + if (target) |t| return .{ .anchor = t, .head = t }; const wt: ?modal.WordTarget = if (is(key, 'w')) .next_word_start else if (is(key, 'b')) @@ -1439,10 +1466,69 @@ pub const Pardes = struct { .next_long_word_end else null; - if (wt) |t| { - const range: modal.HxRange = .{ .anchor = cur, .head = modal.nextGrapheme(text, cur) }; - return setTagRange(pane, text, modal.hxWordMove(text, range, 1, t)); + const t = wt orelse return null; + return modal.hxWordMove(text, .{ .anchor = cur, .head = modal.nextGrapheme(text, cur) }, 1, t); + } + + /// normal mode ON the topbar — row 0, where `k` off a top-row tagline + /// lands. The same one-line vocabulary a tag has (lineMotion), plus `h`/`l` + /// as plain grapheme motion: up here they have no neighbouring window to + /// walk to, so they cost nothing. Enter/Tab runs the word under the cursor + /// through the very dispatch a middle click on it uses, and `j` drops back + /// onto a tagline. The bar is chrome with no tail of its own, so there is + /// deliberately no insert mode and no selection: focus, move, run, leave. + fn topbarKey(p: *Pardes, key: Key) void { + var tb_buf: [1200]u8 = undefined; + const bar = p.topbar(&tb_buf); + // 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 (is(key, Key.escape)) { + p.topbar_col = null; // the active pane still has its body focus + return; + } + // the chord: run the word under the cursor, exactly as a middle click + // on it does. Leave the bar FIRST — `Kill` lives up here and tears the + // session down, the same hazard the pane-tag chord has with `Del`. + if (is(key, Key.enter) or is(key, Key.tab)) { + const word = wordAtCol(bar, @intCast(cur)); + p.topbar_col = null; + if (word.len > 0) p.actOnSelection(.middle, p.active, word, null); + return; } + // j — back down onto a tagline, the mirror of the k that got you here: + // the pane you came from if it still holds the top row, else the + // leftmost pane that does. Recomputed, never remembered, so a pane + // deleted while the bar had focus strands nobody. + if (is(key, 'j')) { + var dest: ?usize = null; + for (p.panes, 0..) |slot, i| { + if (slot == null or p.rects[i].y != TOPBAR_H) continue; + if (i == p.active) { + dest = i; + break; + } + if (dest == null or p.rects[i].x < p.rects[dest.?].x) dest = i; + } + const d = dest orelse return; + const pane = p.panes[d].?; + p.topbar_col = null; + p.active = d; + p.enterTagEdit(pane, -1); + if (pane.tag_edit) pane.mode = .normal; + return; + } + const col: ?usize = if (is(key, 'h')) + modal.prevGrapheme(bar, cur) + else if (is(key, 'l')) + modal.nextGrapheme(bar, cur) + else if (lineMotion(bar, cur, key)) |r| + modal.hxCursor(bar, r) + else + null; + // never past the last cell: there is nothing to append up here, so the + // block cursor stays ON a character (which is where `$` already lands) + if (col) |c| p.topbar_col = @intCast(@min(c, modal.prevGrapheme(bar, bar.len))); } /// tag + '\n' + body: the full selectable pane text (row 0 = the tag) @@ -1513,6 +1599,10 @@ pub const Pardes = struct { } fn handleKey(p: *Pardes, key: Key) void { + // the topbar holds the keyboard (`k` off the topmost tagline): row 0 is + // its own one-line normal mode and owns every key until Esc or a chord. + // Before the pane lookup because it needs no pane — that is the point. + if (p.topbar_col != null) return p.topbarKey(key); const pane = p.panes[p.active] orelse return; // a SPC leader in flight swallows the next key, whatever it is — // before Ctrl-w, so a modified key abandons the sequence instead of @@ -3935,6 +4025,10 @@ pub const Pardes = struct { p.chord_arg = null; } } + // ...and any press at all takes the keyboard back off the topbar: the + // mouse names where focus goes, so leaving a cursor parked on row 0 + // while you click into a pane would just be a lie + if (m.kind == .press) p.topbar_col = null; switch (m.button) { .none => {}, // hover tracked above; motion has no other meaning @@ -5456,6 +5550,12 @@ pub const Pardes = struct { s.fill(0, 0, s.cols, TOPBAR_H, .{ .bg = .{ .rgb = th.tag_bg } }); var tb_buf: [1200]u8 = undefined; _ = s.print(0, 0, s.cols, p.topbar(&tb_buf), .{ .fg = .{ .rgb = th.tag_fg }, .bg = .{ .rgb = th.tag_bg } }); + // the topbar's cursor, if it has the keyboard. AFTER the pane loop on + // purpose: there is exactly one Surface cursor and the bar's must beat + // the active pane's. Always a block — the bar has no insert mode. + if (p.topbar_col) |c| if (c < s.cols) { + s.cursor = .{ .x = c, .y = 0, .bar = false }; + }; // resize-handle hint / drag previews: a dash overlay that keeps the // underlying colors (border drags + hover), or the move indicator diff --git a/src/tutor.txt b/src/tutor.txt index c263015b..2d0e951a 100644 --- a/src/tutor.txt +++ b/src/tutor.txt @@ -124,7 +124,9 @@ ITS tagline, still in normal mode — you walk the taglines of the screen and never drop through a body. (Same moves as SPC w h/j/k/l and Ctrl-w h/j/k/l; nothing that way = you stay put.) The ARROWS are the in-tag - motion, and w/b/e/0/$/^ still walk the tag's own words. + motion, and w/b/e/0/$/^ still walk the tag's own words. Off the TOPMOST + tagline k keeps going: above it is the top bar, which is a tagline too + — the screen's own — and j comes back down. The MODE + path part of a tag is live chrome, so it cannot be edited — but it CAN be selected, by motion or by dragging across it: 0 then W/E selects the path, `y` yanks it, Enter looks it. Typing or backspacing @@ -135,9 +137,14 @@ across the top of the screen holds the window-agnostic builtins: Kill Newcol Tutor Debug NextColor Dump Middle-click "Newcol" for a new column, "Tutor" to spawn this tutor, - "Kill" to quit; Debug toggles the stats overlay and NextColor cycles - the theme: "helix" (the default — a near-black page and chrome that - barely lifts off it, copied from the helix editor's own theme), then + "Kill" to quit. From the KEYBOARD the bar is `k` off the topmost + tagline: it takes the cursor, h/l and the arrows move by character, + w/b/e and 0/$ by word, Enter or Tab runs the word under the cursor + exactly as a middle-click on it would, j drops back onto the tagline + below and Esc leaves. There is nothing to type up here — the bar is + chrome, so it has no insert mode. Debug toggles the stats overlay + and NextColor cycles the theme: "helix" (the default — a near-black + page and chrome that barely lifts off it, copied from helix's own), then "dark", then the acme-light yellow. The bar holds only what you reach for often — the rest of the builtins are words you execute and keys you press. Every one of them has a KEY PATH under SPC (part 3.7): @@ -526,6 +533,7 @@ 2-1 = middle-exec with the left selection as argument file tag = mode + path + "Save Del"; other tags show Del top bar = Kill Newcol Tutor Debug NextColor Dump + (keyboard: k off the topmost tagline) WEB TOUCH: one-finger tap = LOOK; drag = natural scroll LOOK opens URLs or embedded tracked .zig source (read-only) published TTY dump lists every source; tree-sitter colors it @@ -537,7 +545,8 @@ u undo U redo Enter=look Tab=execute : = the tag as a command line (arrows/words move in it, hjkl walk to the neighbouring TAGLINE, y/Enter act on - the selection; the mode+path selects but never edits) + the selection; the mode+path selects but never edits; + k off the TOPMOST tagline = the top bar, j back down) zt zz zb Ctrl-d/u Ctrl-f Ctrl-w hjkl Alt-n/c SPC = the leader: a key path runs a builtin (SPC ? lists them; SPC k Kill, SPC d Del, SPC f s Save, SPC f f Find, diff --git a/test/snapshots/tagnav.golden b/test/snapshots/tagnav.golden index 2b00eba5..77d5a846 100644 --- a/test/snapshots/tagnav.golden +++ b/test/snapshots/tagnav.golden @@ -184,7 +184,100 @@ | | | -== snap up-edge grid=100x30 cursor=39,1 +== snap up-edge grid=100x30 cursor=0,0 +|Kill Newcol Tutor Debug NextColor Dump +| NOR /tmp/pardes-snap/tagnav/cwd/w.txt Save Del NOR /tmp/pardes-snap/tagnav/cwd Del +| 1 alpha +| 2 beta w.txt +| 3 gamma +| +| +| +| +| +| +| +| +| +| +| +| NOR /tmp/pardes-snap/tagnav/cwd Del +| +| w.txt +| +| +| +| +| +| +| +| +| +| +| +== snap topbar-word grid=100x30 cursor=4,0 +|Kill Newcol Tutor Debug NextColor Dump +| NOR /tmp/pardes-snap/tagnav/cwd/w.txt Save Del NOR /tmp/pardes-snap/tagnav/cwd Del +| 1 alpha +| 2 beta w.txt +| 3 gamma +| +| +| +| +| +| +| +| +| +| +| +| NOR /tmp/pardes-snap/tagnav/cwd Del +| +| w.txt +| +| +| +| +| +| +| +| +| +| +| +== snap topbar-graphemes grid=100x30 cursor=6,0 +|Kill Newcol Tutor Debug NextColor Dump +| NOR /tmp/pardes-snap/tagnav/cwd/w.txt Save Del NOR /tmp/pardes-snap/tagnav/cwd Del +| 1 alpha +| 2 beta w.txt +| 3 gamma +| +| +| +| +| +| +| +| +| +| +| +| NOR /tmp/pardes-snap/tagnav/cwd Del +| +| w.txt +| +| +| +| +| +| +| +| +| +| +| +== snap back-down grid=100x30 cursor=39,1 |Kill Newcol Tutor Debug NextColor Dump | NOR /tmp/pardes-snap/tagnav/cwd/w.txt Save Del NOR /tmp/pardes-snap/tagnav/cwd Del | 1 alpha @@ -370,3 +463,127 @@ | | | +== snap topbar-dollar grid=100x30 cursor=37,0 +|Kill Newcol Tutor Debug NextColor Dump +| NOR /tmp/pardes-snap/tagnav/cwd/w.txt Save Del TTY /tmp/pardes-snap/tagnav/cwd Del +| 1 alpha $ ls +| 2 beta w.txt +| 3 gamma $ +| +| +| +| +| +| +| +| +| +| +| +| NOR /tmp/pardes-snap/tagnav/cwd Del +| +| w.txt +| +| +| +| +| +| +| +| +| +| +| +== snap topbar-exec grid=100x30 cursor=7,2 +|Kill Newcol Tutor Debug NextColor Dump Restore /tmp/pardes-dump.zon +| NOR /tmp/pardes-snap/tagnav/cwd/w.txt Save Del TTY /tmp/pardes-snap/tagnav/cwd Del +| 1 alpha $ ls +| 2 beta w.txt +| 3 gamma $ +| +| +| +| +| +| +| +| +| +| +| +| NOR /tmp/pardes-snap/tagnav/cwd Del +| +| w.txt +| +| +| +| +| +| +| +| +| +| +| +== snap topbar-restore-word grid=100x30 cursor=39,0 +|Kill Newcol Tutor Debug NextColor Dump Restore /tmp/pardes-dump.zon +| NOR /tmp/pardes-snap/tagnav/cwd/w.txt Save Del TTY /tmp/pardes-snap/tagnav/cwd Del +| 1 alpha $ ls +| 2 beta w.txt +| 3 gamma $ +| +| +| +| +| +| +| +| +| +| +| +| NOR /tmp/pardes-snap/tagnav/cwd Del +| +| w.txt +| +| +| +| +| +| +| +| +| +| +| +== snap topbar-restored grid=100x30 cursor=7,2 +|Kill Newcol Tutor Debug NextColor Dump +| NOR /tmp/pardes-snap/tagnav/cwd/w.txt Save Del TTY /tmp/pardes-snap/tagnav/cwd Del +| 1 alpha $ ls +| 2 beta w.txt +| 3 gamma $ +| +| +| +| +| +| +| +| +| +| +| +| NOR /tmp/pardes-snap/tagnav/cwd Del +| +| w.txt +| +| +| +| +| +| +| +| +| +| +| diff --git a/test/snapshots/tagnav.snap b/test/snapshots/tagnav.snap index 6ba86c08..fbb9c917 100644 --- a/test/snapshots/tagnav.snap +++ b/test/snapshots/tagnav.snap @@ -42,11 +42,28 @@ snap down-tag key k stable 400 5000 snap up-tag -# k again: nothing above the file. focusDir leaves `active` alone, so the move -# is a plain no-op and the tag KEEPS focus — you stay exactly where you were. +# k again: nothing above the file — but "nothing" is not nowhere. Above the +# topmost tagline is the TOPBAR, row 0, the global one. It is not a pane, so +# focusDir can never find it; this fallback is the one place k walks off the +# layout entirely. Evidence is the cursor=0,0 header: the bar has the keyboard +# and parks on `Kill`. key k stable 400 5000 snap up-edge +# the bar speaks the tag's own one-line vocabulary: w to the next word start +key w +stable 400 5000 +snap topbar-word +# h/l are plain graphemes up here — row 0 has no window to its left or right to +# walk to — and the arrows still mean what they always did +key l right +stable 400 5000 +snap topbar-graphemes +# j is the mirror of the k that got you here: back DOWN onto the topmost pane's +# tagline, still in normal mode, so the hop is a round trip +key j +stable 400 5000 +snap back-down # the arrows are still in-tag motion: two rights walk two columns of the tail key right right stable 400 5000 @@ -73,3 +90,34 @@ key esc settle 700 stable 400 5000 snap tty-restored +# Enter/Tab on the BAR runs the word under the cursor through the very dispatch +# a middle click on it uses, and leaves the bar FIRST — `Kill` lives up here, so +# a builtin that frees the session must never return into a focused topbar (the +# same hazard the pane-tag chord has with `Del`). `Dump` is the visible one: the +# bar GROWS a `Restore ` word, which is also the case where the line gets +# longer than the column you were parked on. +key c-w h +stable 400 5000 +key : +key k +stable 400 5000 +key $ +stable 400 5000 +snap topbar-dollar +key enter +stable 700 15000 +snap topbar-exec +# THE DELETION HAZARD, from the bar itself: `Restore` frees every pane and +# swaps the core out from under the caller. Walk back onto the word the Dump +# just grew — B twice off the tail, because the LONG word motions are the ones +# that step over a path in one go — and run it. The bar drops focus BEFORE the +# builtin, so nothing reaches for state the swap already freed. +key : +key k +stable 400 5000 +key $ B B +stable 400 5000 +snap topbar-restore-word +key enter +stable 900 15000 +snap topbar-restored -- cgit v1.3