From a68564e9fb9fb8be6d997db5e6ad9148fb506d18 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 30 Jun 2026 13:00:23 -0300 Subject: images? lol --- build.zig | 12 +++ build.zig.zon | 4 + e2e_harness.zig | 7 +- image.zig | 83 +++++++++++++++++ main.zig | 282 ++++++++++++++++++++++++++++++++++++++++++++------------ term.zig | 18 ++++ tests.zig | 53 +++++++---- 7 files changed, 383 insertions(+), 76 deletions(-) create mode 100644 image.zig 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"); + } } -- cgit v1.3