summaryrefslogtreecommitdiff
path: root/main.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-06-29 22:33:21 -0300
committerGabriel Schneider <[email protected]>2026-06-30 08:52:01 -0300
commitb5b526f465924324e2cd4424f5bacc43e50b7673 (patch)
tree2eef9096996d0b7444f073edd8db8ac508efec20 /main.zig
parent4d5302edfefea0206ca96726f2fcc254ee228ad3 (diff)
downloadpardes-b5b526f465924324e2cd4424f5bacc43e50b7673.tar.gz
pardes-b5b526f465924324e2cd4424f5bacc43e50b7673.zip
vibe file panes now
Diffstat (limited to 'main.zig')
-rw-r--r--main.zig421
1 files changed, 357 insertions, 64 deletions
diff --git a/main.zig b/main.zig
index 17ee6b87..c5ed5085 100644
--- a/main.zig
+++ b/main.zig
@@ -88,6 +88,8 @@ const gray = struct {
// the drag button: a distinctive red (brighter when the window is focused).
const red = [3]u8{ 0xcc, 0x33, 0x33 };
const red_dim = [3]u8{ 0x66, 0x2a, 0x2a };
+// resize-border hover tint: a teal bar makes the (invisible) drag zones discoverable.
+const hover_bg = [3]u8{ 0x3a, 0x70, 0x70 };
// char-mode inserted text (bright on a faint box so it reads as an overlay).
const edit_fg = [3]u8{ 0xea, 0xea, 0xea };
const edit_bg = [3]u8{ 0x3a, 0x3a, 0x3a };
@@ -154,26 +156,54 @@ fn fork_pty(argv: []const ?[*:0]const u8, cwd: ?[*:0]const u8, ws: std.posix.win
}
fn termWrite(t: *Term, bytes: []const u8) void {
+ if (t.pty.handle < 0) return; // file pane: no pty
var off: usize = 0;
while (off < bytes.len) {
const rc = std.os.linux.write(t.pty.handle, bytes[off..].ptr, bytes.len - off);
- switch (std.posix.errno(rc)) {
- .SUCCESS => off += @intCast(rc),
- .INTR => {},
- else => break,
+ // raw syscall: -errno, not the libc -1 sentinel. Check the sign.
+ const n: isize = @bitCast(rc);
+ if (n < 0) {
+ if (n == -@as(isize, @intFromEnum(std.os.linux.E.INTR))) continue;
+ break;
}
+ off += @intCast(n);
+ }
+}
+
+fn fileLineCount(content: []const u8) usize {
+ return std.mem.count(u8, content, "\n") + 1;
+}
+
+// the pane's current scroll offset (top visible row, in body coordinates). For a
+// file it's `scroll`; for a terminal it's the emulator's scrollback offset.
+fn paneScroll(t: *Term) i32 {
+ if (t.file) |f| return @intCast(f.scroll);
+ return @intCast(t.term.screens.active.pages.scrollbar().offset);
+}
+
+fn paneScrollBy(t: *Term, delta: i32) void {
+ if (t.file) |*f| {
+ const max: i64 = @intCast(fileLineCount(f.content) -| 1);
+ const n = std.math.clamp(@as(i64, @intCast(f.scroll)) + delta, 0, max);
+ f.scroll = @intCast(n);
+ } else {
+ t.term.screens.active.scroll(.{ .delta_row = @intCast(delta) });
}
}
// The shell's current working directory (via /proc/<pid>/cwd). `buf` holds it.
+// A raw linux syscall returns -errno; check the sign directly. (std.posix.errno
+// is the LIBC convention here — `rc == -1` — and we link libc, so it misreads a
+// raw -2/ENOENT as success and we'd slice buf[0..~2^64]. file panes (pid 0) and
+// exited shells both hit this.)
fn termCwd(t: *Term, buf: []u8) []const u8 {
+ if (t.file != null) return "?"; // file pane: no shell, no cwd
var pathbuf: [64]u8 = undefined;
const path = std.fmt.bufPrintZ(&pathbuf, "/proc/{d}/cwd", .{t.pid}) catch return "?";
const rc = std.os.linux.readlinkat(std.os.linux.AT.FDCWD, path, buf.ptr, buf.len);
- return switch (std.posix.errno(rc)) {
- .SUCCESS => buf[0..rc],
- else => "?",
- };
+ const n: isize = @bitCast(rc);
+ if (n < 0) return "?";
+ return buf[0..@intCast(n)];
}
// True if `path` exists and is a directory. open(O_DIRECTORY) fails with ENOTDIR
@@ -190,6 +220,8 @@ fn isDir(path: [*:0]const u8) bool {
// The window tag: the shell's cwd followed by the builtin command names. Plain,
// selectable text (acme-style). Caller frees with `alloc`.
fn tagLine(alloc: std.mem.Allocator, t: *Term) ![]u8 {
+ // a file pane's tag is just its path (+ builtins); no tty mode/cwd.
+ if (t.file) |f| return std.fmt.allocPrint(alloc, "{s} {s}", .{ f.path, builtins_str });
var link: [1024]u8 = undefined;
const cwd = termCwd(t, &link);
// leading 2-char mode indicator: "ch" (char/edit) or "sy" (sym/terminal).
@@ -234,7 +266,7 @@ fn forkTerm(gpa: std.mem.Allocator, init_cols: u16, init_rows: u16, cwd: ?[*:0]c
t.* = .{
.pty = child.file,
.pid = child.pid,
- .term = try ghostty_vt.Terminal.init(gpa, .{ .cols = init_cols, .rows = init_rows }),
+ .term = try ghostty_vt.Terminal.init(gpa, .{ .cols = init_cols, .rows = init_rows, .max_scrollback = 16 * 1024 * 1024 }),
.stream = undefined,
.reader = .{ .any_future = null, .result = {} }, // no-op until startReader
.cols = init_cols,
@@ -244,6 +276,75 @@ fn forkTerm(gpa: std.mem.Allocator, init_cols: u16, init_rows: u16, cwd: ?[*:0]c
return t;
}
+// peel a trailing :NNN (or :NNN:) line suffix: main.zig:100 -> {main.zig, 100}.
+fn parsePathLine(tok: []const u8) struct { path: []const u8, line: usize } {
+ var sep: usize = 0;
+ while (sep < tok.len) : (sep += 1) {
+ if (tok[sep] != ':') continue;
+ var j = sep + 1;
+ var line: usize = 0;
+ while (j < tok.len and std.ascii.isDigit(tok[j])) : (j += 1) line = line * 10 + (tok[j] - '0');
+ if (j == sep + 1) continue; // no digits after ':'
+ if (j < tok.len and tok[j] != ':') continue; // junk after the number
+ return .{ .path = tok[0..sep], .line = line };
+ }
+ return .{ .path = tok, .line = 0 };
+}
+
+// slurp a whole file via linux syscalls (kept flat; no std.fs). Caller frees.
+fn readFileAlloc(gpa: std.mem.Allocator, path: [*:0]const u8) ![]u8 {
+ const l = std.os.linux;
+ const rc = l.open(path, .{ .ACCMODE = .RDONLY, .CLOEXEC = true }, 0);
+ const sfd: isize = @bitCast(rc);
+ if (sfd < 0) return error.OpenFailed;
+ const fd: i32 = @intCast(sfd);
+ defer _ = l.close(fd);
+ var buf: std.ArrayList(u8) = .empty;
+ errdefer buf.deinit(gpa);
+ var chunk: [64 * 1024]u8 = undefined;
+ while (true) {
+ const rrc = l.read(fd, &chunk, chunk.len);
+ // raw syscall: -errno, not the libc -1 sentinel. Check the sign.
+ const n: isize = @bitCast(rrc);
+ if (n < 0) {
+ if (n == -@as(isize, @intFromEnum(l.E.INTR))) continue;
+ return error.ReadFailed;
+ }
+ if (n == 0) break;
+ try buf.appendSlice(gpa, chunk[0..@intCast(n)]);
+ }
+ return buf.toOwnedSlice(gpa);
+}
+
+// Open a read/editable FILE pane: a Term with `file` set, sharing all the
+// layout/selection/edit machinery. No pty (handle -1), no reader, a stub 1x1
+// emulator (only its allocator-owned bits are touched). `line` (1-based, 0=none)
+// scrolls the target near the top. Caller wires it into the layout + frees later.
+fn openFileView(gpa: std.mem.Allocator, rp: [*:0]const u8, line: usize, cols: u16, rows: u16) !*Term {
+ const content = try readFileAlloc(gpa, rp);
+ errdefer gpa.free(content);
+ const path = try gpa.dupe(u8, std.mem.span(rp));
+ errdefer gpa.free(path);
+ const t = try gpa.create(Term);
+ errdefer gpa.destroy(t);
+ const total = fileLineCount(content);
+ const scroll: usize = if (line > 0 and line <= total) line - 1 else 0;
+ t.* = .{
+ .pty = .{ .handle = -1, .flags = .{ .nonblocking = false } },
+ .pid = 0,
+ .term = try ghostty_vt.Terminal.init(gpa, .{ .cols = 1, .rows = 1 }),
+ .stream = undefined,
+ .reader = .{ .any_future = null, .result = {} },
+ .file = .{ .path = path, .content = content, .scroll = scroll },
+ .cols = cols,
+ .rows = rows,
+ .cur_pinned = true,
+ .cur_row = @intCast(scroll),
+ };
+ t.stream = t.term.vtStream();
+ return t;
+}
+
fn startReader(io: std.Io, gpa: std.mem.Allocator, loop: *Loop, t: *Term, id: usize) !void {
t.reader = try io.concurrent(readPty, .{ io, gpa, t.pty, id, loop });
}
@@ -285,6 +386,19 @@ fn layoutInsert(l: *Layout, c: usize, idx: usize, id: usize) void {
l.col_n[c] += 1;
}
+// Before deleting `id`, hand its vertical weight to a sibling (the pane above, or
+// below if it's the first) so the freed space goes to ONE neighbor and the rest of
+// the column keeps its sizes — the deletion mirror of splitBelow. Call BEFORE
+// layoutRemove/deinit, while `id` is still in the layout and not yet freed. No-op
+// when `id` is alone in its column (the column just closes).
+fn absorbVWeight(l: *const Layout, terms: *const [MAX_TERMS]?*Term, id: usize) void {
+ const f = layoutFindTerm(l, id) orelse return;
+ if (l.col_n[f.col] <= 1) return;
+ const gone = terms[id] orelse return;
+ const sib = if (f.idx > 0) l.col_terms[f.col][f.idx - 1] else l.col_terms[f.col][f.idx + 1];
+ if (terms[sib]) |s| s.vweight += gone.vweight;
+}
+
fn layoutAppendColumn(l: *Layout, id: usize) void {
if (l.ncol >= MAX_COLS) return;
const c = l.ncol;
@@ -294,6 +408,61 @@ fn layoutAppendColumn(l: *Layout, id: usize) void {
l.ncol += 1;
}
+// Open a window BELOW `src` (acme-style) without rebalancing the whole column:
+// shrink ONLY `src` to the height of its content so far — and hand the freed
+// rows to the new window `nw`. `src`+`nw` together fill `src`'s old slot, so the
+// other panes in the column keep their proportions. `src_h` is `src`'s height.
+//
+// `keep` INCLUDES the cursor row (`cursor.y + 1`): shrinking to exactly the
+// content height means ghostty only drops blank rows *below* the cursor, so the
+// source's visible text doesn't scroll. Cutting at `cursor.y` would evict the
+// cursor's row and force a 1-row scroll to keep it visible.
+fn splitBelow(src: *Term, nw: *Term, src_h: u16) void {
+ 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.term.screens.active.cursor.y)) + 1;
+ const keep = std.math.clamp(cur, 1, @max(1, body -| 1)); // both panes get >=1 row
+ const w = src.vweight;
+ src.vweight = w * @as(f32, @floatFromInt(BOX_H + keep)) / @as(f32, @floatFromInt(src_h));
+ nw.vweight = w - src.vweight;
+}
+
+// A pristine terminal: a shell nobody has touched — no char-mode typing, the
+// cursor still on its first prompt line, and no scrollback. Used to recognize a
+// throwaway placeholder shell that a file should replace. (A file pane is never
+// pristine — it isn't a terminal.)
+fn isPristineTty(t: *Term) bool {
+ if (t.file != null) return false;
+ if (t.nedits != 0) return false;
+ if (t.term.screens.active.cursor.y != 0) return false;
+ return t.term.screens.active.pages.scrollbar().total <= t.rows;
+}
+
+// When a file lands in `col` (the files column), a leftover UNUSED shell there is
+// just clutter: if the column holds exactly one terminal and it's pristine, delete
+// it. Its weight flows to a neighbor via absorbVWeight (typically the file), so the
+// file takes its place. `keep_id` is the file, used to refocus if the shell was active.
+fn evictLonePristineTty(l: *Layout, terms: *[MAX_TERMS]?*Term, io: std.Io, gpa: std.mem.Allocator, col: usize, keep_id: usize, active: *usize) void {
+ var n_tty: usize = 0;
+ var tty_id: usize = 0;
+ var k: usize = 0;
+ while (k < l.col_n[col]) : (k += 1) {
+ const cid = l.col_terms[col][k];
+ if (terms[cid]) |ct| if (ct.file == null) {
+ n_tty += 1;
+ tty_id = cid;
+ };
+ }
+ if (n_tty != 1) return;
+ const tt = terms[tty_id] orelse return;
+ if (!isPristineTty(tt)) return;
+ absorbVWeight(l, terms, tty_id);
+ layoutRemove(l, tty_id);
+ tt.deinit(io, gpa);
+ gpa.destroy(tt);
+ terms[tty_id] = null;
+ if (active.* == tty_id) active.* = keep_id;
+}
+
// Tile the screen: columns by col_weight, then each column's windows by vweight.
// Last column/window absorbs rounding so the tiling always fills the screen.
// `out_*` written for every live term and live column.
@@ -361,7 +530,7 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a
// body = tty rows with insertion runs spliced in (text shifts, not overdrawn).
// In text mode bodyText also hides OSC 133 prompt rows (see promptFlags); the
// shells emit OSC 133 via the rc we drop at startup (writeFile/bash_rc).
- const off: i32 = @intCast(t.term.screens.active.pages.scrollbar().offset);
+ const off: i32 = paneScroll(t);
const body = try bodyText(alloc, t);
var it = std.mem.splitAny(u8, body, "\n");
var i: u16 = 0;
@@ -439,12 +608,25 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a
while (by < BOX_H) : (by += 1)
gut.writeCell(bx, by, .{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = box_bg } } });
}
- const sb = t.term.screens.active.pages.scrollbar();
const track_h: usize = if (r.h > BOX_H) r.h - BOX_H else 0;
if (track_h > 0) {
- const total: usize = if (sb.total == 0) 1 else sb.total;
- const pos = (track_h * sb.offset) / total;
- const len = @max(1, (track_h * sb.len) / total);
+ // scrollbar thumb: from file lines for a file pane, else the emulator's.
+ var sb_total: usize = undefined;
+ var sb_off: usize = undefined;
+ var sb_len: usize = undefined;
+ if (t.file) |f| {
+ sb_total = fileLineCount(f.content);
+ sb_off = f.scroll;
+ sb_len = t.rows;
+ } else {
+ const sb = t.term.screens.active.pages.scrollbar();
+ sb_total = sb.total;
+ sb_off = sb.offset;
+ sb_len = sb.len;
+ }
+ const total: usize = if (sb_total == 0) 1 else sb_total;
+ const pos = (track_h * sb_off) / total;
+ const len = @max(1, (track_h * sb_len) / total);
var gy: usize = 0;
while (gy < track_h) : (gy += 1) {
const thumb = gy >= pos and gy < pos + len;
@@ -531,6 +713,8 @@ pub fn main(init: std.process.Init) !void {
computeGeom(&layout, &terms, screen_w, screen_h, &rects, &col_x, &col_w);
var drag: Drag = .none;
+ var hover_col: u16 = 0; // last mouse position; used to tint the resize border under it
+ var hover_row: u16 = 0;
while (true) {
const event = try loop.nextEvent();
@@ -538,6 +722,8 @@ pub fn main(init: std.process.Init) !void {
.mouse => |mouse| {
const mcol: u16 = if (mouse.col < 0) 0 else @min(@as(u16, @intCast(mouse.col)), screen_w -| 1);
const mrow: u16 = if (mouse.row < 0) 0 else @min(@as(u16, @intCast(mouse.row)), screen_h -| 1);
+ hover_col = mcol;
+ hover_row = mrow;
// which pane is under the cursor (if any)
const hovered: ?usize = for (terms, 0..) |slot, i| {
@@ -547,9 +733,9 @@ pub fn main(init: std.process.Init) !void {
} else null;
if (mouse.button == .wheel_down) {
- if (hovered) |id| terms[id].?.term.screens.active.scroll(.{ .delta_row = 1 });
+ if (hovered) |id| paneScrollBy(terms[id].?, 1);
} else if (mouse.button == .wheel_up) {
- if (hovered) |id| terms[id].?.term.screens.active.scroll(.{ .delta_row = -1 });
+ if (hovered) |id| paneScrollBy(terms[id].?, -1);
} else if (mouse.type == .press and mouse.button == .left) {
var handled = false;
@@ -590,8 +776,8 @@ pub fn main(init: std.process.Init) !void {
} else if (in_gutter) {
active = id;
if (terms[id]) |t| {
- const local: i16 = @intCast(@as(i32, mrow) - @as(i32, r.y + BOX_H));
- t.term.screens.active.scroll(.{ .delta_row = -local });
+ const local: i32 = @as(i32, mrow) - @as(i32, r.y + BOX_H);
+ paneScrollBy(t, -local); // left: scroll up to here
}
} else {
active = id;
@@ -609,7 +795,14 @@ pub fn main(init: std.process.Init) !void {
const b: Button = if (mouse.button == .middle) .middle else .right;
if (hovered) |id| {
const r = rects[id];
- if (mcol >= r.x + GUTTER) {
+ if (mcol < r.x + GUTTER and mrow >= r.y + BOX_H) {
+ // gutter (scrollbar): right scrolls down, mirroring left's up
+ active = id;
+ if (terms[id]) |t| {
+ const local: i32 = @as(i32, mrow) - @as(i32, r.y + BOX_H);
+ paneScrollBy(t, if (b == .right) local else -local);
+ }
+ } else if (mcol >= r.x + GUTTER) {
active = id;
if (terms[id]) |t| {
const c: i32 = @as(i32, mcol) - @as(i32, r.x + GUTTER);
@@ -684,7 +877,14 @@ pub fn main(init: std.process.Init) !void {
};
}
},
- .move => |d| moveTerm(&layout, &rects, &col_x, &col_w, d.id, d.cur_x, d.cur_y),
+ .move => |d| {
+ moveTerm(&layout, &rects, &col_x, &col_w, d.id, d.cur_x, d.cur_y);
+ // a file moved into the left column evicts a lone unused shell there
+ if (terms[d.id]) |mt| if (mt.file != null) {
+ if (layoutFindTerm(&layout, d.id)) |f| if (f.col == 0)
+ evictLonePristineTty(&layout, &terms, io, gpa, 0, d.id, &active);
+ };
+ },
.select => |s| {
if (terms[s.id]) |t| {
t.sel[@intFromEnum(s.button)].state = .done;
@@ -694,14 +894,18 @@ pub fn main(init: std.process.Init) !void {
if (selectionText(gpa, t, t.sel[@intFromEnum(s.button)])) |txt| {
defer gpa.free(txt);
if (s.button == .right) {
- // acme "look": if the selected text resolves
- // to a directory, open (or focus) a terminal
- // there in the same column and ls it.
- const word = std.mem.trim(u8, txt, " \t\r\n");
+ // acme "look": resolve the selected word
+ // (peeling a :line suffix) relative to the
+ // window's dir. A directory opens/focuses a
+ // terminal there + ls; a file opens/focuses a
+ // file pane scrolled to :line.
+ const word_raw = std.mem.trim(u8, txt, " \t\r\n");
+ const pl = parsePathLine(word_raw);
+ const word = pl.path;
var cwdbuf: [1024]u8 = undefined;
var joinbuf: [2048]u8 = undefined;
var realbuf: [4096]u8 = undefined;
- const cwd = termCwd(t, &cwdbuf);
+ const cwd = if (t.file) |f| (std.fs.path.dirname(f.path) orelse "/") else termCwd(t, &cwdbuf);
const joined: ?[:0]u8 = if (word.len == 0) null else if (word[0] == '/')
(std.fmt.bufPrintZ(&joinbuf, "{s}", .{word}) catch null)
else
@@ -730,11 +934,54 @@ pub fn main(init: std.process.Init) !void {
startReader(io, gpa, &loop, nt, free) catch {};
const f = layoutFindTerm(&layout, s.id).?;
layoutInsert(&layout, f.col, f.idx + 1, free);
+ splitBelow(t, nt, rects[s.id].h);
active = free;
termWrite(nt, "ls\r");
} else |_| {}
}
}
+ } else {
+ const fp = std.mem.span(rp);
+ // focus an existing file pane for this path
+ var found: ?usize = null;
+ for (&terms, 0..) |*slot, i| if (slot.*) |tt| {
+ if (tt.file) |ff| if (std.mem.eql(u8, ff.path, fp)) {
+ found = i;
+ break;
+ };
+ };
+ if (found) |id| {
+ active = id;
+ if (pl.line > 0) if (terms[id]) |tt| if (tt.file) |*ff|
+ if (pl.line <= fileLineCount(ff.content)) {
+ ff.scroll = pl.line - 1;
+ };
+ } else {
+ const slot: ?usize = for (terms, 0..) |sl, i| {
+ if (sl == null) break i;
+ } else null;
+ if (slot) |free| {
+ if (openFileView(gpa, rp, pl.line, screen_w, screen_h)) |nt| {
+ terms[free] = nt;
+ // files live in the LEFT column (acme
+ // convention). Stack at its top with a
+ // fair share of the column height, then
+ // drop a leftover unused shell.
+ const lc = 0;
+ const ln = layout.col_n[lc];
+ if (ln > 0) {
+ var vsum: f32 = 0;
+ for (0..ln) |i| if (terms[layout.col_terms[lc][i]]) |p| {
+ vsum += p.vweight;
+ };
+ nt.vweight = vsum / @as(f32, @floatFromInt(ln));
+ }
+ layoutInsert(&layout, lc, 0, free);
+ active = free;
+ evictLonePristineTty(&layout, &terms, io, gpa, lc, free, &active);
+ } else |_| {}
+ }
+ }
};
} else switch (onSelect(s.button, txt)) {
.none => {},
@@ -759,6 +1006,7 @@ pub fn main(init: std.process.Init) !void {
},
.del => {
// close just this window
+ absorbVWeight(&layout, &terms, s.id);
layoutRemove(&layout, s.id);
t.deinit(io, gpa);
gpa.destroy(t);
@@ -797,7 +1045,7 @@ pub fn main(init: std.process.Init) !void {
const sl = t.sel[@intFromEnum(Button.left)];
const body_vis = sl.r1 - @as(i32, BOX_H);
if (body_vis >= 0) {
- const off: i32 = @intCast(t.term.screens.active.pages.scrollbar().offset);
+ const off: i32 = paneScroll(t);
t.cur_row = body_vis + off;
t.cur_col = sl.c1;
t.cur_off = if (runAt(t, t.cur_row, t.cur_col)) |i| t.edits[i].len else 0;
@@ -840,11 +1088,11 @@ pub fn main(init: std.process.Init) !void {
layoutAppendColumn(&layout, active);
}
} else if (key.matches('d', .{ .ctrl = true })) {
- at.term.screens.active.scroll(.{ .delta_row = 15 });
+ paneScrollBy(at, 15);
} else if (key.matches('u', .{ .ctrl = true })) {
- at.term.screens.active.scroll(.{ .delta_row = -15 });
- } else if (at.editing and key.matches(vaxis.Key.escape, .{})) {
- at.editing = false; // leave char mode (don't forward Esc)
+ paneScrollBy(at, -15);
+ } else if (at.editing and at.file == null and key.matches(vaxis.Key.escape, .{})) {
+ at.editing = false; // leave char mode (don't forward Esc); files have no tty mode
} else if (at.editing) {
// char mode: printable text inserts at the cursor (shifting
// the current run); arrows move the cursor; backspace/etc.
@@ -853,9 +1101,14 @@ pub fn main(init: std.process.Init) !void {
// first keystroke pins the cursor (it was tracking the
// shell cursor until now).
if (!at.cur_pinned) {
- const coff: i32 = @intCast(at.term.screens.active.pages.scrollbar().offset);
- at.cur_row = @as(i32, @intCast(at.term.screens.active.cursor.y)) + coff;
- at.cur_col = @intCast(at.term.screens.active.cursor.x);
+ const coff: i32 = paneScroll(at);
+ if (at.file != null) {
+ at.cur_row = coff;
+ at.cur_col = 0;
+ } else {
+ at.cur_row = @as(i32, @intCast(at.term.screens.active.cursor.y)) + coff;
+ at.cur_col = @intCast(at.term.screens.active.cursor.x);
+ }
at.cur_off = 0;
at.cur_pinned = true;
}
@@ -893,12 +1146,12 @@ pub fn main(init: std.process.Init) !void {
}
},
vaxis.Key.up => {
- const off: i32 = @intCast(at.term.screens.active.pages.scrollbar().offset);
+ const off: i32 = paneScroll(at);
at.cur_row = @max(off, at.cur_row - 1);
at.cur_off = 0;
},
vaxis.Key.down => {
- const off: i32 = @intCast(at.term.screens.active.pages.scrollbar().offset);
+ const off: i32 = paneScroll(at);
at.cur_row = @min(off + @as(i32, at.rows) - 1, at.cur_row + 1);
at.cur_off = 0;
},
@@ -927,6 +1180,7 @@ pub fn main(init: std.process.Init) !void {
},
.pty_eof => |id| {
if (terms[id]) |t| {
+ absorbVWeight(&layout, &terms, id);
layoutRemove(&layout, id);
t.deinit(io, gpa);
gpa.destroy(t);
@@ -957,8 +1211,12 @@ pub fn main(init: std.process.Init) !void {
const cols = @max(1, r.w -| GUTTER);
const rows = @max(1, r.h -| BOX_H); // the tag steals the top row
if (cols != t.cols or rows != t.rows) {
- t.term.resize(gpa, cols, rows) catch {};
- pty_set_size(t.pty, rows, cols);
+ // file panes have no pty/emulator grid to reflow; just record
+ // the size so bodyText renders the right number of rows.
+ if (t.file == null) {
+ t.term.resize(gpa, cols, rows) catch {};
+ pty_set_size(t.pty, rows, cols);
+ }
t.cols = cols;
t.rows = rows;
}
@@ -973,30 +1231,9 @@ pub fn main(init: std.process.Init) !void {
if (slot.*) |t| try renderPane(win, frame_arena.allocator(), t, rects[id], id == active);
}
- // separators (the grips signal that borders are draggable)
- for (0..layout.ncol) |c| {
- if (c + 1 < layout.ncol) {
- const sx = col_x[c] + col_w[c];
- const mid = screen_h / 2;
- var ry: u16 = 0;
- while (ry < screen_h) : (ry += 1) {
- const g: []const u8 = if (ry == mid) "⇔" else "│";
- win.writeCell(sx, ry, .{ .char = .{ .grapheme = g }, .style = .{ .fg = .{ .rgb = gray.dim } } });
- }
- }
- const n = layout.col_n[c];
- var k: usize = 0;
- while (k + 1 < n) : (k += 1) {
- const r = rects[layout.col_terms[c][k]];
- const sy = r.y + r.h;
- const midx = col_x[c] + col_w[c] / 2;
- var rx: u16 = col_x[c];
- while (rx < col_x[c] + col_w[c]) : (rx += 1) {
- const g: []const u8 = if (rx == midx) "⇕" else "─";
- win.writeCell(rx, sy, .{ .char = .{ .grapheme = g }, .style = .{ .fg = .{ .rgb = gray.dim } } });
- }
- }
- }
+ // No static separators: each pane's tagline (top) and scrollbar gutter
+ // (left) already read as borders. The 1-cell gap between panes stays as
+ // an (invisible) drag zone; the dashed preview below shows it on drag.
// dashed preview while dragging; commit happens on release.
switch (drag) {
@@ -1025,7 +1262,29 @@ pub fn main(init: std.process.Init) !void {
win.writeCell(rx, d.cur_y, .{ .char = .{ .grapheme = "╌" }, .style = .{ .fg = .{ .rgb = gray.mid } } });
win.writeCell(d.cur_x, d.cur_y, .{ .char = .{ .grapheme = "▌" }, .style = .{ .fg = .{ .rgb = gray.mid } } });
},
- else => {},
+ else => {
+ // not dragging: tint the resize border under the cursor (matching
+ // the press-handler hit tests) so the invisible drag zones show.
+ var c: usize = 0;
+ while (c + 1 < layout.ncol) : (c += 1) {
+ if (hover_col != col_x[c] + col_w[c]) continue;
+ var ry: u16 = 0;
+ while (ry < screen_h) : (ry += 1)
+ win.writeCell(hover_col, ry, .{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = hover_bg } } });
+ }
+ var cc: usize = 0;
+ while (cc < layout.ncol) : (cc += 1) {
+ if (hover_col < col_x[cc] or hover_col >= col_x[cc] + col_w[cc]) continue;
+ var k: usize = 0;
+ while (k + 1 < layout.col_n[cc]) : (k += 1) {
+ const r = rects[layout.col_terms[cc][k]];
+ if (hover_row != r.y + r.h) continue;
+ var rx: u16 = col_x[cc];
+ while (rx < col_x[cc] + col_w[cc]) : (rx += 1)
+ win.writeCell(rx, hover_row, .{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = hover_bg } } });
+ }
+ }
+ },
}
// debug overlay: a small virtual-text box drawn last, on top of everything.
@@ -1035,11 +1294,20 @@ pub fn main(init: std.process.Init) !void {
for (terms) |slot| {
if (slot != null) nlive += 1;
}
- const sb = at.term.screens.active.pages.scrollbar();
+ var sb_off: usize = undefined;
+ var sb_total: usize = undefined;
+ if (at.file) |f| {
+ sb_off = f.scroll;
+ sb_total = fileLineCount(f.content);
+ } else {
+ const sb = at.term.screens.active.pages.scrollbar();
+ sb_off = sb.offset;
+ sb_total = sb.total;
+ }
const s = std.fmt.bufPrint(
&ov_buf,
"pardes resizes={d}\nterms {d} cols {d}\nactive #{d} {d}x{d}\nscroll {d}/{d}",
- .{ resize_count, nlive, layout.ncol, active, at.cols, at.rows, sb.offset, sb.total },
+ .{ resize_count, nlive, layout.ncol, active, at.cols, at.rows, sb_off, sb_total },
) catch "";
const ow: u16 = 26;
const oh: u16 = 6;
@@ -1165,6 +1433,31 @@ fn promptFlags(t: *Term, flags: []bool) void {
}
fn bodyText(alloc: std.mem.Allocator, t: *Term) ![]u8 {
+ // file pane: line-numbered content from f.scroll, edits spliced like the tty.
+ if (t.file) |f| {
+ var out: std.ArrayList(u8) = .empty;
+ errdefer out.deinit(alloc);
+ var lines = std.mem.splitScalar(u8, f.content, '\n');
+ var skipped: usize = 0;
+ while (skipped < f.scroll) : (skipped += 1) {
+ if (lines.next() == null) break;
+ }
+ var i: usize = 0;
+ while (i < t.rows) : (i += 1) {
+ if (i > 0) try out.append(alloc, '\n');
+ const abs: i32 = @as(i32, @intCast(f.scroll)) + @as(i32, @intCast(i));
+ if (lines.next()) |text| {
+ var lbuf: [16]u8 = undefined;
+ const prefix = std.fmt.bufPrint(&lbuf, "{d: >4} ", .{abs + 1}) catch " ";
+ var base: std.ArrayList(u8) = .empty;
+ defer base.deinit(alloc);
+ try base.appendSlice(alloc, prefix);
+ try base.appendSlice(alloc, text);
+ try spliceRow(alloc, &out, t, abs, base.items);
+ } else try spliceRow(alloc, &out, t, abs, "");
+ }
+ return out.toOwnedSlice(alloc);
+ }
const off: i32 = @intCast(t.term.screens.active.pages.scrollbar().offset);
const body = try t.term.plainString(alloc);
defer alloc.free(body);