summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig114
1 files changed, 107 insertions, 7 deletions
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 <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 (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