diff options
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 286 |
1 files changed, 250 insertions, 36 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index c4893b25..7fe4d95f 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -14,6 +14,7 @@ const uucode = @import("uucode"); const modal = @import("modal.zig"); const look = @import("look.zig"); const syntax = @import("syntax.zig"); +const tracy = @import("tracy.zig"); pub const image = @import("image.zig"); pub const dump = @import("dump.zig"); @@ -31,6 +32,9 @@ pub const BOX_H: u16 = 1; pub const MINW: u16 = 10; pub const MINH: u16 = 3; 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; pub const topbar_str = "Kill Newcol Tutor Debug Colors NextColor Dump"; const pane_builtins_str = "Del"; @@ -462,6 +466,14 @@ pub const Pane = struct { vsel: CharSel = .{}, /// one pending prefix codepoint (0 = none): `g` and `z` pending: u21 = 0, + /// `/` file search: the pattern is typed into the tag tail (no popup); + /// search_at is the tail length before " /" was appended (submit/cancel + /// restore it). n/N walk the grep results in pane search_pane, starting + /// just below search_row (the results shell's prompt row at submit). + search_edit: bool = false, + search_at: u16 = 0, + search_pane: ?usize = null, + search_row: i32 = 0, /// the editable tag tail: seeded with the default builtins on first touch; /// edited in place by a modal one-line editor sharing the pane's mode tag_tail: std.ArrayList(u8) = .empty, @@ -523,9 +535,19 @@ pub const Pane = struct { } fn ensureCursorVisible(pane: *Pane) void { + // scrolloff margin, shrunk on short panes so the band stays non-empty + const margin: i32 = @min(scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); const off = pane.scroll(); const last = off + @as(i32, pane.rows) - 1; - if (pane.cur_row < off) pane.scrollBy(pane.cur_row - off) else if (pane.cur_row > last) pane.scrollBy(pane.cur_row - last); + if (pane.cur_row < off + margin) { + pane.scrollBy(pane.cur_row - margin - off); // scrollBy clamps at line 0 + } else if (pane.cur_row > last - margin) { + // don't scroll a file past EOF-at-bottom-row (vim's bottom clamp); + // terminals overshoot harmlessly — the vt clamps at the live bottom + var to = pane.cur_row + margin; + if (pane.file) |f| to = @min(to, @as(i32, @intCast(modal.lineCount(f.content) -| 1))); + pane.scrollBy(@max(0, to - last)); + } } /// the last navigable row: file -> last content line; terminal -> viewport bottom @@ -628,9 +650,11 @@ pub const Pardes = struct { /// press during an active middle select-drag; consumed on middle release chord_arg: ?[]u8 = null, - effects: [64]Effect = undefined, + /// pending effects, drained by the shell after each update. Growable: one + /// update can emit unbounded writes (a big selection executed is chunked + /// into 64-byte write effects) — a fixed ring overflows and panics. + effects: std.ArrayList(Effect) = .empty, effects_head: usize = 0, - effects_tail: usize = 0, /// modal yank register (gpa-owned); a yank also mirrors out to the system /// clipboard via the set_clipboard effect. @@ -678,6 +702,7 @@ pub const Pardes = struct { if (p.yank) |y| gpa.free(y); if (p.dump_out) |d| gpa.free(d); if (p.chord_arg) |a| gpa.free(a); + p.effects.deinit(gpa); p.scratch.deinit(); gpa.free(p.surface.cells); gpa.destroy(p); @@ -717,6 +742,7 @@ pub const Pardes = struct { fn newShell(p: *Pardes, id: usize, cwd: []const u8) !*Pane { std.debug.assert(p.panes[id] == null); + if (cwd.len > 256) return error.PathTooLong; // spawn effect cwd is a Buf(256) const pane = try p.gpa.create(Pane); pane.* = .{ .vt = try ghostty_vt.Terminal.init(p.gpa, .{ @@ -780,14 +806,16 @@ pub const Pardes = struct { } fn emit(p: *Pardes, e: Effect) void { - std.debug.assert(p.effects_tail - p.effects_head < p.effects.len); - p.effects[p.effects_tail % p.effects.len] = e; - p.effects_tail += 1; + p.effects.append(p.gpa, e) catch {}; } pub fn nextEffect(p: *Pardes) ?Effect { - if (p.effects_head == p.effects_tail) return null; - const e = p.effects[p.effects_head % p.effects.len]; + if (p.effects_head == p.effects.items.len) { + p.effects.clearRetainingCapacity(); + p.effects_head = 0; + return null; + } + const e = p.effects.items[p.effects_head]; p.effects_head += 1; return e; } @@ -840,13 +868,13 @@ pub const Pardes = struct { return std.fmt.allocPrint(arena, "img {s} Petscii {s} Ascii {s}", .{ iv.path, mode, pane_builtins_str }); } if (pane.file) |f| { - const fmode = if (pane.mode == .insert) "in" else "nm"; + const fmode = if (pane.mode == .insert) "INS" else "NOR"; return std.fmt.allocPrint(arena, "{s} {s}", .{ fmode, f.path }); } const mode = switch (pane.mode) { - .normal => "nm", - .insert => "in", - .tty => "sy", + .normal => "NOR", + .insert => "INS", + .tty => "TTY", }; return std.fmt.allocPrint(arena, "{s} {s}", .{ mode, pane.cwdSlice() }); } @@ -878,6 +906,7 @@ pub const Pardes = struct { fn exitTagEdit(pane: *Pane) void { pane.tag_edit = false; pane.tag_sel = false; + pane.search_edit = false; // an abandoned `/` input stays as tag text } fn tagSelBounds(pane: *Pane) struct { lo: usize, hi: usize } { @@ -1145,12 +1174,14 @@ pub const Pardes = struct { nt.greet = true; const f = p.layoutFindTerm(p.active).?; p.layoutInsert(f.col, f.idx + 1, free); + p.splitBelow(p.active, nt); p.active = free; return; } if (key.alt and !key.ctrl and key.cp == 'c') { const f = p.layoutFindTerm(p.active).?; if (p.ncol < MAX_COLS and p.col_n[f.col] > 1) { + p.absorbVWeight(p.active); p.layoutRemove(p.active); p.layoutAppendColumn(p.active); } @@ -1165,6 +1196,16 @@ pub const Pardes = struct { } else p.enterTty(p.active); return; } + // `/` search input in flight: Enter greps, Esc abandons; both restore + // the tag tail and hand focus back to the body. + if (pane.search_edit and (is(key, Key.enter) or is(key, Key.escape))) { + if (is(key, Key.enter)) p.submitSearch(pane); + pane.tag_tail.shrinkRetainingCapacity(@min(pane.search_at, pane.tag_tail.items.len)); + exitTagEdit(pane); + pane.mode = .normal; + pane.pending = 0; + return; + } // normal-mode Enter/Tab: the acme chords — look at (Enter) or execute // (Tab) the modal selection, else the file-ish word under the cursor // (on the tag tail when it's focused for editing, else the body). @@ -1522,6 +1563,129 @@ pub const Pardes = struct { if (is(key, 'y')) return p.normalYank(pane); if (is(key, 'u')) return p.doUndo(pane); if (is(key, 'U')) return p.doRedo(pane); + // 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); + if (pane.file != null and is(key, 'N')) return p.searchStep(pane, -1); + // terminal n/N: look the next/previous output row that looks somewhere + if (pane.isTerminal() and is(key, 'n')) return p.lookStep(pane, pl, 1); + if (pane.isTerminal() and is(key, 'N')) return p.lookStep(pane, pl, -1); + } + + // ---- file search (`/` + n/N): grep into a shell pane, look the results ---- + + /// `/` on a file pane: type the pattern into the tag tail after a " /" + /// marker — the existing modal one-line editor, visible while typing, + /// nothing that disappears. Enter/Esc are intercepted in handleKey. + fn startSearch(p: *Pardes, pane: *Pane) void { + if (!pane.tag_init) { + pane.tag_tail.appendSlice(p.gpa, file_pane_tail) catch return; + pane.tag_init = true; + } + pane.search_at = @intCast(pane.tag_tail.items.len); + pane.tag_tail.appendSlice(p.gpa, " /") catch return; + pane.search_edit = true; + pane.tag_edit = true; + pane.tag_sel = false; + pane.mode = .insert; + pane.pending = 0; + pane.tag_col = @intCast(pane.tag_tail.items.len); + } + + /// Enter on a `/` input: run `grep -nH '<pat>' <path>` in a shell in the + /// file's directory (the same path middle-click send takes), so the + /// results are ordinary persistent terminal text. Remember the results + /// pane and start n/N below its current prompt row. + fn submitSearch(p: *Pardes, pane: *Pane) void { + const f = pane.file orelse return; + const tail = pane.tag_tail.items; + const pat = std.mem.trim(u8, tail[@min(@as(usize, pane.search_at) + 2, tail.len)..], " \t\r\n"); + if (pat.len == 0) return; + const arena = p.scratch.allocator(); + var cmd: std.ArrayList(u8) = .empty; + cmd.appendSlice(arena, "grep -nH '") catch return; + for (pat) |ch| { + if (ch == '\'') cmd.appendSlice(arena, "'\\''") catch return else cmd.append(arena, ch) catch return; + } + cmd.appendSlice(arena, "' '") catch return; + cmd.appendSlice(arena, f.path) catch return; + cmd.append(arena, '\'') catch return; + const dst = p.ttyForDir(std.fs.path.dirname(f.path) orelse "/") orelse return; + const rp = p.panes[dst] orelse return; + p.padOutputBelowEdits(dst); + p.emitWrite(dst, cmd.items); + p.emitWrite(dst, "\r"); + pane.search_pane = dst; + // absolute row of the shell's cursor (the prompt echoing the grep); + // results land below it, so n scans from there — older output and + // stale results of a previous search stay behind + const sb = rp.vt.screens.active.pages.scrollbar(); + pane.search_row = @intCast(sb.total -| rp.rows + rp.vt.screens.active.cursor.y); + } + + /// n/N: select the next/previous grep result row in the results pane and + /// look it. Result rows read `path:NNN:...` and are found by their path + /// prefix; prompt/echo rows are already blanked by paneCursorLines (the + /// OSC 133 semantic row marks), so only real output is walked. + fn searchStep(p: *Pardes, pane: *Pane, delta: i32) void { + const f = pane.file orelse return; + const rid = pane.search_pane orelse return; + const rp = p.panes[rid] orelse return; + if (!rp.isTerminal()) return; + const pl = p.paneCursorLines(rp) catch return; + const nrows: i32 = @intCast(pl.lines.len); + var r = pane.search_row + delta; + while (r >= 0 and r < nrows) : (r += delta) { + const ln = pl.lines[@intCast(r)]; + if (!std.mem.startsWith(u8, ln, f.path) or ln.len <= f.path.len or ln[f.path.len] != ':') continue; + pane.search_row = r; + // select the result line in the results pane and keep it in view + rp.msel = .{ .active = true, .r0 = r, .r1 = r }; + rp.vsel.active = false; + rp.cur_row = r; + rp.cur_col = 0; + rp.cur_off = 0; + rp.cur_pinned = true; + rp.ensureCursorVisible(); + // look the leading file-ish word (path:NNN:) — the ordinary look + // path scrolls this file pane to the line + var hi: usize = 0; + while (hi < ln.len and look.isFileChar(ln[hi])) hi += 1; + p.actOnSelection(.right, rid, ln[0..hi], null); + return; + } + // no result that way (or none at all): nowhere to go, stay put + } + + /// n/N on a terminal pane (normal mode): select the next/previous output + /// row whose leading file-ish word looks somewhere — resolves to a + /// file/dir/path:NNN exactly as a right click would — and look it. The + /// scan only resolves (realpath, no pane opens, no scrolls); the one + /// chosen row gets the real look. The modal cursor is the position, so + /// the walk continues from wherever j/k left it. The look may focus what + /// it opened; focus comes back here so the next n keeps stepping. + fn lookStep(p: *Pardes, pane: *Pane, pl: PaneLines, delta: i32) void { + const id = p.active; + const nrows: i32 = @intCast(pl.lines.len); + var realbuf: [4096]u8 = undefined; + var r = pane.cur_row + delta; + while (r >= 0 and r < nrows) : (r += delta) { + const ln = pl.lines[@intCast(r)]; + var hi: usize = 0; + while (hi < ln.len and look.isFileChar(ln[hi])) hi += 1; + if (hi == 0 or look.resolve(ln[0..hi], pane.cwdSlice(), &realbuf) == .none) continue; + pane.msel = .{ .active = true, .r0 = r, .r1 = r }; + pane.vsel.active = false; + pane.cur_row = r; + pane.cur_col = 0; + pane.cur_off = 0; + pane.cur_pinned = true; + pane.ensureCursorVisible(); + p.actOnSelection(.right, id, ln[0..hi], null); + p.active = id; + return; + } + // nothing lookable that way: stay put } const InsertAt = enum { at, append, line_start, line_end, open_below, open_above }; @@ -2433,29 +2597,49 @@ pub const Pardes = struct { p.ncol += 1; } - /// Hand a dying pane's vertical weight to ONE sibling (the pane above, or - /// below for the topmost) so the rest of the column keeps its sizes — the - /// deletion mirror of splitBelow. Call while `id` is still in the layout. + /// Snap every pane in column `c` to its on-screen row count so later + /// weight edits move ONLY the panes they name: computeGeom rounds + /// round(avail*w/vsum) per pane, and with fractional weights a split or + /// absorb elsewhere in the column can jiggle a bystander by a row. + fn snapColWeights(p: *Pardes, c: usize) void { + for (0..p.col_n[c]) |k| { + const pid = p.col_terms[c][k]; + if (p.panes[pid]) |pp| pp.vweight = @floatFromInt(@max(1, p.rects[pid].h)); + } + } + + /// Hand a dying pane's rows to ONE sibling (the pane above, or below for + /// the topmost) so the rest of the column keeps its sizes bit-identical — + /// the deletion mirror of splitBelow. Call while `id` is still in the + /// layout, with rects current. fn absorbVWeight(p: *Pardes, id: usize) void { const f = p.layoutFindTerm(id) orelse return; if (p.col_n[f.col] <= 1) return; - const gone = p.panes[id] orelse return; + p.snapColWeights(f.col); const sib = if (f.idx > 0) p.col_terms[f.col][f.idx - 1] else p.col_terms[f.col][f.idx + 1]; - if (p.panes[sib]) |s| s.vweight += gone.vweight; + if (p.panes[sib]) |s| s.vweight += @as(f32, @floatFromInt(@max(1, p.rects[id].h))); } /// Open a window BELOW `src` (acme-style) without rebalancing the column: /// shrink ONLY src to its content height (cursor row kept visible) and hand - /// the freed rows to `nw` — together they fill src's old slot. + /// the freed rows to `nw` — together they fill src's old slot and the other + /// panes keep their sizes bit-identical (weights snap to row counts). fn splitBelow(p: *Pardes, src_id: usize, nw: *Pane) void { const src = p.panes[src_id] orelse return; const src_h = p.rects[src_id].h; const body: u16 = if (src_h > BOX_H) src_h - BOX_H else 1; const cur: u16 = if (src.file != null) body / 2 else @as(u16, @intCast(src.vt.screens.active.cursor.y)) + 1; - const keep = std.math.clamp(cur, 1, @max(1, body -| 1)); - const w = src.vweight; - src.vweight = w * @as(f32, @floatFromInt(BOX_H + keep)) / @as(f32, @floatFromInt(src_h)); - nw.vweight = w - src.vweight; + // cap keep so a content-full source still leaves the new pane a tag + + // a few body rows (an Alt-n from a full shell was born 0 rows tall) + const keep = std.math.clamp(cur, 1, @max(1, body -| (BOX_H + 3))); + if (p.layoutFindTerm(src_id)) |f| for (0..p.col_n[f.col]) |k| { + const pid = p.col_terms[f.col][k]; + if (p.panes[pid]) |pp| if (pp != nw) { + pp.vweight = @floatFromInt(@max(1, p.rects[pid].h)); + }; + }; + src.vweight = @floatFromInt(BOX_H + keep); + nw.vweight = @floatFromInt(@max(1, src_h -| (BOX_H + keep))); } /// a shell nobody has touched: no typing, cursor on the first prompt line, @@ -2482,6 +2666,7 @@ pub const Pardes = struct { if (n_tty != 1) return; const tt = p.panes[tty_id] orelse return; if (!isPristineTty(tt)) return; + p.computeGeom(); // a just-stacked doc has no rect yet; absorb snaps to rows p.absorbVWeight(tty_id); p.layoutRemove(tty_id); p.deinitPane(tt); @@ -2501,6 +2686,7 @@ pub const Pardes = struct { const nt = p.newShell(free, dir) catch return null; nt.greet = false; const rc = if (p.ncol > 0) p.ncol - 1 else 0; + if (p.col_n[rc] > 0) p.splitBelow(p.col_terms[rc][p.col_n[rc] - 1], nt); p.layoutInsert(rc, p.col_n[rc], free); return free; } @@ -2558,18 +2744,17 @@ pub const Pardes = struct { } /// stack a fresh doc pane at the top of the LEFT column (acme convention: - /// files left, terminals right) with a fair share, then evict a leftover - /// pristine shell + /// 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 fn stackDocLeft(p: *Pardes, free: usize, nt: *Pane) void { const lc = 0; - const ln = p.col_n[lc]; - if (ln > 0) { - var vsum: f32 = 0; - for (0..ln) |i| if (p.panes[p.col_terms[lc][i]]) |pp| { - vsum += pp.vweight; - }; - nt.vweight = vsum / @as(f32, @floatFromInt(ln)); - } + if (p.col_n[lc] > 0) if (p.panes[p.col_terms[lc][0]]) |top| { + p.snapColWeights(lc); + const h = p.rects[p.col_terms[lc][0]].h; + nt.vweight = @floatFromInt(@max(1, h / 2)); + top.vweight = @floatFromInt(@max(1, h -| h / 2)); + }; p.layoutInsert(lc, 0, free); p.active = free; p.evictLonePristineTty(lc, free); @@ -2607,7 +2792,7 @@ pub const Pardes = struct { }; switch (look.resolve(txt, cwd, &realbuf)) { .none => {}, - .url => |u| p.emit(.{ .open_link = .from(u) }), + .url => |u| if (u.len <= 256) p.emit(.{ .open_link = .from(u) }), .dir => |dir| { // focus an existing terminal on this dir, else fork one below for (p.panes, 0..) |slot, i| { @@ -2631,11 +2816,17 @@ pub const Pardes = struct { if (slot) |tt| if (tt.file) |*ff| if (std.mem.eql(u8, ff.path, target.path)) { p.active = i; if (target.line > 0 and target.line <= fileLineCount(ff.content)) { - const next = target.line - 1; + const next = (target.line - 1) -| tt.rows / 2; // center, clamp at top if (next != ff.scroll) { ff.scroll = next; ff.syntax_dirty = true; } + // land the modal cursor on the target line (and keep + // ensureCursorVisible agreeing with the recenter — + // a stale cursor would yank the view right back) + tt.cur_row = @intCast(target.line - 1); + tt.cur_col = 0; + tt.cur_pinned = true; } return; }; @@ -2643,6 +2834,11 @@ pub const Pardes = struct { const free = p.freeSlot() orelse return; const nt = p.openFileView(free, target.path, target.line) catch return; p.placeDoc(id, free, nt); + // center the target line: the pane's real body height only + // exists after placement, so lay out now and pull the + // scroll up by half a body (line 0 opens stay at the top) + p.computeGeom(); + nt.file.?.scroll -|= @max(1, p.rects[free].h -| BOX_H) / 2; }, .image => |target| { for (p.panes, 0..) |slot, i| { @@ -2918,7 +3114,7 @@ pub const Pardes = struct { if (src.scroll > 0) pane.vt.screens.active.scroll(.{ .delta_row = -@as(isize, @intCast(src.scroll)) }); } p.setCwd(i, t.cwd); - if (std.mem.startsWith(u8, src.tag, "sy ")) pane.mode = .tty; + if (std.mem.startsWith(u8, src.tag, "TTY ")) pane.mode = .tty; p.restoreTail(pane, src.tag); }, .file => { @@ -3077,6 +3273,8 @@ pub const Pardes = struct { /// re-highlight the visible window of any file whose syntax went stale /// (edit, scroll, load) — visible-range-first so big files stay snappy fn refreshDirtyHighlights(p: *Pardes) void { + const tz = tracy.zone(@src(), "refreshDirtyHighlights"); + defer tz.end(); for (p.panes) |slot| { const pane = slot orelse continue; if (pane.file == null) continue; @@ -3226,6 +3424,8 @@ pub const Pardes = struct { } fn renderPane(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, active: bool) !void { + const tz = tracy.zone(@src(), "renderPane"); + defer tz.end(); if (r.w <= GUTTER or r.h == 0) return; const s = &p.surface; const th = p.theme(); @@ -3278,7 +3478,9 @@ pub const Pardes = struct { .fg = if (th.fg) |c| .{ .rgb = c } else .default, .bg = if (th.bg) |c| .{ .rgb = c } else .default, }; + const tz_body = tracy.zone(@src(), "bodyText"); const body = try p.bodyText(arena, pane); + tz_body.end(); var it = std.mem.splitScalar(u8, body, '\n'); var i: u16 = 0; while (it.next()) |line| : (i += 1) { @@ -3288,14 +3490,24 @@ pub const Pardes = struct { // file line-number gutter: mute the first PREFIX_W columns. Cheap // chrome, not gated on colors_on; selection/cursor passes still win. + // The cursor row's number takes the tag style (same row math as the + // cursor pass below) so the eye finds the current line. if (pane.file != null) { + const goff = pane.scroll(); + const gcur = pane.vt.screens.active.cursor; + const gcrow = if (pane.cur_pinned) pane.cur_row else @as(i32, @intCast(gcur.y)) + goff; + const cur_vr: i32 = if (active and !pane.tag_edit) gcrow - goff else -1; var vr: u16 = 0; while (vr < body_h) : (vr += 1) { + const on_cursor = @as(i32, vr) == cur_vr; var c: u16 = 0; while (c < PREFIX_W and c < tw) : (c += 1) { const cell = s.at(tx + c, r.y + BOX_H + vr); cell.default = false; // paints blank gutter rows too - cell.style.fg = .{ .rgb = th.lineno }; + if (on_cursor) { + cell.style.fg = .{ .rgb = th.tag_fg }; + cell.style.bg = .{ .rgb = th.tag_bg }; + } else cell.style.fg = .{ .rgb = th.lineno }; } } } @@ -3303,6 +3515,8 @@ pub const Pardes = struct { // tree-sitter style byte; content starts after the lineno gutter if (p.colors_on) if (pane.file) |*f| { if (f.highlights.len > 0) { + const tz_recolor = tracy.zone(@src(), "synRecolor"); + defer tz_recolor.end(); var flines = std.mem.splitScalar(u8, f.content, '\n'); var skip: usize = 0; while (skip < f.scroll) : (skip += 1) { |
