summaryrefslogtreecommitdiff
path: root/main.zig
diff options
context:
space:
mode:
Diffstat (limited to 'main.zig')
-rw-r--r--main.zig961
1 files changed, 698 insertions, 263 deletions
diff --git a/main.zig b/main.zig
index 5d604460..a19fae38 100644
--- a/main.zig
+++ b/main.zig
@@ -1,6 +1,7 @@
const std = @import("std");
const vaxis = @import("vaxis");
const ghostty_vt = @import("ghostty-vt");
+const Term = @import("term.zig");
extern "c" fn forkpty(
amaster: *c_int,
@@ -9,8 +10,10 @@ extern "c" fn forkpty(
winp: ?*const std.posix.winsize,
) c_int;
-extern "c" fn execvp(
- file: [*:0]const u8,
+// execv, not execvp: no PATH search means no malloc in the child, so it's
+// async-signal-safe and survives being forked from a multithreaded process.
+extern "c" fn execv(
+ path: [*:0]const u8,
argv: [*:null]const ?[*:0]const u8,
) c_int;
@@ -18,13 +21,12 @@ extern "c" fn _exit(status: c_int) noreturn;
// basic file navigation
// basic shell io
-// basic layouting: for now following acme and ad
+// acme-style layout: columns split horizontally, windows stacked in each column.
// * /home/goblin/05-genizah/plan9port/src/cmd/acme
-// * /home/goblin/05-genizah/ad
-// * /usr/lib/zig
-// * ../03-plano
// right click and enter on lines to open files at specified lines
+const PtyRead = struct { id: usize, bytes: []u8 };
+
pub const Command = struct {
prev: ?*Command = null,
pub var value: union(enum) {
@@ -34,92 +36,286 @@ pub const Command = struct {
quit,
tick,
key_press: vaxis.Key,
- pty_read: []u8,
+ pty_read: PtyRead,
+ pty_eof: usize,
winsize: vaxis.Winsize,
mouse: vaxis.Mouse,
} = .nop;
};
const Loop = vaxis.Loop(@TypeOf(Command.value));
-const MAX_FILES: usize = 0x200;
-fn ticker(io: std.Io, loop: *Loop) void {
- while (true) {
- io.sleep(.fromMilliseconds(2), .awake) catch return;
- _ = loop.tryPostEvent(.tick) catch return;
- }
-}
+// layout constants
+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)
+const MINH: u16 = 3; // min pane height (cells)
+const MAX_TERMS: usize = 16;
+const MAX_COLS: usize = 6;
+
+const Rect = struct { x: u16, y: u16, w: u16, h: u16 };
+
+// acme model: columns own X (col_weight), windows own Y (Term.vweight). The
+// per-column ordered list `col_terms[c][0..col_n[c]]` is the source of truth.
+const Layout = struct {
+ ncol: usize,
+ col_weight: [MAX_COLS]f32,
+ col_terms: [MAX_COLS][MAX_TERMS]usize,
+ col_n: [MAX_COLS]usize,
+};
+
+const Drag = union(enum) {
+ none,
+ border_v: struct { left_col: usize, cur_x: u16 },
+ border_h: struct { col: usize, top_idx: usize, cur_y: u16 },
+ move: struct { id: usize, cur_x: u16, cur_y: u16 },
+ select: struct { id: usize, button: Button },
+};
+
+// mouse button driving a selection; also indexes Term.sel / SEL_BG / SEL_FG.
+const Button = enum { left, middle, right };
+
+// highlight colors per button (left, middle, right)
+const SEL_BG = [3]u8{ 4, 2, 5 }; // blue, green, magenta
+const SEL_FG = [3]u8{ 7, 0, 7 }; // readable text on the highlight
fn fork_pty(argv: [:0]const u8, ws: std.posix.winsize) std.Io.File {
var master: c_int = undefined;
const pid = forkpty(&master, null, null, &ws);
if (pid == 0) {
const child_argv = [_:null]?[*:0]const u8{ argv.ptr, null };
- _ = execvp(argv.ptr, &child_argv);
+ _ = execv(argv.ptr, &child_argv);
_exit(127);
}
-
- // not sure about the false here
- const f: std.Io.File = .{ .handle = master, .flags = .{ .nonblocking = false } };
- return f;
+ return .{ .handle = master, .flags = .{ .nonblocking = false } };
}
-// TIOCGWINSZ: ask the kernel what size the pty currently thinks it is.
-fn pty_get_size(pty: std.Io.File) std.posix.winsize {
- var ws: std.posix.winsize = .{ .row = 0, .col = 0, .xpixel = 0, .ypixel = 0 };
- _ = std.posix.system.ioctl(pty.handle, std.posix.T.IOCGWINSZ, @intFromPtr(&ws));
- return ws;
-}
-
-// TIOCSWINSZ: set the pty size; the kernel raises SIGWINCH in the child (bash)
-// so it (and anything running in it) reflows to the new grid.
+// TIOCSWINSZ: set the pty size; the kernel raises SIGWINCH in the child so it
+// (and anything running in it) reflows to the new grid.
fn pty_set_size(pty: std.Io.File, rows: u16, cols: u16) void {
const ws: std.posix.winsize = .{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
_ = std.posix.system.ioctl(pty.handle, std.posix.T.IOCSWINSZ, @intFromPtr(&ws));
}
-fn readPty(io: std.Io, gpa: std.mem.Allocator, pty: std.Io.File, loop: *Loop) !void {
+// One reader task per terminal. Tags each chunk with the terminal's slot id so
+// the main loop knows which emulator to feed; posts pty_eof when the shell exits.
+fn readPty(io: std.Io, gpa: std.mem.Allocator, pty: std.Io.File, id: usize, loop: *Loop) anyerror!void {
var read_buf: [0x1000]u8 = undefined;
var reader = pty.readerStreaming(io, &read_buf);
while (true) {
var buf: [0x1000]u8 = undefined;
var vec = [_][]u8{&buf};
- const n = try reader.interface.readVec(&vec);
+ const n = reader.interface.readVec(&vec) catch break;
if (n == 0) break;
const bytes = try gpa.dupe(u8, buf[0..n]);
- errdefer gpa.free(bytes);
- try loop.postEvent(.{ .pty_read = bytes });
+ loop.postEvent(.{ .pty_read = .{ .id = id, .bytes = bytes } }) catch {
+ gpa.free(bytes);
+ break;
+ };
+ }
+ loop.postEvent(.{ .pty_eof = id }) catch {};
+}
+
+// fork the pty + boot the emulator. Forking is the dangerous part in a
+// multithreaded process, so this does NOT start the reader task (no new
+// threads) — call startReader once the term is wired in.
+fn forkTerm(gpa: std.mem.Allocator, init_cols: u16, init_rows: u16) !*Term {
+ const pty = fork_pty("/usr/bin/bash", .{ .row = init_rows, .col = init_cols, .xpixel = 0, .ypixel = 0 });
+ const t = try gpa.create(Term);
+ errdefer gpa.destroy(t);
+ t.* = .{
+ .pty = pty,
+ .term = try ghostty_vt.Terminal.init(gpa, .{ .cols = init_cols, .rows = init_rows }),
+ .stream = undefined,
+ .reader = .{ .any_future = null, .result = {} }, // no-op until startReader
+ .cols = init_cols,
+ .rows = init_rows,
+ };
+ t.stream = t.term.vtStream();
+ return t;
+}
+
+fn startReader(io: std.Io, gpa: std.mem.Allocator, loop: *Loop, t: *Term, id: usize) !void {
+ t.reader = try io.concurrent(readPty, .{ io, gpa, t.pty, id, loop });
+}
+
+// ---- layout surgery over the per-column ordered lists ----
+
+fn layoutFindTerm(l: *const Layout, id: usize) ?struct { col: usize, idx: usize } {
+ var c: usize = 0;
+ while (c < l.ncol) : (c += 1) {
+ var k: usize = 0;
+ while (k < l.col_n[c]) : (k += 1) {
+ if (l.col_terms[c][k] == id) return .{ .col = c, .idx = k };
+ }
+ }
+ return null;
+}
+
+fn layoutRemove(l: *Layout, id: usize) void {
+ const f = layoutFindTerm(l, id) orelse return;
+ const c = f.col;
+ var k = f.idx;
+ while (k + 1 < l.col_n[c]) : (k += 1) l.col_terms[c][k] = l.col_terms[c][k + 1];
+ l.col_n[c] -= 1;
+ if (l.col_n[c] == 0) {
+ var j = c;
+ while (j + 1 < l.ncol) : (j += 1) {
+ l.col_terms[j] = l.col_terms[j + 1];
+ l.col_n[j] = l.col_n[j + 1];
+ l.col_weight[j] = l.col_weight[j + 1];
+ }
+ l.ncol -= 1;
+ }
+}
+
+fn layoutInsert(l: *Layout, c: usize, idx: usize, id: usize) void {
+ var k = l.col_n[c];
+ while (k > idx) : (k -= 1) l.col_terms[c][k] = l.col_terms[c][k - 1];
+ l.col_terms[c][idx] = id;
+ l.col_n[c] += 1;
+}
+
+fn layoutAppendColumn(l: *Layout, id: usize) void {
+ if (l.ncol >= MAX_COLS) return;
+ const c = l.ncol;
+ l.col_weight[c] = 1;
+ l.col_terms[c][0] = id;
+ l.col_n[c] = 1;
+ l.ncol += 1;
+}
+
+// Tile the screen: columns by col_weight, then each column's windows by vweight.
+// Last column/window absorbs rounding so the tiling always fills the screen.
+// `out_*` written for every live term and live column.
+fn computeGeom(
+ l: *const Layout,
+ terms: *const [MAX_TERMS]?*Term,
+ sw: u16,
+ sh: u16,
+ rects: *[MAX_TERMS]Rect,
+ col_x: *[MAX_COLS]u16,
+ col_w: *[MAX_COLS]u16,
+) void {
+ if (l.ncol == 0) return;
+ var wsum: f32 = 0;
+ for (0..l.ncol) |c| wsum += l.col_weight[c];
+ if (wsum <= 0) wsum = 1;
+ const sep_cols: u16 = @intCast(l.ncol - 1);
+ const avail_w: u16 = sw -| sep_cols;
+
+ var x: u16 = 0;
+ for (0..l.ncol) |c| {
+ const last = (c + 1 == l.ncol);
+ const cw: u16 = if (last) (sw -| x) else @max(1, @as(u16, @intFromFloat(@round(@as(f32, @floatFromInt(avail_w)) * l.col_weight[c] / wsum))));
+ col_x[c] = x;
+ col_w[c] = cw;
+
+ const n = l.col_n[c];
+ var vsum: f32 = 0;
+ for (0..n) |k| {
+ if (terms[l.col_terms[c][k]]) |t| vsum += t.vweight;
+ }
+ if (vsum <= 0) vsum = 1;
+ const sep_rows: u16 = if (n > 0) @intCast(n - 1) else 0;
+ const avail_h: u16 = sh -| sep_rows;
+
+ var y: u16 = 0;
+ for (0..n) |k| {
+ const id = l.col_terms[c][k];
+ const t = terms[id] orelse continue;
+ const lastk = (k + 1 == n);
+ const ch: u16 = if (lastk) (sh -| y) else @max(1, @as(u16, @intFromFloat(@round(@as(f32, @floatFromInt(avail_h)) * t.vweight / vsum))));
+ rects[id] = .{ .x = x, .y = y, .w = cw, .h = ch };
+ y +|= ch + 1;
+ }
+ x +|= cw + 1;
}
- try loop.postEvent(.quit);
}
-const term_x_padding = 3;
+// `alloc` must be a per-frame arena, NOT freed before vx.render(): vaxis is a
+// retained renderer and stores each cell's grapheme as a *slice* into the text
+// we hand it, so plainString has to outlive the render call.
+fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, active: bool) !void {
+ if (r.w <= GUTTER or r.h == 0) return;
+
+ // text
+ const text = root.child(.{ .x_off = r.x + GUTTER, .y_off = r.y, .width = r.w - GUTTER, .height = r.h });
+ text.clear();
+ const tty_str = try t.term.plainString(alloc);
+ const off: i32 = @intCast(t.term.screens.active.pages.scrollbar().offset);
+ var it = std.mem.splitAny(u8, tty_str, "\n");
+ var i: u16 = 0;
+ while (it.next()) |line| : (i += 1) {
+ _ = text.printSegment(.{ .text = line }, .{ .row_offset = i });
+ }
+
+ // recolor selected cells, one pass per button — later buttons win on overlap.
+ // only while actively dragging; a released selection is no longer drawn.
+ for (t.sel, 0..) |sl, b| {
+ if (sl.state != .dragging) continue;
+ const r0 = @min(sl.r0, sl.r1);
+ const r1 = @max(sl.r0, sl.r1);
+ const c0 = @max(0, @min(sl.c0, sl.c1));
+ const c1 = @max(0, @max(sl.c0, sl.c1));
+ var row: u16 = 0;
+ while (row < r.h) : (row += 1) {
+ const j = @as(i32, row) + off;
+ if (j < r0 or j > r1) continue;
+ var col: i32 = c0;
+ while (col <= c1 and col < @as(i32, r.w - GUTTER)) : (col += 1) {
+ const cc: u16 = @intCast(col);
+ if (text.readCell(cc, row)) |cell| {
+ var nc = cell;
+ nc.default = false; // else vaxis ignores the style on cleared cells
+ nc.style.bg = .{ .index = SEL_BG[b] };
+ nc.style.fg = .{ .index = SEL_FG[b] };
+ text.writeCell(cc, row, nc);
+ }
+ }
+ }
+ }
+ if (active) text.showCursor(t.term.screens.active.cursor.x, t.term.screens.active.cursor.y);
+
+ // gutter: box (the drag/rearrange handle) on top, scrollbar below
+ const gut = root.child(.{ .x_off = r.x, .y_off = r.y, .width = GUTTER, .height = r.h });
+ const box_bg: u8 = if (active) 4 else 8;
+ var bx: u16 = 0;
+ while (bx < GUTTER) : (bx += 1) {
+ var by: u16 = 0;
+ while (by < BOX_H) : (by += 1)
+ gut.writeCell(bx, by, .{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .index = box_bg } } });
+ }
+ const sb = t.term.screens.active.pages.scrollbar();
+ const track_h: usize = if (r.h > BOX_H) r.h - BOX_H else 0;
+ if (track_h > 0) {
+ const total: usize = if (sb.total == 0) 1 else sb.total;
+ const pos = (track_h * sb.offset) / total;
+ const len = @max(1, (track_h * sb.len) / total);
+ var gy: usize = 0;
+ while (gy < track_h) : (gy += 1) {
+ const thumb = gy >= pos and gy < pos + len;
+ const st_bg: u8 = if (thumb) 6 else 8;
+ var cx: u16 = 0;
+ while (cx < GUTTER) : (cx += 1)
+ gut.writeCell(cx, @intCast(BOX_H + gy), .{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .index = st_bg } } });
+ }
+ }
+}
pub fn main(init: std.process.Init) !void {
const io = init.io;
const gpa = init.gpa;
- // live grid size of the embedded terminal; recomputed on every resize.
- // seeded so the pty/ghostty have something before the first winsize event.
- var nrows: u16 = 64;
- var ncols: u16 = 100;
-
- // how many winsize events we've handled; shown on the debug panel.
+ var screen_w: u16 = 80;
+ var screen_h: u16 = 24;
var resize_count: usize = 0;
- var term: ghostty_vt.Terminal = try .init(gpa, .{
- .cols = ncols,
- .rows = nrows,
- });
- defer term.deinit(gpa);
- var term_stream = term.vtStream();
- defer term_stream.deinit();
-
- var selection_start_col: ?i32 = null;
- var selection_end_col: ?i32 = null;
- var selection_start_row: ?i32 = null;
- var selection_end_row: ?i32 = null;
- var selection_state: enum { done, dragging, none } = .none;
+ // 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);
+ defer frame_arena.deinit();
+ var ov_buf: [128]u8 = undefined; // overlay text; must outlive each vx.render()
var tty_buf: [0x10000]u8 = undefined;
var tty = try vaxis.Tty.init(io, &tty_buf);
@@ -127,264 +323,503 @@ pub fn main(init: std.process.Init) !void {
var vx = try vaxis.init(io, gpa, init.environ_map, .{});
defer vx.deinit(gpa, tty.writer());
- const pty_master = fork_pty("/usr/bin/bash", .{
- .row = nrows,
- .col = ncols,
- .xpixel = 0,
- .ypixel = 0,
- });
-
- var write_buf: [0x100]u8 = undefined;
- var writer = pty_master.writer(io, &write_buf);
-
- std.debug.print("written {}\n", .{try writer.interface.write("ls /\r\n")});
- try writer.flush();
-
try vx.setMouseMode(tty.writer(), true);
try vx.enterAltScreen(tty.writer());
defer vx.exitAltScreen(tty.writer()) catch {};
+ // ---- terminals + layout ----
+ var terms: [MAX_TERMS]?*Term = @splat(null);
+ defer for (&terms) |*slot| {
+ if (slot.*) |t| {
+ t.deinit(io, gpa);
+ gpa.destroy(t);
+ slot.* = null;
+ }
+ };
+
+ var layout: Layout = .{
+ .ncol = 0,
+ .col_weight = @splat(1),
+ .col_terms = undefined,
+ .col_n = @splat(0),
+ };
+
+ // 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);
+ terms[1] = try forkTerm(gpa, screen_w, screen_h);
+ terms[2] = try forkTerm(gpa, screen_w, screen_h);
+ 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);
try loop.start();
defer loop.stop();
-
- // start() only spawns the tty-read thread; it does NOT wire up resize
- // delivery. Without this, the only winsize event we ever see is the initial
- // one ttyRun posts, so the terminal never tracks the window after startup.
- // installResizeHandler registers the SIGWINCH handler that posts a winsize
- // event on every resize. (vaxis resets it automatically if the terminal
- // later negotiates in-band resize via queryTerminal.)
try loop.installResizeHandler();
- var pty_reader = try io.concurrent(readPty, .{ io, gpa, pty_master, &loop });
- defer _ = pty_reader.cancel(io) catch {};
- defer pty_master.close(io);
- var dbg_print_buf: [0x1000]u8 = undefined;
+ // 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);
+ }
+
+ var rects: [MAX_TERMS]Rect = undefined;
+ var col_x: [MAX_COLS]u16 = undefined;
+ var col_w: [MAX_COLS]u16 = undefined;
+ computeGeom(&layout, &terms, screen_w, screen_h, &rects, &col_x, &col_w);
+
+ var drag: Drag = .none;
while (true) {
const event = try loop.nextEvent();
- const win = vx.window();
switch (event) {
.mouse => |mouse| {
- if (mouse.button == .wheel_down) {
- term.screens.active.scroll(.{ .delta_row = 1 });
- }
+ const mcol: u16 = if (mouse.col < 0) 0 else @min(@as(u16, @intCast(mouse.col)), screen_w -| 1);
+ const mrow: u16 = if (mouse.row < 0) 0 else @min(@as(u16, @intCast(mouse.row)), screen_h -| 1);
- if (mouse.button == .wheel_up) {
- term.screens.active.scroll(.{ .delta_row = -1 });
- }
+ // which pane is under the cursor (if any)
+ const hovered: ?usize = for (terms, 0..) |slot, i| {
+ if (slot == null) continue;
+ const r = rects[i];
+ if (mcol >= r.x and mcol < r.x + r.w and mrow >= r.y and mrow < r.y + r.h) break i;
+ } else null;
- if (mouse.type == .press) {
- if (mouse.button == .left and mouse.col <= term_x_padding) {
- term.screens.active.scroll(.{ .delta_row = -(mouse.row) });
- }
+ if (mouse.button == .wheel_down) {
+ if (hovered) |id| terms[id].?.term.screens.active.scroll(.{ .delta_row = 1 });
+ } else if (mouse.button == .wheel_up) {
+ if (hovered) |id| terms[id].?.term.screens.active.scroll(.{ .delta_row = -1 });
+ } else if (mouse.type == .press and mouse.button == .left) {
+ var handled = false;
- if (mouse.button == .right and mouse.col <= term_x_padding) {
- term.screens.active.scroll(.{ .delta_row = mouse.row });
+ // vertical separator (between columns)?
+ var c: usize = 0;
+ while (c + 1 < layout.ncol and !handled) : (c += 1) {
+ if (mcol == col_x[c] + col_w[c]) {
+ drag = .{ .border_v = .{ .left_col = c, .cur_x = mcol } };
+ handled = true;
+ }
}
- // selection
- if (mouse.button == .left and mouse.col > term_x_padding) {
- selection_start_col = mouse.col;
- selection_start_row = mouse.row + @as(
- i32,
- @intCast(term.screens.active.pages.scrollbar().offset),
- );
- selection_state = .dragging;
+ // horizontal separator (between stacked windows)?
+ if (!handled) {
+ var cc: usize = 0;
+ outer: while (cc < layout.ncol) : (cc += 1) {
+ if (mcol < col_x[cc] or mcol >= col_x[cc] + col_w[cc]) continue;
+ var k: usize = 0;
+ while (k + 1 < layout.col_n[cc]) : (k += 1) {
+ const id = layout.col_terms[cc][k];
+ const r = rects[id];
+ if (mrow == r.y + r.h) {
+ drag = .{ .border_h = .{ .col = cc, .top_idx = k, .cur_y = mrow } };
+ handled = true;
+ break :outer;
+ }
+ }
+ }
}
- }
-
- if (mouse.type == .drag and selection_state == .dragging and mouse.col > term_x_padding) {
- selection_end_col = mouse.col;
- selection_end_row = mouse.row + @as(
- i32,
- @intCast(term.screens.active.pages.scrollbar().offset),
- );
- }
- if (mouse.type == .release and mouse.col > term_x_padding) {
- selection_end_col = mouse.col;
- selection_end_row = mouse.row + @as(
- i32,
- @intCast(term.screens.active.pages.scrollbar().offset),
- );
- selection_state = .done;
+ // inside a pane?
+ if (!handled) {
+ if (hovered) |id| {
+ const r = rects[id];
+ const in_gutter = mcol < r.x + GUTTER;
+ if (in_gutter and mrow < r.y + BOX_H) {
+ drag = .{ .move = .{ .id = id, .cur_x = mcol, .cur_y = mrow } };
+ } else if (in_gutter) {
+ active = id;
+ if (terms[id]) |t| {
+ const local: i16 = @intCast(@as(i32, mrow) - @as(i32, r.y + BOX_H));
+ t.term.screens.active.scroll(.{ .delta_row = -local });
+ }
+ } else {
+ active = id;
+ if (terms[id]) |t| {
+ const textx = r.x + GUTTER;
+ const off: i32 = @intCast(t.term.screens.active.pages.scrollbar().offset);
+ t.sel[@intFromEnum(Button.left)] = .{
+ .state = .dragging,
+ .c0 = @as(i32, mcol) - @as(i32, textx),
+ .r0 = (@as(i32, mrow) - @as(i32, r.y)) + off,
+ .c1 = @as(i32, mcol) - @as(i32, textx),
+ .r1 = (@as(i32, mrow) - @as(i32, r.y)) + off,
+ };
+ drag = .{ .select = .{ .id = id, .button = .left } };
+ }
+ }
+ }
+ }
+ } else if (mouse.type == .press and (mouse.button == .middle or mouse.button == .right)) {
+ // middle/right select text too (own state + color), text area only
+ const b: Button = if (mouse.button == .middle) .middle else .right;
+ if (hovered) |id| {
+ const r = rects[id];
+ if (mcol >= r.x + GUTTER) {
+ active = id;
+ if (terms[id]) |t| {
+ const textx = r.x + GUTTER;
+ const off: i32 = @intCast(t.term.screens.active.pages.scrollbar().offset);
+ t.sel[@intFromEnum(b)] = .{
+ .state = .dragging,
+ .c0 = @as(i32, mcol) - @as(i32, textx),
+ .r0 = (@as(i32, mrow) - @as(i32, r.y)) + off,
+ .c1 = @as(i32, mcol) - @as(i32, textx),
+ .r1 = (@as(i32, mrow) - @as(i32, r.y)) + off,
+ };
+ drag = .{ .select = .{ .id = id, .button = b } };
+ }
+ }
+ }
+ } else if (mouse.type == .drag) {
+ switch (drag) {
+ .border_v => |*d| {
+ const c = d.left_col;
+ if (c + 1 < layout.ncol) {
+ const lo = col_x[c] + MINW;
+ const hi = col_x[c] +| col_w[c] +| col_w[c + 1] -| MINW;
+ d.cur_x = if (lo <= hi) std.math.clamp(mcol, lo, hi) else mcol;
+ } else d.cur_x = mcol;
+ },
+ .border_h => |*d| {
+ const cc = d.col;
+ const k = d.top_idx;
+ if (k + 1 < layout.col_n[cc]) {
+ const a = layout.col_terms[cc][k];
+ const b = layout.col_terms[cc][k + 1];
+ const lo = rects[a].y + MINH;
+ const hi = rects[a].y +| rects[a].h +| rects[b].h -| MINH;
+ d.cur_y = if (lo <= hi) std.math.clamp(mrow, lo, hi) else mrow;
+ } else d.cur_y = mrow;
+ },
+ .move => |*d| {
+ d.cur_x = mcol;
+ d.cur_y = mrow;
+ },
+ .select => |s| {
+ if (terms[s.id]) |t| {
+ const r = rects[s.id];
+ const textx = r.x + GUTTER;
+ const off: i32 = @intCast(t.term.screens.active.pages.scrollbar().offset);
+ t.sel[@intFromEnum(s.button)].c1 = @as(i32, mcol) - @as(i32, textx);
+ t.sel[@intFromEnum(s.button)].r1 = (@as(i32, mrow) - @as(i32, r.y)) + off;
+ }
+ },
+ .none => {},
+ }
+ } else if (mouse.type == .release) {
+ switch (drag) {
+ .border_v => |d| {
+ const c = d.left_col;
+ if (c + 1 < layout.ncol) {
+ const combined: f32 = @floatFromInt(col_w[c] + col_w[c + 1]);
+ var nl: f32 = @floatFromInt(d.cur_x -| col_x[c]);
+ const minw: f32 = MINW;
+ nl = std.math.clamp(nl, minw, @max(minw, combined - minw));
+ const pair = layout.col_weight[c] + layout.col_weight[c + 1];
+ layout.col_weight[c] = pair * (nl / combined);
+ layout.col_weight[c + 1] = pair - layout.col_weight[c];
+ }
+ },
+ .border_h => |d| {
+ const c = d.col;
+ const k = d.top_idx;
+ if (k + 1 < layout.col_n[c]) {
+ const a = layout.col_terms[c][k];
+ const b = layout.col_terms[c][k + 1];
+ if (terms[a]) |ta| if (terms[b]) |tb| {
+ const combined: f32 = @floatFromInt(rects[a].h + rects[b].h);
+ var nt: f32 = @floatFromInt(d.cur_y -| rects[a].y);
+ const minh: f32 = MINH;
+ nt = std.math.clamp(nt, minh, @max(minh, combined - minh));
+ const pair = ta.vweight + tb.vweight;
+ ta.vweight = pair * (nt / combined);
+ tb.vweight = pair - ta.vweight;
+ };
+ }
+ },
+ .move => |d| moveTerm(&layout, &rects, &col_x, &col_w, d.id, d.cur_x, d.cur_y),
+ .select => |s| {
+ if (terms[s.id]) |t| {
+ t.sel[@intFromEnum(s.button)].state = .done;
+ if (selectionText(gpa, t, t.sel[@intFromEnum(s.button)])) |txt| {
+ defer gpa.free(txt);
+ onSelect(s.button, txt);
+ } else |_| {}
+ }
+ },
+ .none => {},
+ }
+ drag = .none;
}
},
.key_press => |key| {
- if (key.matches('c', .{ .ctrl = true })) {
- break;
- }
-
- if (key.matches('k', .{ .ctrl = true })) {
- win.clear();
- }
-
- // this is not what I expected; it's just blank up or down
- // if (key.matches('d', .{ .ctrl = true })) {
- // try term.scrollUp(1);
- // }
+ if (key.matches('c', .{ .ctrl = true })) break;
- if (key.matches('d', .{ .ctrl = true })) {
- term.screens.active.scroll(.{ .delta_row = 15 });
- }
- if (key.matches('u', .{ .ctrl = true })) {
- term.screens.active.scroll(.{ .delta_row = -15 });
- }
-
- if (key.text) |text| {
- try writer.interface.writeAll(text);
- } else switch (key.codepoint) {
- vaxis.Key.enter => try writer.interface.writeAll("\r"),
- vaxis.Key.backspace => try writer.interface.writeAll("\x7f"),
- vaxis.Key.tab => try writer.interface.writeAll("\t"),
- vaxis.Key.escape => try writer.interface.writeAll("\x1b"),
- vaxis.Key.up => try writer.interface.writeAll("\x1b[A"),
- vaxis.Key.down => try writer.interface.writeAll("\x1b[B"),
- vaxis.Key.right => try writer.interface.writeAll("\x1b[C"),
- vaxis.Key.left => try writer.interface.writeAll("\x1b[D"),
- else => {},
+ if (terms[active]) |at| {
+ if (key.matches('n', .{ .alt = true })) {
+ // new terminal below the active one, in its column.
+ // ponytail: forks from the now-multithreaded process;
+ // execv (not execvp) keeps the child async-signal-safe.
+ const free_slot: ?usize = for (terms, 0..) |s, i| {
+ if (s == null) break i;
+ } else null;
+ if (free_slot) |slot| {
+ if (forkTerm(gpa, screen_w, screen_h)) |nt| {
+ terms[slot] = nt;
+ startReader(io, gpa, &loop, nt, slot) catch {};
+ const f = layoutFindTerm(&layout, active).?;
+ layoutInsert(&layout, f.col, f.idx + 1, slot);
+ active = slot;
+ } else |_| {}
+ }
+ } else if (key.matches('c', .{ .alt = true })) {
+ // move active terminal into a fresh column
+ const f = layoutFindTerm(&layout, active).?;
+ if (layout.ncol < MAX_COLS and layout.col_n[f.col] > 1) {
+ layoutRemove(&layout, active);
+ layoutAppendColumn(&layout, active);
+ }
+ } else if (key.matches('d', .{ .ctrl = true })) {
+ at.term.screens.active.scroll(.{ .delta_row = 15 });
+ } else if (key.matches('u', .{ .ctrl = true })) {
+ at.term.screens.active.scroll(.{ .delta_row = -15 });
+ } else {
+ const bytes: ?[]const u8 = if (key.text) |text| text else switch (key.codepoint) {
+ vaxis.Key.enter => "\r",
+ vaxis.Key.backspace => "\x7f",
+ vaxis.Key.tab => "\t",
+ vaxis.Key.escape => "\x1b",
+ vaxis.Key.up => "\x1b[A",
+ vaxis.Key.down => "\x1b[B",
+ vaxis.Key.right => "\x1b[C",
+ vaxis.Key.left => "\x1b[D",
+ else => null,
+ };
+ if (bytes) |b| {
+ // raw write straight to the pty master; a fresh
+ // File.Writer per keystroke doesn't reliably flush.
+ var off: usize = 0;
+ while (off < b.len) {
+ const rc = std.os.linux.write(at.pty.handle, b[off..].ptr, b.len - off);
+ switch (std.posix.errno(rc)) {
+ .SUCCESS => off += @intCast(rc),
+ .INTR => {},
+ else => break,
+ }
+ }
+ }
+ }
}
- try writer.flush();
},
- .pty_read => |bytes| {
- defer gpa.free(bytes);
- term_stream.nextSlice(bytes);
+ .pty_read => |pr| {
+ defer gpa.free(pr.bytes);
+ if (terms[pr.id]) |t| t.stream.nextSlice(pr.bytes);
+ },
+ .pty_eof => |id| {
+ if (terms[id]) |t| {
+ layoutRemove(&layout, id);
+ t.deinit(io, gpa);
+ gpa.destroy(t);
+ terms[id] = null;
+ // refocus the first surviving terminal; quit if none remain.
+ if (active == id) active = for (terms, 0..) |s, i| {
+ if (s != null) break i;
+ } else break;
+ }
},
.winsize => |ws| {
resize_count += 1;
-
- // 1. let vaxis resize its own screen buffers
+ screen_w = ws.cols;
+ screen_h = ws.rows;
try vx.resize(gpa, tty.writer(), ws);
-
- // 2. carve a new grid out of the window. layout is: left
- // scrollbar gutter, then the terminal pane and the debug pane
- // side by side, each wrapped in a 1-cell border. so the two
- // panes split the leftover width, minus 4 cols of borders.
- const usable: u16 = if (ws.cols > term_x_padding + 4) ws.cols - term_x_padding - 4 else 2;
- // ncols = @max(1, usable / 2);
- ncols = usable;
- nrows = @max(1, if (ws.rows > 2) ws.rows - 2 else 1);
-
- // 3. push it to ghostty's emulator grid
- try term.resize(gpa, ncols, nrows);
-
- // 4. push it to the pty so bash (and its children) reflow
- pty_set_size(pty_master, nrows, ncols);
},
.quit => break,
else => {},
}
- // term.scrollViewport(.bottom);
- const tty_str = try term.plainString(gpa);
- defer gpa.free(tty_str);
-
- const win_child = win.child(.{
- .border = .{ .where = .bottom },
- .x_off = term_x_padding,
- .height = nrows + 2,
- .width = ncols + 2,
- });
-
- const win_scroll = win.child(.{
- .border = .{ .where = .none },
- .height = nrows + 2,
- .width = term_x_padding - 1,
- });
+ // recompute geometry from the (possibly mutated) layout, then push any
+ // grid-size change to each emulator + pty. Weights only change on a drag
+ // *release*, so mid-drag the rects are unchanged and nothing resizes.
+ computeGeom(&layout, &terms, screen_w, screen_h, &rects, &col_x, &col_w);
+ for (&terms, 0..) |*slot, id| {
+ if (slot.*) |t| {
+ const r = rects[id];
+ const cols = @max(1, r.w -| GUTTER);
+ const rows = @max(1, r.h);
+ if (cols != t.cols or rows != t.rows) {
+ t.term.resize(gpa, cols, rows) catch {};
+ pty_set_size(t.pty, rows, cols);
+ t.cols = cols;
+ t.rows = rows;
+ }
+ }
+ }
- const scroll_pos = (@as(usize, win_scroll.height) * term.screens.active.pages.scrollbar().offset) / term.screens.active.pages.scrollbar().total;
- const scroll_height = @max(1, (@as(usize, win_scroll.height) * term.screens.active.pages.scrollbar().len) / term.screens.active.pages.scrollbar().total);
+ // ---- render ----
+ const win = vx.window();
+ win.clear();
- win_scroll.fill(.{
- .char = .{ .grapheme = " " },
- .style = .{ .bg = .{ .index = 4 } },
- });
+ for (&terms, 0..) |*slot, id| {
+ if (slot.*) |t| try renderPane(win, frame_arena.allocator(), t, rects[id], id == active);
+ }
- for (0..term_x_padding) |col| {
- for (0..scroll_height) |row| {
- win_scroll.writeCell(@intCast(col), @intCast(scroll_pos + row), .{
- .char = .{ .grapheme = " " },
- .style = .{ .bg = .{ .index = 0 }, .fg = .{ .index = 6 } },
- });
+ // separators (the grips signal that borders are draggable)
+ for (0..layout.ncol) |c| {
+ if (c + 1 < layout.ncol) {
+ const sx = col_x[c] + col_w[c];
+ const mid = screen_h / 2;
+ var ry: u16 = 0;
+ while (ry < screen_h) : (ry += 1) {
+ const g: []const u8 = if (ry == mid) "⇔" else "│";
+ win.writeCell(sx, ry, .{ .char = .{ .grapheme = g }, .style = .{ .fg = .{ .index = 5 } } });
+ }
+ }
+ const n = layout.col_n[c];
+ var k: usize = 0;
+ while (k + 1 < n) : (k += 1) {
+ const r = rects[layout.col_terms[c][k]];
+ const sy = r.y + r.h;
+ const midx = col_x[c] + col_w[c] / 2;
+ var rx: u16 = col_x[c];
+ while (rx < col_x[c] + col_w[c]) : (rx += 1) {
+ const g: []const u8 = if (rx == midx) "⇕" else "─";
+ win.writeCell(rx, sy, .{ .char = .{ .grapheme = g }, .style = .{ .fg = .{ .index = 5 } } });
+ }
}
}
- const win_dbg = win.child(.{
- .border = .{ .where = .all },
- .x_off = (ncols / 2) + 2 + term_x_padding,
- .height = nrows + 2,
- .width = (ncols / 2) + 2,
- });
+ // dashed preview while dragging; commit happens on release.
+ switch (drag) {
+ .border_v => |d| {
+ var ry: u16 = 0;
+ while (ry < screen_h) : (ry += 1)
+ win.writeCell(d.cur_x, ry, .{ .char = .{ .grapheme = "╎" }, .style = .{ .fg = .{ .index = 3 } } });
+ },
+ .border_h => |d| {
+ const c = d.col;
+ var rx: u16 = col_x[c];
+ while (rx < col_x[c] + col_w[c]) : (rx += 1)
+ win.writeCell(rx, d.cur_y, .{ .char = .{ .grapheme = "╌" }, .style = .{ .fg = .{ .index = 3 } } });
+ },
+ .move => |d| {
+ // insertion indicator: dashed line at cur_y across the target column
+ var tc: usize = if (layout.ncol > 0) layout.ncol - 1 else 0;
+ for (0..layout.ncol) |c| {
+ if (d.cur_x >= col_x[c] and d.cur_x < col_x[c] + col_w[c]) {
+ tc = c;
+ break;
+ }
+ }
+ var rx: u16 = col_x[tc];
+ while (rx < col_x[tc] + col_w[tc]) : (rx += 1)
+ win.writeCell(rx, d.cur_y, .{ .char = .{ .grapheme = "╌" }, .style = .{ .fg = .{ .index = 2 } } });
+ win.writeCell(d.cur_x, d.cur_y, .{ .char = .{ .grapheme = "▌" }, .style = .{ .fg = .{ .index = 2 } } });
+ },
+ else => {},
+ }
- win_child.clear();
+ // 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| {
+ var nlive: usize = 0;
+ for (terms) |slot| {
+ if (slot != null) nlive += 1;
+ }
+ const sb = at.term.screens.active.pages.scrollbar();
+ const s = std.fmt.bufPrint(
+ &ov_buf,
+ "pardes resizes={d}\nterms {d} cols {d}\nactive #{d} {d}x{d}\nscroll {d}/{d}",
+ .{ resize_count, nlive, layout.ncol, active, at.cols, at.rows, sb.offset, sb.total },
+ ) catch "";
+ 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 } });
+ ov.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .index = 0 } } });
+ _ = ov.printSegment(.{ .text = s, .style = .{ .fg = .{ .index = 3 } } }, .{});
+ }
- // _ = win_child.printSegment(.{ .text = tty_str }, .{});
- // Selection rectangle in win_child-local space: rows carry the scroll
- // offset; cols are window-relative, so shift left by term_x_padding to
- // land inside win_child (x_off = term_x_padding).
- const off: i32 = @intCast(term.screens.active.pages.scrollbar().offset);
- const sel: ?struct { r0: i32, r1: i32, c0: i32, c1: i32 } = blk: {
- if (selection_state == .none) break :blk null;
- const sr = selection_start_row orelse break :blk null;
- const er = selection_end_row orelse break :blk null;
- const sc = selection_start_col orelse break :blk null;
- const ec = selection_end_col orelse break :blk null;
- break :blk .{
- .r0 = @min(sr, er),
- .r1 = @max(sr, er),
- .c0 = @min(sc, ec) - term_x_padding,
- .c1 = @max(sc, ec) - term_x_padding,
- };
- };
+ try vx.render(tty.writer());
+ _ = frame_arena.reset(.retain_capacity);
+ }
+}
- var lines_it = std.mem.splitAny(u8, tty_str, "\n");
- {
- var i: u16 = 0;
- while (lines_it.next()) |line| : (i += 1) {
- const j = @as(i32, i) + off;
- if (sel) |s| if (j >= s.r0 and j <= s.r1) {
- // ponytail: byte index == column (ASCII); wide chars misalign.
- const b0 = @min(@as(usize, @intCast(@max(0, s.c0))), line.len);
- const b1 = @min(@as(usize, @intCast(@max(0, s.c1 + 1))), line.len);
- _ = win_child.print(&.{
- .{ .text = line[0..b0] },
- .{ .text = line[b0..b1], .style = .{ .bg = .{ .index = 4 }, .fg = .{ .index = 3 } } },
- .{ .text = line[b1..] },
- }, .{ .row_offset = i });
- continue;
- };
- _ = win_child.printSegment(.{ .text = line }, .{ .row_offset = i });
- }
+// On box-drag release: drop the window into the column under cur_x at the
+// vertical slot picked by cur_y (acme's coldragwin, minus the live retile).
+fn moveTerm(
+ l: *Layout,
+ rects: *const [MAX_TERMS]Rect,
+ col_x: *const [MAX_COLS]u16,
+ col_w: *const [MAX_COLS]u16,
+ id: usize,
+ cur_x: u16,
+ cur_y: u16,
+) void {
+ const src = layoutFindTerm(l, id) orelse return;
+ // target column under cur_x (last column absorbs anything past the right edge)
+ var tc: usize = if (l.ncol > 0) l.ncol - 1 else 0;
+ for (0..l.ncol) |c| {
+ if (cur_x >= col_x[c] and cur_x < col_x[c] + col_w[c]) {
+ tc = c;
+ break;
}
+ }
- win_child.showCursor(term.screens.active.cursor.x, term.screens.active.cursor.y);
+ // insertion index in the target column, ignoring the moving window itself
+ var ti: usize = 0;
+ var k: usize = 0;
+ while (k < l.col_n[tc]) : (k += 1) {
+ const cid = l.col_terms[tc][k];
+ if (cid == id) continue;
+ const rr = rects[cid];
+ if (cur_y < rr.y + rr.h / 2) break;
+ ti += 1;
+ }
- const pty_ws = pty_get_size(pty_master);
- const dbg_str = try std.fmt.bufPrintSentinel(
- &dbg_print_buf,
- "c {} {}\npty pid {}\n grid {}x{} pty {}x{}\n resizes={}\n scroll {}/{} vis: {}\n{} -> {}, {} -> {}",
- .{
- pty_master.handle,
- term.screens.active.cursor.x,
- term.screens.active.cursor.y,
- ncols,
- nrows,
- pty_ws.col,
- pty_ws.row,
- resize_count,
- term.screens.active.pages.scrollbar().offset,
- term.screens.active.pages.scrollbar().total,
- term.screens.active.pages.scrollbar().len,
- selection_start_col orelse 0,
- selection_end_col orelse 0,
- selection_start_row orelse 0,
- selection_end_row orelse 0,
- },
- 0,
- );
- _ = win_dbg.printSegment(.{ .text = dbg_str }, .{});
+ const src_solo = l.col_n[src.col] == 1;
+ if (tc == src.col and src_solo) return; // dragging the only window of its column: nothing to do
- try vx.render(tty.writer());
+ layoutRemove(l, id);
+ if (src_solo and src.col < tc) tc -= 1; // removing src dropped a column to our left
+ if (tc >= l.ncol) tc = l.ncol - 1;
+ if (ti > l.col_n[tc]) ti = l.col_n[tc];
+ layoutInsert(l, tc, ti, id);
+}
+
+// Build the block-selected text, newline-joined per row; caller frees.
+fn selectionText(gpa: std.mem.Allocator, t: *Term, sl: Term.Sel) ![]u8 {
+ const off: i32 = @intCast(t.term.screens.active.pages.scrollbar().offset);
+ const r0 = @min(sl.r0, sl.r1);
+ const r1 = @max(sl.r0, sl.r1);
+ const c0: usize = @intCast(@max(0, @min(sl.c0, sl.c1)));
+ const c1: usize = @intCast(@max(0, @max(sl.c0, sl.c1)));
+ const ps = try t.term.plainString(gpa);
+ defer gpa.free(ps);
+ var out: std.ArrayList(u8) = .empty;
+ errdefer out.deinit(gpa);
+ var it = std.mem.splitAny(u8, ps, "\n");
+ var i: i32 = 0;
+ var first = true;
+ while (it.next()) |line| : (i += 1) {
+ const j = i + off;
+ if (j < r0 or j > r1) continue;
+ if (!first) try out.append(gpa, '\n');
+ first = false;
+ const b0 = @min(c0, line.len);
+ const b1 = @min(c1 + 1, line.len);
+ try out.appendSlice(gpa, line[b0..b1]);
}
+ return out.toOwnedSlice(gpa);
+}
+
+// Empty selection callback; receives the button and the selected text slice.
+// Fill in per-button behaviour later (acme: left=snarf, middle=execute, right=look).
+fn onSelect(button: Button, text: []const u8) void {
+ _ = button;
+ _ = text;
}