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 --- src/pardes.zig | 114 +++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 107 insertions(+), 7 deletions(-) (limited to 'src/pardes.zig') 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 -- cgit v1.3