summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-26 17:47:39 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commita4a2a96d023a5977ae7a7d07c7ee9e4fe38e9c60 (patch)
tree72d69bce82c9e0145ae63bdf263a5ea16c705748 /src/pardes.zig
parent2c670371a21cde5ab506eda02dc2233e20fc1fe1 (diff)
downloadpardes-a4a2a96d023a5977ae7a7d07c7ee9e4fe38e9c60.tar.gz
pardes-a4a2a96d023a5977ae7a7d07c7ee9e4fe38e9c60.zip
Small ux improvements, : tagline focus
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig220
1 files changed, 178 insertions, 42 deletions
diff --git a/src/pardes.zig b/src/pardes.zig
index 16193967..e32e2869 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -35,6 +35,11 @@ pub const PREFIX_W: u16 = 5;
/// vim 'scrolloff': keyboard cursor moves keep this many context rows visible
/// above/below the cursor (clamped at file boundaries and short panes)
const scroll_off = 3;
+/// which key runs which acme chord — everywhere it exists: normal mode, the
+/// tag, and the `:` command line. false (the default) is the original binding,
+/// Enter looks / Tab executes (so a command runs with `:w<Tab>`); true swaps
+/// them for the vim reading.
+pub const swap_enter_tab = false;
pub const topbar_str = "Kill Newcol Tutor Debug Colors NextColor Crt Dump";
const pane_builtins_str = "Del";
@@ -704,6 +709,11 @@ pub const Pardes = struct {
col_w: [MAX_COLS]u16 = undefined,
active: usize = 0,
+ /// keyboard focus history, least-recent first, `active` last: rebuilt each
+ /// sync (dead slots pruned, ids are reused). A closing pane hands focus
+ /// back to the last live one instead of whatever slot comes first.
+ focus_hist: [MAX_PANES]usize = undefined,
+ nfocus: usize = 0,
theme_idx: usize = 0,
colors_on: bool = true,
show_debug: bool = false,
@@ -1079,11 +1089,12 @@ pub const Pardes = struct {
exitTagEdit(pane); // the tag is ALWAYS insert; Esc leaves it
return;
}
- // Enter looks / Tab executes the selection or the word at the cursor
+ // the acme chords on the selection or the word at the cursor
if (is(key, Key.enter) or is(key, Key.tab)) {
if (p.tagChordText(pane)) |txt| {
pane.tag_sel = false;
- p.actOnSelection(if (is(key, Key.tab)) .middle else .right, p.active, txt, null);
+ const btn: Mouse.Button = if (is(key, Key.enter) == swap_enter_tab) .middle else .right;
+ p.actOnSelection(btn, p.active, txt, null);
}
return;
}
@@ -1112,6 +1123,83 @@ pub const Pardes = struct {
}
}
+ /// write a helix range back onto the tag cursor + selection: the tail's
+ /// one-line mirror of setPaneRange.
+ fn setTagRange(pane: *Pane, r: modal.HxRange) void {
+ const text = pane.tag_tail.items;
+ const lo = @min(r.anchor, r.head);
+ const hi = @max(r.anchor, r.head);
+ pane.tag_col = @intCast(modal.hxCursor(text, r));
+ pane.tag_sel = hi > lo + 1; // a 1-wide range IS the block cursor
+ if (pane.tag_sel) pane.tag_anchor = @intCast(if (r.head > r.anchor) lo else hi - 1);
+ }
+
+ /// normal mode ON the tag — where `:` lands. The body's own helix motions
+ /// with the tail as a one-line document, plus insert entry and the acme
+ /// chords; editing keys stay in insert (`i` then type, like the mouse path).
+ fn tagNormalKey(p: *Pardes, pane: *Pane, key: Key) void {
+ const text = pane.tag_tail.items;
+ 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 (is(key, Key.escape)) return exitTagEdit(pane);
+ // the chord: run the selection (or the word under the cursor) and drop
+ // back into the body — the whole point of `:`. Same keys as everywhere
+ // else (swap_enter_tab), so the command line is `:w<Tab>` by default.
+ // Nothing under the cursor means nothing ran: the tag keeps focus.
+ if (is(key, Key.enter) or is(key, Key.tab)) {
+ const btn: Mouse.Button = if (is(key, Key.enter) == swap_enter_tab) .middle else .right;
+ const txt = p.tagChordText(pane) orelse return;
+ const id = p.active;
+ exitTagEdit(pane); // leave the tag FIRST: Del frees the pane
+ p.actOnSelection(btn, id, txt, null);
+ return;
+ }
+ // insert entry (one line, so I/A are the tail's ends)
+ if (is(key, 'i') or is(key, 'a') or is(key, 'I') or is(key, 'A')) {
+ pane.tag_col = @intCast(switch (key.cp) {
+ 'a' => modal.nextGrapheme(text, cur),
+ 'I' => 0,
+ 'A' => text.len,
+ else => cur,
+ });
+ pane.tag_sel = false;
+ pane.mode = .insert;
+ return;
+ }
+ const target: ?usize = if (is(key, 'h') or is(key, Key.left))
+ modal.prevGrapheme(text, cur)
+ else if (is(key, 'l') or is(key, Key.right))
+ modal.nextGrapheme(text, cur)
+ else if (is(key, '0') or is(key, Key.home))
+ 0
+ else if (is(key, '$') or is(key, Key.end))
+ modal.prevGrapheme(text, text.len)
+ else if (is(key, '^'))
+ modal.firstNonWs(text)
+ else
+ null;
+ if (target) |t| return setTagRange(pane, .{ .anchor = t, .head = t });
+ // word motions select their traversed span, exactly like the body's
+ const wt: ?modal.WordTarget = if (is(key, 'w'))
+ .next_word_start
+ else if (is(key, 'b'))
+ .prev_word_start
+ else if (is(key, 'e'))
+ .next_word_end
+ else if (is(key, 'W'))
+ .next_long_word_start
+ else if (is(key, 'B'))
+ .prev_long_word_start
+ else if (is(key, 'E'))
+ .next_long_word_end
+ else
+ null;
+ if (wt) |t| {
+ const range: modal.HxRange = .{ .anchor = cur, .head = modal.nextGrapheme(text, cur) };
+ return setTagRange(pane, modal.hxWordMove(text, range, 1, t));
+ }
+ }
+
/// d/c: delete the selection (or the char under the cursor), yanking it;
/// c then enters insert
/// tag + '\n' + body: the full selectable pane text (row 0 = the tag)
@@ -1253,12 +1341,19 @@ pub const Pardes = struct {
pane.pending = 0;
return;
}
- // normal-mode Enter/Tab: the acme chords — look at (Enter) or execute
- // (Tab) the EXPLICIT modal selection (v/x/X, terminal n/N, search
- // n/N); implicit motion residue falls back to the file-ish word
- // under the cursor (on the tag tail when it's focused, else the body).
+ // tag editing intercepts every other key: a modal one-line editor over
+ // the tail, sharing the pane's mode. Above the body chords — a focused
+ // tag owns Enter/Tab too (that IS the `:` command line).
+ if (pane.tag_edit) {
+ if (pane.mode == .normal) p.tagNormalKey(pane, key) else p.tagInsertKey(pane, key);
+ return;
+ }
+ // normal-mode Enter/Tab: the acme chords — look at / execute (which
+ // key is which is swap_enter_tab) the EXPLICIT modal selection (v/x/X,
+ // terminal n/N, search n/N); implicit motion residue falls back to the
+ // file-ish word under the cursor.
if (pane.mode == .normal and (is(key, Key.enter) or is(key, Key.tab))) {
- const btn: Mouse.Button = if (is(key, Key.tab)) .middle else .right;
+ const btn: Mouse.Button = if (is(key, Key.enter) == swap_enter_tab) .middle else .right;
pane.pinCursor();
const explicit = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active;
if (explicit) {
@@ -1276,12 +1371,6 @@ pub const Pardes = struct {
p.actOnSelection(btn, p.active, word, null);
return;
}
- // tag editing intercepts every other key: a modal one-line editor
- // over the tail, sharing the pane's mode.
- if (pane.tag_edit) {
- p.tagInsertKey(pane, key);
- return;
- }
// normal-mode `p`/`P` paste the core's yank register directly (helix:
// the default register, NOT the system clipboard — most terminals
// refuse the OSC 52 read, so a round trip would never come back).
@@ -2044,6 +2133,16 @@ pub const Pardes = struct {
if (isC(key, 'x')) return p.normalAdjustNumber(pane, -@as(i64, @intCast(cnt)));
if (is(key, 'u')) return p.doUndo(pane);
if (is(key, 'U')) return p.doRedo(pane);
+ // `:` — vim's command line with acme's vocabulary: focus the pane's own
+ // tag in normal mode, parked at the tail's start. Motions walk the
+ // words and the execute chord runs the one under the cursor, then
+ // hands the body back (`:w<Tab>` = Save). Always the start, not the
+ // remembered tag column, so `:w` is the same keystrokes every time.
+ if (is(key, ':')) {
+ p.enterTagEdit(pane, 0);
+ if (pane.tag_edit) pane.mode = .normal;
+ return;
+ }
// file search: `/` types a pattern into the tag, n/N walk the results
if (pane.file != null and is(key, '/')) return p.startSearch(pane);
if (pane.file != null and is(key, 'n')) return p.searchStep(pane, 1);
@@ -4012,8 +4111,20 @@ pub const Pardes = struct {
}
fn layoutAppendColumn(p: *Pardes, id: usize) void {
+ p.layoutInsertColumn(p.ncol, id);
+ }
+
+ /// a fresh single-pane column at index `at` (the columns from there on
+ /// shift right); an equal weight, so the others narrow proportionally
+ fn layoutInsertColumn(p: *Pardes, at: usize, id: usize) void {
if (p.ncol >= MAX_COLS) return;
- const c = p.ncol;
+ const c = @min(at, p.ncol);
+ var j = p.ncol;
+ while (j > c) : (j -= 1) {
+ p.col_terms[j] = p.col_terms[j - 1];
+ p.col_n[j] = p.col_n[j - 1];
+ p.col_weight[j] = p.col_weight[j - 1];
+ }
p.col_weight[c] = 1;
p.col_terms[c][0] = id;
p.col_n[c] = 1;
@@ -4212,7 +4323,9 @@ pub const Pardes = struct {
/// stack a fresh doc pane at the top of the LEFT column (acme convention:
/// files left, terminals right), halving ONLY the old top pane's slot so
/// the rest of the column keeps its sizes; then evict a leftover pristine
- /// shell
+ /// shell. The placement of last resort — a first doc normally takes a
+ /// column of its own (placeDoc), and this only runs when the column bar
+ /// is full.
fn stackDocLeft(p: *Pardes, free: usize, nt: *Pane) void {
const lc = 0;
if (p.col_n[lc] > 0) if (p.panes[p.col_terms[lc][0]]) |top| {
@@ -4410,9 +4523,7 @@ pub const Pardes = struct {
p.layoutRemove(id);
p.deinitPane(pane);
p.panes[id] = null;
- if (p.active == id) p.active = for (p.panes, 0..) |sl, i| {
- if (sl != null) break i;
- } else {
+ if (p.active == id) p.active = p.prevFocus(id) orelse {
p.quit = true;
p.emit(.quit);
return;
@@ -4430,9 +4541,7 @@ pub const Pardes = struct {
p.panes[tid] = null;
}
}
- if (p.panes[p.active] == null) p.active = for (p.panes, 0..) |sl, i| {
- if (sl != null) break i;
- } else {
+ if (p.panes[p.active] == null) p.active = p.prevFocus(p.active) orelse {
p.quit = true;
p.emit(.quit);
return;
@@ -4441,26 +4550,18 @@ pub const Pardes = struct {
.tutor => {
const free = p.freeSlot() orelse return;
const nt = p.openTutorView(free) catch return;
- p.stackDocLeft(free, nt);
+ p.placeDoc(id, free, nt); // a doc like any other
},
}
}
- /// place a fresh doc pane. With a single column there is no separate file
- /// column, so split BELOW the pane the look came from — the same acme split
- /// as a dir open (splitParent picks a taller pane if the source is too
- /// short). With two+ columns, open in the file column: split an existing
- /// doc (the source if it's one, else the first found), or take the left
- /// column when no doc exists yet.
+ /// place a fresh doc pane. Any doc already open is the split parent (the
+ /// source if it IS one, else the first found) and the newcomer lands right
+ /// below it, so all docs share one column. The FIRST doc of the session
+ /// instead gets a column of its own on the left (acme: files left, shells
+ /// right) — nothing is displaced, the other columns just narrow. Only a
+ /// full column bar falls back to stacking into the leftmost one.
fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void {
- if (p.ncol == 1) {
- const src = p.splitParent(from_id);
- const f = p.layoutFindTerm(src).?;
- p.layoutInsert(f.col, f.idx + 1, free);
- p.splitBelow(src, nt);
- p.active = free;
- return;
- }
var src_id: ?usize = null;
if (p.panes[from_id]) |src| if (src.file != null or src.image != null) {
src_id = from_id;
@@ -4477,9 +4578,14 @@ pub const Pardes = struct {
p.layoutInsert(sf.col, sf.idx + 1, free);
p.splitBelow(src, nt);
p.active = free;
- } else {
- p.stackDocLeft(free, nt);
+ return;
}
+ if (p.ncol < MAX_COLS) {
+ p.layoutInsertColumn(0, free);
+ p.active = free;
+ return;
+ }
+ p.stackDocLeft(free, nt);
}
// ---- dump / load (acme-style: `pardes -l state.zon` restores a session) ----
@@ -4698,19 +4804,48 @@ pub const Pardes = struct {
p.layoutRemove(id);
p.deinitPane(pane);
p.panes[id] = null;
- if (p.active == id) p.active = for (p.panes, 0..) |slot, i| {
- if (slot != null) break i;
- } else {
+ if (p.active == id) p.active = p.prevFocus(id) orelse {
p.quit = true;
p.emit(.quit);
return;
};
}
+ /// where focus falls when the active pane closes: the most recently
+ /// focused pane still alive (else any live one). Null = nothing left.
+ fn prevFocus(p: *Pardes, closing: usize) ?usize {
+ var i = p.nfocus;
+ while (i > 0) {
+ i -= 1;
+ const id = p.focus_hist[i];
+ if (id != closing and p.panes[id] != null) return id;
+ }
+ for (p.panes, 0..) |slot, k| {
+ if (slot != null and k != closing) return k;
+ }
+ return null;
+ }
+
/// Recompute geometry, push grid-size changes to each emulator + pty, fire
/// deferred greetings. The mirror of the prototype's loop epilogue.
fn sync(p: *Pardes) void {
p.computeGeom();
+ // focus history, rebuilt in one pass: drop dead slots and the current
+ // active, then push active back on top. Every focus change (mouse,
+ // look, Ctrl-w, Alt-n) lands here without touching its call site.
+ {
+ var w: usize = 0;
+ for (p.focus_hist[0..p.nfocus]) |id| {
+ if (p.panes[id] == null or id == p.active) continue;
+ p.focus_hist[w] = id;
+ w += 1;
+ }
+ p.nfocus = w;
+ if (p.panes[p.active] != null) {
+ p.focus_hist[p.nfocus] = p.active;
+ p.nfocus += 1;
+ }
+ }
for (&p.panes, 0..) |*slot, id| {
const pane = slot.* orelse continue;
if (pane.reply_len > 0) {
@@ -4981,7 +5116,8 @@ pub const Pardes = struct {
// cursor while editing the tail: prefix length + column within it
if (active and pane.tag_edit) {
const cx: usize = pfx.len + pane.tag_col;
- if (cx < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(cx)), .y = r.y, .bar = true }; // tags are always insert
+ // bar while typing, block for `:` normal mode (same rule as a body)
+ if (cx < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(cx)), .y = r.y, .bar = pane.mode == .insert };
}
// image pane: the picture fills the body — petscii glyph art into the