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 | |
| parent | 2c670371a21cde5ab506eda02dc2233e20fc1fe1 (diff) | |
| download | pardes-a4a2a96d023a5977ae7a7d07c7ee9e4fe38e9c60.tar.gz pardes-a4a2a96d023a5977ae7a7d07c7ee9e4fe38e9c60.zip | |
Small ux improvements, : tagline focus
Diffstat (limited to 'src')
| -rw-r--r-- | src/pardes.zig | 220 | ||||
| -rw-r--r-- | src/tutor.txt | 21 |
2 files changed, 194 insertions, 47 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 diff --git a/src/tutor.txt b/src/tutor.txt index e2dfb123..cf9ecab0 100644 --- a/src/tutor.txt +++ b/src/tutor.txt @@ -108,10 +108,14 @@ THE TAG: each pane has a one-line tag: its MODE (NOR/INS/TTY) + directory or file path + builtins. File panes show "Save Del" by default; Save writes the current file to disk, Del closes the window. Clicking a tag - edits it, ALWAYS in insert mode (a one-line tag has no use for normal - mode): type straight in, Enter looks / Tab executes the word at the - cursor, Esc hands focus back to the body. Terminal and - image panes show "Del". "Delcol" still exists as a command: type it in + edits it in insert mode: type straight in, Enter looks / Tab executes + the word at the cursor, Esc hands focus back to the body. `:` from the + body focuses that same tag in NORMAL mode, parked at its start — vim's + command line with acme's words in it: `:w<Tab>` is w onto "Save", Tab + to execute it, and you land back in the body where you left off (the + chord keys are the same here as anywhere else; `i` if you would rather + type a command than walk to one). Terminal and image panes show "Del". + "Delcol" still exists as a command: type it in a tag or body and execute it to close the whole column. A SEPARATE bar across the top of the screen holds the window-agnostic builtins: Kill Newcol Tutor Debug Colors NextColor @@ -132,6 +136,11 @@ when the pane is focused) to MOVE a window between columns / reorder - the scrollbar is the gutter below the box: left-click scrolls UP to that point, right-click scrolls DOWN + - the FIRST file/image/tutor you open takes a new leftmost column of + its own (nothing is displaced, the other columns just narrow); every + later one splits below a doc already open, so docs share that column + - closing a window (Del, or its shell exiting) hands focus back to the + window you were in before it, not to whichever one comes first (Mouse actions are hard to unit-test from text; native gestures are covered by the e2e suite. Browser touch and embedded-source LOOK are @@ -196,7 +205,8 @@ terminal into the live shell is the tty toggle (Ctrl-b by default; see Part 2). - Enter / Tab in normal mirror the mouse: Enter = look (open the path - under the cursor), Tab = execute. `u` undo, `U` redo. + under the cursor), Tab = execute. `:` runs a command from the tag + (see Part 1). `u` undo, `U` redo. - Editing a file pane MUTATES real content; a terminal pane yanks rendered text and pastes it as an insertion run (shell output can't be deleted, only pasted text can). @@ -441,6 +451,7 @@ the input is empty, mid-text otherwise) KEYS (helix): h j k l w b e 0 $ ^ gg G v x d c y p i a I A o O u undo U redo Enter=look Tab=execute + : = the tag as a command line (motions, Enter, back) zt zz zb Ctrl-d/u Ctrl-f Ctrl-w hjkl Alt-n/c vs Helix: no multi-cursor; selection is LINE-first (x), plus v chars. |
