diff options
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 144 |
1 files changed, 108 insertions, 36 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index ac543aeb..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"; @@ -531,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 @@ -636,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. @@ -686,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); @@ -725,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, .{ @@ -788,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; } @@ -848,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() }); } @@ -1154,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); } @@ -2575,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, @@ -2624,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); @@ -2643,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; } @@ -2700,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); @@ -2749,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| { @@ -2773,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; }; @@ -2785,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| { @@ -3060,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 => { @@ -3219,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; @@ -3368,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(); @@ -3420,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) { @@ -3430,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 }; } } } @@ -3445,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) { |
