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, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const std.posix.winsize, ) c_int; // 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; extern "c" fn _exit(status: c_int) noreturn; // basic file navigation // basic shell io // acme-style layout: columns split horizontally, windows stacked in each column. // * /home/goblin/05-genizah/plan9port/src/cmd/acme // 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) { close, ping, nop, quit, tick, key_press: vaxis.Key, pty_read: PtyRead, pty_eof: usize, winsize: vaxis.Winsize, mouse: vaxis.Mouse, } = .nop; }; const Loop = vaxis.Loop(@TypeOf(Command.value)); // 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 }; _ = execv(argv.ptr, &child_argv); _exit(127); } return .{ .handle = master, .flags = .{ .nonblocking = false } }; } // 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)); } // 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 = reader.interface.readVec(&vec) catch break; if (n == 0) break; const bytes = try gpa.dupe(u8, buf[0..n]); 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; } } // `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; var screen_w: u16 = 80; var screen_h: u16 = 24; var resize_count: usize = 0; // 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); var vx = try vaxis.init(io, gpa, init.environ_map, .{}); defer vx.deinit(gpa, tty.writer()); 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(); try loop.installResizeHandler(); // 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(); switch (event) { .mouse => |mouse| { 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); // 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.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; // 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; } } // 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; } } } } // 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 (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, } } } } } }, .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; screen_w = ws.cols; screen_h = ws.rows; try vx.resize(gpa, tty.writer(), ws); }, .quit => break, else => {}, } // 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; } } } // ---- render ---- const win = vx.window(); win.clear(); for (&terms, 0..) |*slot, id| { if (slot.*) |t| try renderPane(win, frame_arena.allocator(), t, rects[id], id == active); } // 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 } } }); } } } // 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 => {}, } // 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 } } }, .{}); } try vx.render(tty.writer()); _ = frame_arena.reset(.retain_capacity); } } // 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; } } // 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 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 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; }