diff options
| author | Gabriel Schneider <[email protected]> | 2026-06-29 17:25:43 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-06-29 19:14:37 -0300 |
| commit | fd095f7fa6e26fba3aa797d2b57740e776d3bb04 (patch) | |
| tree | 17fa8c2a2e4bc26a691ae4912b8b8dd7e74f76fa /main.zig | |
| parent | a0ef7e5037505b211bab83fa18854204c5d66ba2 (diff) | |
| download | pardes-fd095f7fa6e26fba3aa797d2b57740e776d3bb04.tar.gz pardes-fd095f7fa6e26fba3aa797d2b57740e776d3bb04.zip | |
vibes layout and multiple terminals
Diffstat (limited to 'main.zig')
| -rw-r--r-- | main.zig | 961 |
1 files changed, 698 insertions, 263 deletions
@@ -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; } |
