diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-26 17:47:39 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | a4a2a96d023a5977ae7a7d07c7ee9e4fe38e9c60 (patch) | |
| tree | 72d69bce82c9e0145ae63bdf263a5ea16c705748 /src/pardes.zig | |
| parent | 2c670371a21cde5ab506eda02dc2233e20fc1fe1 (diff) | |
| download | pardes-a4a2a96d023a5977ae7a7d07c7ee9e4fe38e9c60.tar.gz pardes-a4a2a96d023a5977ae7a7d07c7ee9e4fe38e9c60.zip | |
Small ux improvements, : tagline focus
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 220 |
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 |
