summaryrefslogtreecommitdiff
path: root/main.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-06-30 13:00:23 -0300
committerGabriel Schneider <[email protected]>2026-06-30 15:32:46 -0300
commita68564e9fb9fb8be6d997db5e6ad9148fb506d18 (patch)
tree0230a359962c2e5e67bc4875050afe8411142c9e /main.zig
parent651af1db938bcbaa039d14b65ff6bc184a3d91b4 (diff)
downloadpardes-a68564e9fb9fb8be6d997db5e6ad9148fb506d18.tar.gz
pardes-a68564e9fb9fb8be6d997db5e6ad9148fb506d18.zip
images? lol
Diffstat (limited to 'main.zig')
-rw-r--r--main.zig282
1 files changed, 225 insertions, 57 deletions
diff --git a/main.zig b/main.zig
index 7221c5eb..339cddba 100644
--- a/main.zig
+++ b/main.zig
@@ -3,6 +3,33 @@ const vaxis = @import("vaxis");
const ghostty_vt = @import("ghostty-vt");
const Term = @import("term.zig");
const modal = @import("modal.zig");
+const image = @import("image.zig");
+
+// A TUI can't let std.log hit stderr — it's the same tty as our alt-screen, so the
+// message lands on top of the UI (this is the stray text you saw when opening yazi:
+// ghostty-vt's parser warns via std.log about sequences it doesn't implement, e.g.
+// kitty text-sizing). Route every log line to /tmp/pardes.log instead: the display
+// stays clean and the messages are still there to inspect.
+pub const std_options: std.Options = .{ .logFn = fileLog };
+
+var log_fd: i32 = -1;
+fn fileLog(
+ comptime level: std.log.Level,
+ comptime scope: @EnumLiteral(),
+ comptime format: []const u8,
+ args: anytype,
+) void {
+ if (log_fd < 0) {
+ const rc = std.os.linux.open("/tmp/pardes.log", .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true, .APPEND = true }, 0o644);
+ const sfd: isize = @bitCast(rc);
+ if (sfd < 0) return; // can't open the log: drop the message (never touch stderr)
+ log_fd = @intCast(sfd);
+ }
+ var buf: [4096]u8 = undefined;
+ const prefix = "[" ++ @tagName(level) ++ "] " ++ @tagName(scope) ++ ": ";
+ const line = std.fmt.bufPrint(&buf, prefix ++ format ++ "\n", args) catch return;
+ _ = std.os.linux.write(log_fd, line.ptr, line.len);
+}
extern "c" fn forkpty(
amaster: *c_int,
@@ -61,6 +88,7 @@ fn setYank(gpa: std.mem.Allocator, text: []const u8) void {
const Loop = vaxis.Loop(@TypeOf(Command.value));
// layout constants
+const TOPBAR_H: u16 = 1; // global tagbar row at the very top (full width)
const GUTTER: u16 = 2; // left gutter: box (top) + scrollbar (below) per pane
const BOX_H: u16 = 1; // rows of the drag/rearrange box; scrollbar starts below it
const MINW: u16 = 10; // min pane width (cells)
@@ -93,12 +121,14 @@ const Button = enum { left, middle, right };
// monochrome, low-contrast palette (rgb grays).
const gray = struct {
- const bar = [3]u8{ 0x2a, 0x2a, 0x2a }; // dim bars (tag, scrollbar track, box idle)
+ const bar = [3]u8{ 0x2a, 0x2a, 0x2a }; // dim bars (scrollbar track, box idle)
const dim = [3]u8{ 0x3c, 0x3c, 0x3c }; // separators
const mid = [3]u8{ 0x52, 0x52, 0x52 }; // box active, scrollbar thumb, move preview
const lit = [3]u8{ 0x6e, 0x6e, 0x6e }; // dashed resize preview
const txt = [3]u8{ 0x96, 0x96, 0x96 }; // muted text
};
+// tagline background: the dim gray with a slight indigo tint (more blue, hint of red).
+const tag_bg = [3]u8{ 0x2c, 0x2a, 0x3e };
// 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 };
@@ -116,21 +146,24 @@ const sel_fg = [3]u8{ 0xd2, 0xd2, 0xd2 };
const msel_bg = [3]u8{ 0x4a, 0x3a, 0x1e };
const msel_fg = [3]u8{ 0xd8, 0xc8, 0xa8 };
-// Tag builtins. Middle-clicking the name in a window's tag runs the action;
-// `builtins_str` is the space-joined name list, produced at comptime.
-const SelectAction = enum { none, send, newcol, delcol, del, tutor };
+// Builtins. Clicking the name runs the action. Window-specific ones (Del, Delcol)
+// live in each pane's tag; window-agnostic ones (Kill, Newcol, Tutor) live in the
+// single global tagbar at the top. onSelect matches against the whole list, so a
+// name works wherever it's shown.
+const SelectAction = enum { none, send, newcol, delcol, del, tutor, kill };
const Builtin = struct { name: []const u8, action: SelectAction };
const builtins = [_]Builtin{
+ .{ .name = "Kill", .action = .kill },
.{ .name = "Newcol", .action = .newcol },
- .{ .name = "Delcol", .action = .delcol },
- .{ .name = "Del", .action = .del },
.{ .name = "Tutor", .action = .tutor },
+ .{ .name = "Del", .action = .del },
+ .{ .name = "Delcol", .action = .delcol },
};
-const builtins_str = blk: {
- var s: []const u8 = "";
- for (builtins, 0..) |b, i| s = s ++ (if (i == 0) "" else " ") ++ b.name;
- break :blk s;
-};
+// per-pane tag commands (window-specific).
+const pane_builtins_str = "Del Delcol";
+// the global tagbar: window-agnostic commands. Kill closes everything; Debug
+// toggles the stats overlay (handled in the tagbar click, not a SelectAction).
+const topbar_str = "Kill Newcol Tutor Debug";
// Path of the generated bash rc that adds OSC 133 semantic prompts (shell
// integration). bash --rcfile reads it for interactive shells; it sources the
@@ -222,7 +255,7 @@ fn paneScrollBy(t: *Term, delta: i32) void {
// the shell is at a prompt (don't inject newlines into a running program).
// ponytail: pad caps at the grid height — you can't click/type past the pane.
fn padOutputBelowEdits(t: *Term) void {
- if (t.file != null or t.nedits == 0) return;
+ if (!t.isTerminal() or t.nedits == 0) return;
if (!t.term.cursorIsAtPrompt()) return;
var max_row: i32 = std.math.minInt(i32);
for (t.edits[0..t.nedits]) |e| {
@@ -241,7 +274,7 @@ fn padOutputBelowEdits(t: *Term) void {
// 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
+ if (!t.isTerminal()) return "?"; // file/image 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);
@@ -264,10 +297,12 @@ 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 {
+ // an image pane's tag: a label + path (+ builtins); display-only, no mode/cwd.
+ if (t.image) |iv| return std.fmt.allocPrint(alloc, "img {s} {s}", .{ iv.path, pane_builtins_str });
// a file pane's tag: mode + path (+ builtins); no cwd. Files never reach tty.
if (t.file) |f| {
const fmode = if (t.mode == .insert) "in" else "nm";
- return std.fmt.allocPrint(alloc, "{s} {s} {s}", .{ fmode, f.path, builtins_str });
+ return std.fmt.allocPrint(alloc, "{s} {s} {s}", .{ fmode, f.path, pane_builtins_str });
}
var link: [1024]u8 = undefined;
const cwd = termCwd(t, &link);
@@ -277,7 +312,18 @@ fn tagLine(alloc: std.mem.Allocator, t: *Term) ![]u8 {
.insert => "in",
.tty => "sy",
};
- return std.fmt.allocPrint(alloc, "{s} {s} {s}", .{ mode, cwd, builtins_str });
+ return std.fmt.allocPrint(alloc, "{s} {s} {s}", .{ mode, cwd, pane_builtins_str });
+}
+
+// The whitespace-delimited word covering `col` in `str` (empty if on a gap / past
+// the end). Used to resolve which global-tagbar command was clicked.
+fn wordAtCol(str: []const u8, col: u16) []const u8 {
+ if (col >= str.len or str[col] == ' ') return "";
+ var lo: usize = col;
+ while (lo > 0 and str[lo - 1] != ' ') lo -= 1;
+ var hi: usize = col;
+ while (hi < str.len and str[hi] != ' ') hi += 1;
+ return str[lo..hi];
}
// TIOCSWINSZ: set the pty size; the kernel raises SIGWINCH in the child so it
@@ -396,6 +442,29 @@ fn openFileView(gpa: std.mem.Allocator, rp: [*:0]const u8, line: usize, cols: u1
return t;
}
+// Open an IMAGE pane (the kitty-graphics side quest): a Term with `image` set,
+// sharing the layout machinery. No pty (handle -1), no reader, a stub 1x1 emulator
+// (only its allocator-owned bits are touched). The render loop transmits + draws
+// the image (see image.zig). `rp` is the absolute path. Caller wires it in + frees.
+fn openImageView(gpa: std.mem.Allocator, rp: [*:0]const u8, cols: u16, rows: u16) !*Term {
+ const path = try gpa.dupe(u8, std.mem.span(rp));
+ errdefer gpa.free(path);
+ const t = try gpa.create(Term);
+ errdefer gpa.destroy(t);
+ 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 = {} },
+ .image = .{ .path = path },
+ .cols = cols,
+ .rows = rows,
+ };
+ t.stream = t.term.vtStream();
+ return t;
+}
+
// Open the built-in Tutor as a read/editable FILE pane: same machinery as
// openFileView, but the content is the tutor text embedded at comptime (so the
// binary is self-contained — no external file needed) and the path is a fixed
@@ -511,7 +580,7 @@ fn splitBelow(src: *Term, nw: *Term, src_h: u16) void {
// 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.isTerminal()) 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;
@@ -610,9 +679,10 @@ fn computeGeom(
}
if (vsum <= 0) vsum = 1;
const sep_rows: u16 = if (n > 0) @intCast(n - 1) else 0;
- const avail_h: u16 = sh -| sep_rows;
+ // reserve the top row for the global tagbar: panes tile [TOPBAR_H, sh).
+ const avail_h: u16 = (sh -| TOPBAR_H) -| sep_rows;
- var y: u16 = 0;
+ var y: u16 = TOPBAR_H;
for (0..n) |k| {
const id = l.col_terms[c][k];
const t = terms[id] orelse continue;
@@ -638,8 +708,25 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a
// tag: cwd + builtins, drawn as a dim bar; plain selectable text.
const tag = try tagLine(alloc, t);
const tagw = text.child(.{ .height = BOX_H });
- tagw.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = gray.bar } } });
- _ = tagw.printSegment(.{ .text = tag, .style = .{ .fg = .{ .rgb = gray.txt }, .bg = .{ .rgb = gray.bar } } }, .{ .wrap = .none });
+ tagw.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = tag_bg } } });
+ _ = tagw.printSegment(.{ .text = tag, .style = .{ .fg = .{ .rgb = gray.txt }, .bg = .{ .rgb = tag_bg } } }, .{ .wrap = .none });
+
+ // image pane: the picture fills the body; draw the box + a plain (thumbless)
+ // gutter so it reads like any other pane. kitty placements are re-issued each
+ // frame, so this runs every render. Nothing else (text/cursor/selection) applies.
+ if (t.image) |*iv| {
+ if (r.h > BOX_H) image.draw(iv, text.child(.{ .y_off = BOX_H, .height = r.h - BOX_H }));
+ const gut = root.child(.{ .x_off = r.x, .y_off = r.y, .width = GUTTER, .height = r.h });
+ const box_bg = if (active) red else red_dim;
+ var gy: u16 = 0;
+ while (gy < r.h) : (gy += 1) {
+ const bg = if (gy < BOX_H) box_bg else gray.bar;
+ var gx: u16 = 0;
+ while (gx < GUTTER) : (gx += 1)
+ gut.writeCell(gx, gy, .{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = bg } } });
+ }
+ return;
+ }
// 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
@@ -1443,6 +1530,7 @@ fn actOnSelection(
active: *usize,
sw: u16,
sh: u16,
+ images_ok: bool,
button: Button,
id: usize,
txt: []const u8,
@@ -1489,23 +1577,27 @@ fn actOnSelection(
layoutInsert(layout, f.col, f.idx + 1, free);
splitBelow(t, nt, rects[id].h);
active.* = free;
- termWrite(nt, "ls\r");
+ nt.greet = true;
} else |_| {}
}
}
} else {
const fp = std.mem.span(rp);
- // focus an existing file pane for this path
+ // an image opens as an IMAGE pane, but only when the terminal reported
+ // the kitty-graphics capability; otherwise it falls back to a text view.
+ const as_image = images_ok and image.isImagePath(fp);
+ // focus an existing pane already showing this path (file or image)
var found: ?usize = null;
for (terms, 0..) |*slot, i| if (slot.*) |tt| {
- if (tt.file) |ff| if (std.mem.eql(u8, ff.path, fp)) {
+ const tp: ?[]const u8 = if (tt.file) |ff| ff.path else if (tt.image) |iv| iv.path else null;
+ if (tp) |p| if (std.mem.eql(u8, p, fp)) {
found = i;
break;
};
};
if (found) |fid| {
active.* = fid;
- if (pl.line > 0) if (terms[fid]) |tt| if (tt.file) |*ff|
+ if (!as_image and pl.line > 0) if (terms[fid]) |tt| if (tt.file) |*ff|
if (pl.line <= fileLineCount(ff.content)) {
ff.scroll = pl.line - 1;
};
@@ -1514,11 +1606,15 @@ fn actOnSelection(
if (sl == null) break i;
} else null;
if (slot) |free| {
- if (openFileView(gpa, rp, pl.line, sw, sh)) |nt| {
+ const opened: ?*Term = if (as_image)
+ (openImageView(gpa, rp, sw, sh) catch null)
+ else
+ (openFileView(gpa, rp, pl.line, sw, sh) catch null);
+ if (opened) |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.
+ // files/images 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) {
@@ -1531,7 +1627,7 @@ fn actOnSelection(
layoutInsert(layout, lc, 0, free);
active.* = free;
evictLonePristineTty(layout, terms, io, gpa, lc, free, active);
- } else |_| {}
+ }
}
}
};
@@ -1539,6 +1635,7 @@ fn actOnSelection(
}
switch (onSelect(button, txt)) {
.none => {},
+ .kill => return true, // close everything: signal the main loop to quit
.send => {
// terminal: run in itself. file pane: run in a shell sitting in the
// file's directory (found by cwd, else a fresh tty in the right column).
@@ -1564,7 +1661,7 @@ fn actOnSelection(
startReader(io, gpa, loop, nt, free) catch {};
layoutAppendColumn(layout, free);
active.* = free;
- termWrite(nt, "ls\r");
+ nt.greet = true;
} else |_| {}
};
},
@@ -1639,10 +1736,23 @@ pub fn main(init: std.process.Init) !void {
const io = init.io;
const gpa = init.gpa;
+ // --tty: start tmux-style — a single shell pane already in tty mode — instead
+ // of the default multi-pane editor layout. It only changes the startup layout
+ // and mode; everything else behaves identically.
+ const tty_only = blk: {
+ const args = init.minimal.args.toSlice(init.arena.allocator()) catch break :blk false;
+ for (args) |a| if (std.mem.eql(u8, a, "--tty")) break :blk true;
+ break :blk false;
+ };
+
var screen_w: u16 = 80;
var screen_h: u16 = 24;
var resize_count: usize = 0;
+ // stb_image allocator for image panes (see image.zig).
+ image.start(io, gpa);
+ defer image.stop();
+
// per-frame scratch for plainString; reset (not freed) after each render so
// the retained vaxis screen can keep referencing it through vx.render().
var frame_arena: std.heap.ArenaAllocator = .init(gpa);
@@ -1682,20 +1792,31 @@ pub fn main(init: std.process.Init) !void {
// Fork the initial terminals BEFORE starting any io worker thread. forkpty
// from a multithreaded process can leave the child wedged before exec; doing
- // it while still single-threaded avoids that. (initial: two columns, the
- // left one split into two windows.)
- terms[0] = try forkTerm(gpa, screen_w, screen_h, null);
- terms[1] = try forkTerm(gpa, screen_w, screen_h, null);
- terms[2] = try forkTerm(gpa, screen_w, screen_h, null);
- // a fresh shell greets you with its directory listing (the readers below pick
- // up the output once they start).
- for (terms[0..3]) |slot| if (slot) |t| termWrite(t, "ls\r");
- layout.ncol = 2;
- layout.col_n[0] = 2;
- layout.col_terms[0][0] = 0;
- layout.col_terms[0][1] = 1;
- layout.col_n[1] = 1;
- layout.col_terms[1][0] = 2;
+ // it while still single-threaded avoids that.
+ if (tty_only) {
+ // tmux-style: a single shell, already in tty mode, filling one column.
+ terms[0] = try forkTerm(gpa, screen_w, screen_h, null);
+ terms[0].?.mode = .tty;
+ layout.ncol = 1;
+ layout.col_n[0] = 1;
+ layout.col_terms[0][0] = 0;
+ } else {
+ // editor default: two columns, the left one split into two windows.
+ terms[0] = try forkTerm(gpa, screen_w, screen_h, null);
+ terms[1] = try forkTerm(gpa, screen_w, screen_h, null);
+ terms[2] = try forkTerm(gpa, screen_w, screen_h, null);
+ // greet each fresh shell with `ls` once it's been sized (see the resize
+ // loop): sending it now wraps the listing to the full-screen width.
+ for (terms[0..3]) |slot| if (slot) |t| {
+ t.greet = true;
+ };
+ layout.ncol = 2;
+ layout.col_n[0] = 2;
+ layout.col_terms[0][0] = 0;
+ layout.col_terms[0][1] = 1;
+ layout.col_n[1] = 1;
+ layout.col_terms[1][0] = 2;
+ }
var active: usize = 0;
var loop: Loop = .init(io, &tty, &vx);
@@ -1703,6 +1824,12 @@ pub fn main(init: std.process.Init) !void {
defer loop.stop();
try loop.installResizeHandler();
+ // Ask the terminal what it supports; the loop's reader thread fills vx.caps
+ // from the replies. We only need kitty graphics (for image panes). Keep the
+ // timeout tiny — a real terminal answers in well under a millisecond, and we
+ // don't want to stall startup on one that never will.
+ vx.queryTerminal(tty.writer(), std.Io.Duration.fromMilliseconds(20)) catch {};
+
// now safe to spawn the per-terminal reader tasks (no forking here)
for (&terms, 0..) |*slot, id| {
if (slot.*) |t| try startReader(io, gpa, &loop, t, id);
@@ -1717,6 +1844,7 @@ pub fn main(init: std.process.Init) !void {
var hover_col: u16 = 0; // last mouse position; used to tint the resize border under it
var hover_row: u16 = 0;
var ctrl_w_pending: bool = false; // Ctrl-w prefix for helix-style pane focus
+ var show_debug: bool = false; // the stats overlay; toggled by the Debug tagbar command
while (true) {
const event = try loop.nextEvent();
@@ -1738,6 +1866,19 @@ pub fn main(init: std.process.Init) !void {
if (hovered) |id| paneScrollBy(terms[id].?, 1);
} else if (mouse.button == .wheel_up) {
if (hovered) |id| paneScrollBy(terms[id].?, -1);
+ } else if (mouse.type == .press and mrow < TOPBAR_H) {
+ // global tagbar: run the command under the click. Left OR middle
+ // (middle for acme muscle memory; left so it also works where the
+ // terminal swallows middle-click, e.g. ghostty). id=active is just
+ // a valid pane for the action context; Kill/Newcol/Tutor ignore it.
+ if (mouse.button == .left or mouse.button == .middle) {
+ const word = wordAtCol(topbar_str, mcol);
+ if (std.mem.eql(u8, word, "Debug")) {
+ show_debug = !show_debug;
+ } else if (word.len > 0) {
+ if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, vx.caps.kitty_graphics, .middle, active, word)) break;
+ }
+ }
} else if (mouse.type == .press and mouse.button == .left) {
var handled = false;
@@ -1917,7 +2058,7 @@ pub fn main(init: std.process.Init) !void {
expandSel(gpa, t, &t.sel[@intFromEnum(s.button)]);
if (selectionText(gpa, t, t.sel[@intFromEnum(s.button)])) |txt| {
defer gpa.free(txt);
- if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, s.button, s.id, txt)) break;
+ if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, vx.caps.kitty_graphics, s.button, s.id, txt)) break;
} else |_| {}
}
}
@@ -1962,7 +2103,7 @@ pub fn main(init: std.process.Init) !void {
const f = layoutFindTerm(&layout, active).?;
layoutInsert(&layout, f.col, f.idx + 1, slot);
active = slot;
- termWrite(nt, "ls\r");
+ nt.greet = true;
} else |_| {}
}
break :kpress;
@@ -1981,7 +2122,7 @@ pub fn main(init: std.process.Init) !void {
// tty forwards every key straight to the pty, so it's deliberately
// off the normal editing path — this toggle is the only way in/out.
if (key.matches('b', .{ .ctrl = true })) {
- if (at.file == null) {
+ if (at.isTerminal()) {
if (at.mode == .tty) {
at.mode = .normal;
at.pending = 0;
@@ -2000,7 +2141,7 @@ pub fn main(init: std.process.Init) !void {
expandSel(gpa, at, &sel);
if (selectionText(gpa, at, sel)) |word| {
defer gpa.free(word);
- if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, btn, active, word)) break;
+ if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, vx.caps.kitty_graphics, btn, active, word)) break;
} else |_| {}
break :kpress;
}
@@ -2066,15 +2207,23 @@ 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) {
- // 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) {
+ // file/image panes have no pty/emulator grid to reflow; just
+ // record the size so bodyText renders the right number of rows.
+ if (t.isTerminal()) {
t.term.resize(gpa, cols, rows) catch {};
pty_set_size(t.pty, rows, cols);
}
t.cols = cols;
t.rows = rows;
}
+ // greet a freshly forked shell with `ls` once it has its real size
+ // (after the first winsize), so the listing wraps to the pane width
+ // and not the placeholder full-screen size it was forked at.
+ if (t.greet and t.isTerminal() and resize_count > 0) {
+ pty_set_size(t.pty, t.rows, t.cols);
+ termWrite(t, "ls\r");
+ t.greet = false;
+ }
}
}
@@ -2083,9 +2232,19 @@ pub fn main(init: std.process.Init) !void {
win.clear();
for (&terms, 0..) |*slot, id| {
- if (slot.*) |t| try renderPane(win, frame_arena.allocator(), t, rects[id], id == active);
+ if (slot.*) |t| {
+ // image panes transmit lazily (needs the tty writer, which lives
+ // here on the render thread); renderPane then re-places the picture.
+ if (t.image) |*iv| image.ensureLoaded(iv, &vx, gpa, tty.writer());
+ try renderPane(win, frame_arena.allocator(), t, rects[id], id == active);
+ }
}
+ // global tagbar (full width, top row): app name + window-agnostic commands.
+ const bar = win.child(.{ .x_off = 0, .y_off = 0, .width = screen_w, .height = TOPBAR_H });
+ bar.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = tag_bg } } });
+ _ = bar.printSegment(.{ .text = topbar_str, .style = .{ .fg = .{ .rgb = gray.txt }, .bg = .{ .rgb = tag_bg } } }, .{ .wrap = .none });
+
// 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.
@@ -2093,7 +2252,7 @@ pub fn main(init: std.process.Init) !void {
// dashed preview while dragging; commit happens on release.
switch (drag) {
.border_v => |d| {
- var ry: u16 = 0;
+ var ry: u16 = TOPBAR_H;
while (ry < screen_h) : (ry += 1)
win.writeCell(d.cur_x, ry, .{ .char = .{ .grapheme = "╎" }, .style = .{ .fg = .{ .rgb = gray.lit } } });
},
@@ -2123,7 +2282,7 @@ pub fn main(init: std.process.Init) !void {
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;
+ var ry: u16 = TOPBAR_H;
while (ry < screen_h) : (ry += 1)
win.writeCell(hover_col, ry, .{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = hover_bg } } });
}
@@ -2144,7 +2303,8 @@ pub fn main(init: std.process.Init) !void {
// debug overlay: a small virtual-text box drawn last, on top of everything.
// ov_buf lives across vx.render() — vaxis keeps slices into it (retained).
- if (terms[active]) |at| {
+ // Off by default; the "Debug" tagbar command toggles it.
+ if (show_debug) if (terms[active]) |at| {
var nlive: usize = 0;
for (terms) |slot| {
if (slot != null) nlive += 1;
@@ -2167,10 +2327,10 @@ pub fn main(init: std.process.Init) !void {
const ow: u16 = 26;
const oh: u16 = 6;
const ox: u16 = if (screen_w > ow) screen_w - ow else 0;
- const ov = win.child(.{ .x_off = ox, .y_off = 0, .width = ow, .height = oh, .border = .{ .where = .all } });
+ const ov = win.child(.{ .x_off = ox, .y_off = TOPBAR_H, .width = ow, .height = oh, .border = .{ .where = .all } });
ov.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = gray.bar } } });
_ = ov.printSegment(.{ .text = s, .style = .{ .fg = .{ .rgb = gray.txt } } }, .{});
- }
+ };
try vx.render(tty.writer());
_ = frame_arena.reset(.retain_capacity);
@@ -2288,6 +2448,14 @@ fn promptFlags(t: *Term, flags: []bool) void {
}
fn bodyText(alloc: std.mem.Allocator, t: *Term) ![]u8 {
+ // image pane: no text body (the picture is drawn over it). Return blank rows so
+ // the tag stays selectable — selectionText/expandSel read this, and the stub
+ // emulator's plainString must not be touched here (that path broke tag clicks).
+ if (t.image != null) {
+ const buf = try alloc.alloc(u8, t.rows -| 1);
+ @memset(buf, '\n');
+ return buf;
+ }
// file pane: line-numbered content from f.scroll, edits spliced like the tty.
if (t.file) |f| {
var out: std.ArrayList(u8) = .empty;