From b5b526f465924324e2cd4424f5bacc43e50b7673 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 29 Jun 2026 22:33:21 -0300 Subject: vibe file panes now --- main.zig | 421 ++++++++++++++++++++++++++++++++++++++++++++++++++++---------- term.zig | 22 +++- tests.zig | 35 ++++++ 3 files changed, 412 insertions(+), 66 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//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); diff --git a/term.zig b/term.zig index 307b116f..b0b424ee 100644 --- a/term.zig +++ b/term.zig @@ -14,6 +14,13 @@ pub const Sel = struct { pub const EDIT_CAP = 128; // max chars in one insertion run pub const MAX_EDITS = 16; // max concurrent insertion runs per window +/// A file pane's backing state (see the `file` field below). +pub const File = struct { + path: []const u8, // gpa-owned absolute path + content: []u8, // gpa-owned file bytes + scroll: usize = 0, // top visible file line +}; + /// A run of inserted ("char mode") text. `col` is the ORIGINAL tty column it is /// spliced in *before* — the run shifts the underlying row right, it doesn't /// overwrite. `row`/`col` are pane-local (row carries the BOX_H tag offset). @@ -39,6 +46,12 @@ term: ghostty_vt.Terminal, stream: ghostty_vt.TerminalStream, reader: std.Io.Future(anyerror!void), +/// When set, this pane is a (read/editable) text FILE view, not a terminal: its +/// body comes from `content` instead of the emulator, and all the tty-specific +/// handling (pty io, modes, prompt hiding) is skipped. Everything else — layout, +/// selection, char-mode editing — is shared. pty.handle is -1, reader is a no-op. +file: ?File = null, + /// current emulator grid size; we only push a resize when it actually changes. cols: u16, rows: u16, @@ -67,8 +80,13 @@ nedits: usize = 0, edits: [MAX_EDITS]Edit = @splat(.{}), pub fn deinit(self: *Term, io: std.Io, gpa: std.mem.Allocator) void { - _ = self.reader.cancel(io) catch {}; + if (self.file) |f| { + gpa.free(f.path); + gpa.free(f.content); + } else { + _ = self.reader.cancel(io) catch {}; + self.pty.close(io); + } self.stream.deinit(); self.term.deinit(gpa); - self.pty.close(io); } diff --git a/tests.zig b/tests.zig index eeb397fa..d9cc763b 100644 --- a/tests.zig +++ b/tests.zig @@ -26,6 +26,15 @@ fn writeAll(fd: c_int, bytes: []const u8) !void { } } +fn writeFileE2E(path: [*:0]const u8, data: []const u8) !void { + const rc = linux.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644); + const sfd: isize = @bitCast(rc); + if (sfd < 0) return error.OpenFailed; + const fd: c_int = @intCast(sfd); + defer _ = linux.close(fd); + try writeAll(fd, data); +} + // TIOCSWINSZ on the pty master: changes the size and makes the kernel raise // SIGWINCH in the slave's foreground process group (i.e. the app under test). fn setWinsize(fd: c_int, rows: u16, cols: u16) !void { @@ -218,6 +227,32 @@ pub fn main(init: std.process.Init) !void { return error.RightClickDirFailed; } + // 8. Right-click a "path:line" opens a line-numbered FILE pane SCROLLED to + // that line. Build a tall file whose deep marker (line 150) is only + // reachable by scrolling, type the path:line on a blank row of the right + // column, then right-click it. Typed text renders starting at the click + // column, so the right-click lands on the path without knowing any pane + // geometry. The file pane opens, scrolls to line 150 -> the marker shows. + var fbuf: std.ArrayList(u8) = .empty; + defer fbuf.deinit(gpa); + { + var ln: usize = 1; + while (ln <= 200) : (ln += 1) + try fbuf.appendSlice(gpa, if (ln == 150) "ZZDEEPMARK\n" else "filler\n"); + } + try writeFileE2E("/tmp/pardesE2Efile.txt", fbuf.items); + try setWinsize(master, 50, 200); // clean, known geometry (col 149 is in the right column) + try drainFor(gpa, master, &output, 700); // settle the reflow + output.clearRetainingCapacity(); + try writeAll(master, "\x1b[<0;150;11M\x1b[<0;150;11m"); // left-click blank row, right column -> text mode + pin + try writeAll(master, "/tmp/pardesE2Efile.txt:150"); // type the path:line (char mode) + _ = try pollFor(gpa, master, &output, "ZZNEVERZZ", 8); // settle (build the run) + try writeAll(master, "\x1b[<2;156;11M\x1b[<2;156;11m"); // right-click on the typed path + if (!try pollFor(gpa, master, &output, "ZZDEEPMARK", 120)) { + dump(output.items, "right-click 'path:150' did not open the file scrolled to line 150"); + return error.RightClickFileFailed; + } + // Tell the app to quit (ctrl-c is the app's own exit key). try writeAll(master, "\x03"); } -- cgit v1.3