summaryrefslogtreecommitdiff
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
parent651af1db938bcbaa039d14b65ff6bc184a3d91b4 (diff)
downloadpardes-a68564e9fb9fb8be6d997db5e6ad9148fb506d18.tar.gz
pardes-a68564e9fb9fb8be6d997db5e6ad9148fb506d18.zip
images? lol
-rw-r--r--build.zig12
-rw-r--r--build.zig.zon4
-rw-r--r--e2e_harness.zig7
-rw-r--r--image.zig83
-rw-r--r--main.zig282
-rw-r--r--term.zig18
-rw-r--r--tests.zig53
7 files changed, 383 insertions, 76 deletions
diff --git a/build.zig b/build.zig
index c5c85857..88d2123a 100644
--- a/build.zig
+++ b/build.zig
@@ -82,6 +82,18 @@ pub fn build(b: *std.Build) void {
vaxis_mod.addImport("uucode", uucode_mod);
exe.root_module.addImport("vaxis", vaxis_mod);
+ // zstbi: C (stb_image)-backed image decoder for the image-viewer panes. We use
+ // it instead of zigimg because zigimg's pure-Zig decoder crashes the Zig 0.16
+ // compiler here; stb_image is plain C (extern fns), so it compiles fine, decodes
+ // every common format, and offers downscaling. Its module ("root") carries the
+ // C source + include path + libc, so importing it is all that's needed.
+ const zstbi_mod = b.dependency("zstbi", .{ .target = target, .optimize = optimize }).module("root");
+ exe.root_module.addImport("zstbi", zstbi_mod);
+
+ // Install the exe so `zig build` (and `zig build install`) produces it in
+ // zig-out/bin for global install, not just as a hidden dep of `run`/`test`.
+ b.installArtifact(exe);
+
const run_exe = b.addRunArtifact(exe);
const run = b.step("run", "descend to the pardes");
diff --git a/build.zig.zon b/build.zig.zon
index 2484f418..9da98684 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -20,6 +20,10 @@
.url = "git+https://github.com/jacobsandlund/uucode#2826a37a4562284fdacd8fa029d49509cc9bffcd",
.hash = "uucode-0.2.0-ZZjBPlK5VADj7fdoq7G8LIHzD5o6FSkcBXXrRWr4jnrA",
},
+ .zstbi = .{
+ .url = "git+https://github.com/zig-gamedev/zstbi#3813f5f113b26f644cfa01a30155cc76b3526e91",
+ .hash = "zstbi-0.11.0-dev-L0Ea_3GWBwAh7m54XX055vwLKPTPij5hyqyeq92MXPE2",
+ },
},
.minimum_zig_version = "0.16.0",
.paths = .{""},
diff --git a/e2e_harness.zig b/e2e_harness.zig
index 05b9c6e5..8b795705 100644
--- a/e2e_harness.zig
+++ b/e2e_harness.zig
@@ -42,11 +42,16 @@ pub const Harness = struct {
trace: bool = false,
pub fn init(gpa: std.mem.Allocator, exe: [*:0]const u8, rows: u16, cols: u16) !Harness {
+ return initArgs(gpa, exe, rows, cols, null);
+ }
+
+ /// Like init, but passes one extra CLI arg to the app (e.g. "--tty").
+ pub fn initArgs(gpa: std.mem.Allocator, exe: [*:0]const u8, rows: u16, cols: u16, arg: ?[*:0]const u8) !Harness {
var master: c_int = undefined;
const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
const pid = forkpty(&master, null, null, &ws);
if (pid == 0) {
- const argv = [_:null]?[*:0]const u8{ exe, null };
+ const argv: [3:null]?[*:0]const u8 = .{ exe, arg, null };
_ = execvp(exe, &argv);
_exit(127);
}
diff --git a/image.zig b/image.zig
new file mode 100644
index 00000000..56085f5a
--- /dev/null
+++ b/image.zig
@@ -0,0 +1,83 @@
+// Image-viewer panes — the kitty-graphics side quest, kept out of the main editor
+// logic. A pane can carry a `View`; the render loop transmits it once (lazily,
+// because transmitting needs the tty writer) and re-places it every frame.
+//
+// Decoding is done by zstbi (C stb_image) — NOT zigimg, whose pure-Zig decoder
+// crashes the Zig 0.16 compiler when analyzed here. We decode to RGBA, downscale
+// large images so the transmit stays cheap, then hand the raw pixels to the
+// terminal via vaxis (kitty graphics). Call `start` once before any pane loads.
+const std = @import("std");
+const vaxis = @import("vaxis");
+const zstbi = @import("zstbi");
+
+// Cap the longest side before transmitting: a terminal pane is at most a screenful
+// of cells, so multi-thousand-pixel photos just waste decode/transmit time.
+const MAX_DIM: u32 = 1280;
+
+pub const View = struct {
+ path: []const u8, // gpa-owned absolute path; doubles as the tag label
+ tried: bool = false, // transmit attempted (success or failure) — try exactly once
+ handle: ?vaxis.Image = null, // kitty image handle once transmitted (null = pending/failed)
+ // ponytail: we never vx.freeImage on close, so the terminal-side pixel data
+ // leaks until app exit. Fine for a viewer; add freeImage in the close paths
+ // (needs vx + the tty writer there) if you open many large images.
+};
+
+// Initialize / tear down the stb_image allocator. Call once at startup / exit.
+pub fn start(io: std.Io, gpa: std.mem.Allocator) void {
+ zstbi.init(io, gpa);
+}
+pub fn stop() void {
+ zstbi.deinit();
+}
+
+// Extensions stb_image can decode. Right-clicking one of these — when the terminal
+// reports kitty graphics — opens an image pane instead of a text view.
+pub fn isImagePath(path: []const u8) bool {
+ const exts = [_][]const u8{
+ ".png", ".jpg", ".jpeg", ".jpe", ".bmp", ".tga",
+ ".gif", ".psd", ".hdr", ".pic", ".pnm", ".ppm", ".pgm",
+ };
+ for (exts) |ext| {
+ if (path.len >= ext.len and std.ascii.eqlIgnoreCase(path[path.len - ext.len ..], ext)) return true;
+ }
+ return false;
+}
+
+// Decode (RGBA), downscale to MAX_DIM, and transmit to the terminal exactly once.
+// Runs from the render loop, which owns the tty writer. On any failure `handle`
+// stays null and the pane shows just its tag.
+pub fn ensureLoaded(view: *View, vx: *vaxis.Vaxis, gpa: std.mem.Allocator, writer: *std.Io.Writer) void {
+ if (view.tried) return;
+ view.tried = true;
+ var pathbuf: [4096]u8 = undefined;
+ const pz = std.fmt.bufPrintZ(&pathbuf, "{s}", .{view.path}) catch return;
+ var img = zstbi.Image.loadFromFile(pz, 4) catch return; // force 4 components = RGBA
+ defer img.deinit();
+
+ // downscale the longest side to MAX_DIM (keeping aspect) when it's bigger.
+ var scaled: ?zstbi.Image = null;
+ defer if (scaled) |*s| s.deinit();
+ const longest = @max(img.width, img.height);
+ const src: *const zstbi.Image = if (longest > MAX_DIM) blk: {
+ const nw = @max(1, img.width * MAX_DIM / longest);
+ const nh = @max(1, img.height * MAX_DIM / longest);
+ scaled = img.resize(nw, nh);
+ break :blk &scaled.?;
+ } else &img;
+
+ // base64-encode the raw RGBA and transmit it (terminal shows the pixels as-is,
+ // no terminal-side decode); vaxis chunks the payload over the kitty protocol.
+ const enc = std.base64.standard.Encoder;
+ const b64 = gpa.alloc(u8, enc.calcSize(src.data.len)) catch return;
+ defer gpa.free(b64);
+ _ = enc.encode(b64, src.data);
+ view.handle = vx.transmitPreEncodedImage(writer, b64, @intCast(src.width), @intCast(src.height), .rgba) catch null;
+}
+
+// Re-place the image in `win`, scaled to fit while keeping aspect ratio. Kitty
+// placements aren't persistent (render clears them), so this must run every frame.
+pub fn draw(view: *const View, win: vaxis.Window) void {
+ const h = view.handle orelse return;
+ h.draw(win, .{ .scale = .contain }) catch {};
+}
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;
diff --git a/term.zig b/term.zig
index 6f43f586..6989eb79 100644
--- a/term.zig
+++ b/term.zig
@@ -1,5 +1,6 @@
const std = @import("std");
const ghostty_vt = @import("ghostty-vt");
+const image_mod = @import("image.zig");
/// One text selection (block-shaped), in pane-local coords; the row carries the
/// scrollback offset. .none == nothing selected.
@@ -69,6 +70,16 @@ reader: std.Io.Future(anyerror!void),
/// selection, char-mode editing — is shared. pty.handle is -1, reader is a no-op.
file: ?File = null,
+/// When set, this pane displays an image (kitty graphics) instead of text. Like a
+/// file pane it has no pty/reader (handle -1) and a stub emulator; the render loop
+/// transmits + draws it. See image.zig. Mutually exclusive with `file`.
+image: ?image_mod.View = null,
+
+/// A freshly forked terminal greets with `ls` once it has its real geometry. Set
+/// at fork; the resize loop fires the `ls` after sizing the pty, so the listing
+/// wraps to the right width (sending it at fork time uses the wrong, full size).
+greet: bool = false,
+
/// current emulator grid size; we only push a resize when it actually changes.
cols: u16,
rows: u16,
@@ -101,10 +112,17 @@ cur_off: u16 = 0,
nedits: usize = 0,
edits: [MAX_EDITS]Edit = @splat(.{}),
+/// A real shell pane (has a pty/emulator), as opposed to a file or image view.
+pub fn isTerminal(self: *const Term) bool {
+ return self.file == null and self.image == null;
+}
+
pub fn deinit(self: *Term, io: std.Io, gpa: std.mem.Allocator) void {
if (self.file) |f| {
gpa.free(f.path);
gpa.free(f.content);
+ } else if (self.image) |iv| {
+ gpa.free(iv.path);
} else {
_ = self.reader.cancel(io) catch {};
self.pty.close(io);
diff --git a/tests.zig b/tests.zig
index 504dad4d..51b4e83a 100644
--- a/tests.zig
+++ b/tests.zig
@@ -39,6 +39,13 @@ pub fn main(init: std.process.Init) !void {
hs.trace = config.trace;
defer hs.deinit();
+ // 0. The stats overlay is off by default; turn it on via the "Debug" command in
+ // the global tagbar (top row). "Debug" sits at cols 18-22, so SGR col ~20,
+ // row 1. This also exercises the global tagbar + the Debug toggle. Give the
+ // app a moment to come up and render the bar first.
+ try hs.pump(800);
+ try hs.send("\x1b[<1;20;1M\x1b[<1;20;1m"); // toggle the Debug overlay on
+
// 1. The app posts an initial winsize on startup. Its winsize handler bumps
// the resize counter to 1 and repaints, so "resizes=1" shows in the debug
// overlay. Confirms the app is up and handled its first size.
@@ -65,7 +72,7 @@ pub fn main(init: std.process.Init) !void {
try hs.pump(600);
try hs.send("printf '#!/bin/sh\\necho ZZTABRAN\\n' > /tmp/zztabcmd; chmod +x /tmp/zztabcmd; clear; echo /tmp/zztabcmd\r");
try hs.pump(1200);
- try hs.send("\x1b[<0;8;2M\x1b[<0;8;2m"); // left-click the path on body row 0 -> normal + pin
+ try hs.send("\x1b[<0;8;3M\x1b[<0;8;3m"); // left-click the path on body row 0 -> normal + pin (+1 row: global tagbar)
try hs.pump(600);
try hs.send("\t"); // TAB: execute "/tmp/zztabcmd"
try hs.expectWaitFor("ZZTABRAN", 8000, "normal-mode Tab did not execute the word under the cursor");
@@ -85,7 +92,7 @@ pub fn main(init: std.process.Init) !void {
try hs.pump(900);
try hs.send("\x02"); // tty -> normal
try hs.pump(400);
- try hs.send("\x1b[<0;5;10M\x1b[<0;5;10m"); // left-click a low blank row (screen row 9) -> normal + pin
+ try hs.send("\x1b[<0;5;11M\x1b[<0;5;11m"); // left-click a low blank row -> normal + pin (+1 row: global tagbar)
try hs.pump(400);
try hs.send("i"); // insert
try hs.pump(400);
@@ -93,7 +100,7 @@ pub fn main(init: std.process.Init) !void {
try hs.pump(600);
try hs.send("\x1b"); // -> normal
try hs.pump(400);
- try hs.send("\x1b[<1;5;10M\x1b[<33;21;10M\x1b[<1;21;10m"); // middle-drag-select the line -> run (padded)
+ try hs.send("\x1b[<1;5;11M\x1b[<33;21;11M\x1b[<1;21;11m"); // middle-drag-select the line -> run (padded)
try hs.expectWaitFor("PAD13Z", 8000, "padded command did not run");
try hs.pump(600);
{
@@ -149,7 +156,7 @@ pub fn main(init: std.process.Init) !void {
try hs.send("echo CMDHIDEZZ\r");
try hs.expectWaitFor("CMDHIDEZZ", 8000, "tty mode did not run the command");
try hs.pump(800); // let tty-mode frames settle
- try hs.send("\x1b[<0;6;3M\x1b[<0;6;3m"); // left-click body -> normal (prompts now hidden)
+ try hs.send("\x1b[<0;6;4M\x1b[<0;6;4m"); // left-click body -> normal (prompts now hidden) (+1 row: global tagbar)
try hs.pump(600);
try hs.expectContains("CMDHIDEZZ", "command output not shown in normal mode");
// The prompt row (with the typed command) is hidden in normal mode now too. We
@@ -161,13 +168,13 @@ pub fn main(init: std.process.Init) !void {
// cursor at text col 0, type a computed echo, then middle-select those 15
// cells and release. "Q7Z" appears only if the SELECTED typed text ran —
// proving selection reads the rendered screen.
- try hs.send("\x1b[<0;3;14M\x1b[<0;3;14m"); // click blank row 13, col 0 -> normal + pin
+ try hs.send("\x1b[<0;3;15M\x1b[<0;3;15m"); // click blank row, col 0 -> normal + pin (+1 row: global tagbar)
try hs.pump(400);
try hs.send("i"); // normal -> insert so typing composes the command
try hs.pump(400);
try hs.send("echo Q$((3+4))Z");
try hs.pump(600); // let the run build
- try hs.send("\x1b[<1;3;14M\x1b[<33;17;14M\x1b[<1;17;14m"); // middle-select it
+ try hs.send("\x1b[<1;3;15M\x1b[<33;17;15M\x1b[<1;17;15m"); // middle-select it (+1 row: global tagbar)
try hs.expectWaitFor("Q7Z", 8000, "middle-click did not execute the selected typed text");
// leave insert -> normal so the next step can reach tty via Ctrl-b
try hs.send("\x1b");
@@ -184,7 +191,7 @@ pub fn main(init: std.process.Init) !void {
try hs.pump(500);
{
var w: usize = 0;
- while (w < 30) : (w += 1) try hs.send("\x1b[<64;5;5M"); // wheel UP over the pane
+ while (w < 30) : (w += 1) try hs.send("\x1b[<64;5;6M"); // wheel UP over the pane (+1 row: global tagbar)
}
try hs.pump(500);
try hs.expectNotContains("LN40", "wheel-up did not scroll the pane off the bottom");
@@ -202,7 +209,7 @@ pub fn main(init: std.process.Init) !void {
try hs.pump(700);
try hs.send("rm -rf /tmp/pardesE2E; mkdir -p /tmp/pardesE2E/ZZUNIQDIR; clear; echo /tmp/pardesE2E\r");
try hs.pump(2000); // let it run + settle
- try hs.send("\x1b[<2;5;2M\x1b[<2;5;2m"); // right-click the path on screen row 1
+ try hs.send("\x1b[<2;5;3M\x1b[<2;5;3m"); // right-click the path (+1 row: global tagbar)
try hs.expectWaitFor("ZZUNIQDIR", 12000, "right-click on a directory did not open+ls it");
// 8. Right-click a "path:line" opens a line-numbered FILE pane SCROLLED to
@@ -220,13 +227,13 @@ pub fn main(init: std.process.Init) !void {
}
try hs.resize(30, 100); // clean geometry (right column starts ~col 51)
try hs.pump(700); // settle the reflow
- try hs.send("\x1b[<0;60;11M\x1b[<0;60;11m"); // left-click blank row, right column -> normal + pin
+ try hs.send("\x1b[<0;60;19M\x1b[<0;60;19m"); // left-click a blank row well below the ls output, right column -> normal + pin (+1 row: global tagbar)
try hs.pump(400);
try hs.send("i"); // normal -> insert so typing composes the path:line
try hs.pump(400);
try hs.send("/tmp/pardesE2Efile.txt:150");
try hs.pump(800); // build the run
- try hs.send("\x1b[<2;70;11M\x1b[<2;70;11m"); // right-click on the typed path
+ try hs.send("\x1b[<2;70;19M\x1b[<2;70;19m"); // right-click on the typed path (+1 row: global tagbar)
try hs.expectWaitFor("ZZDEEPMARK", 12000, "right-click 'path:150' did not open the file scrolled to line 150");
// 9. Modal modes on the active FILE pane (left open & focused by step 8):
@@ -260,16 +267,26 @@ pub fn main(init: std.process.Init) !void {
// sanity: the deep marker (line 150, above the opened/deleted line) survives.
try hs.expectContains("ZZDEEPMARK", "ZZDEEPMARK vanished — file pane content was clobbered");
- // 11. The Tutor builtin: middle-clicking "Tutor" in any tag spawns the tutor
- // text as a new file pane. The tutor is embedded in the binary
- // (@embedFile), so its content (the PARDES TUTOR header + the acme mouse
- // intro) appears on screen. Middle-click is button 1 (SGR: <1;...M).
- // Tag row 0 is the first row of any pane; "Tutor" sits at the end of the
- // tag. Click somewhere in the tag's right half to hit it.
- try hs.send("\x1b[<1;48;1M\x1b[<1;48;1m"); // middle-click "Tutor" in the tag row (file pane's "nm " prefix shifts it +3)
- try hs.expectWaitFor("PARDES TUTOR", 10000, "middle-click 'Tutor' did not spawn the tutor pane");
+ // 11. The Tutor builtin now lives in the GLOBAL tagbar (top row, full width):
+ // "Kill Newcol Tutor Debug". Clicking "Tutor" there spawns the tutor as a
+ // file pane. The tutor is embedded in the binary (@embedFile), so its
+ // content (the PARDES TUTOR header + the acme mouse intro) appears. "Tutor"
+ // sits at cols 12-16 (0-based) -> SGR col ~14, the tagbar is screen row 1.
+ try hs.send("\x1b[<1;14;1M\x1b[<1;14;1m"); // middle-click "Tutor" in the global tagbar
+ try hs.expectWaitFor("PARDES TUTOR", 10000, "global-tagbar 'Tutor' did not spawn the tutor pane");
try hs.expectContains("tmux + vi + acme", "tutor pane content is wrong (no tagline)");
// Tell the app to quit (ctrl-c is the app's own exit key).
try hs.send("\x03");
+
+ // 12. The --tty flag: start tmux-style as a single shell pane already in tty
+ // mode. Confirm the (only) pane's tag shows "sy" (tty) and that keystrokes
+ // go straight to the shell. A fresh app instance with the flag.
+ {
+ var hs2 = try Harness.initArgs(gpa, exe.ptr, 24, 80, "--tty");
+ defer hs2.deinit();
+ try hs2.expectWaitFor("sy /", 6000, "--tty did not start in tty mode (no 'sy' tag)");
+ try hs2.send("echo ZZTTYFLAG\r");
+ try hs2.expectWaitFor("ZZTTYFLAG", 8000, "--tty single shell did not forward input to the shell");
+ }
}