diff options
Diffstat (limited to 'main.zig')
| -rw-r--r-- | main.zig | 272 |
1 files changed, 191 insertions, 81 deletions
@@ -72,14 +72,40 @@ const Drag = union(enum) { select: struct { id: usize, button: Button }, }; -// mouse button driving a selection; also indexes Term.sel / SEL_BG / SEL_FG. +// mouse button driving a selection; also indexes Term.sel. 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 +// monochrome, low-contrast palette (rgb grays). +const gray = struct { + const bar = [3]u8{ 0x2a, 0x2a, 0x2a }; // dim bars (tag, scrollbar track, box idle) + const dim = [3]u8{ 0x3c, 0x3c, 0x3c }; // separators + const mid = [3]u8{ 0x52, 0x52, 0x52 }; // box active, scrollbar thumb, move preview + const lit = [3]u8{ 0x6e, 0x6e, 0x6e }; // dashed resize preview + const txt = [3]u8{ 0x96, 0x96, 0x96 }; // muted text +}; +// the drag button: a distinctive red (brighter when the window is focused). +const red = [3]u8{ 0xcc, 0x33, 0x33 }; +const red_dim = [3]u8{ 0x66, 0x2a, 0x2a }; +// per-button selection highlight: three gray levels, muted text. +const SEL_BG = [3][3]u8{ .{ 0x38, 0x38, 0x38 }, .{ 0x4c, 0x4c, 0x4c }, .{ 0x60, 0x60, 0x60 } }; +const sel_fg = [3]u8{ 0xc0, 0xc0, 0xc0 }; -fn fork_pty(argv: [:0]const u8, ws: std.posix.winsize) std.Io.File { +// Tag builtins. Middle-clicking the name in a window's tag runs the action; +// `builtins_str` is the space-joined name list, produced at comptime. +const SelectAction = enum { none, send, newcol, delcol, del }; +const Builtin = struct { name: []const u8, action: SelectAction }; +const builtins = [_]Builtin{ + .{ .name = "Newcol", .action = .newcol }, + .{ .name = "Delcol", .action = .delcol }, + .{ .name = "Del", .action = .del }, +}; +const builtins_str = blk: { + var s: []const u8 = ""; + for (builtins, 0..) |b, i| s = s ++ (if (i == 0) "" else " ") ++ b.name; + break :blk s; +}; + +fn fork_pty(argv: [:0]const u8, ws: std.posix.winsize) struct { file: std.Io.File, pid: std.posix.pid_t } { var master: c_int = undefined; const pid = forkpty(&master, null, null, &ws); if (pid == 0) { @@ -87,7 +113,35 @@ fn fork_pty(argv: [:0]const u8, ws: std.posix.winsize) std.Io.File { _ = execv(argv.ptr, &child_argv); _exit(127); } - return .{ .handle = master, .flags = .{ .nonblocking = false } }; + return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid }; +} + +fn termWrite(t: *Term, bytes: []const u8) void { + var off: usize = 0; + while (off < bytes.len) { + const rc = std.os.linux.write(t.pty.handle, bytes[off..].ptr, bytes.len - off); + switch (std.posix.errno(rc)) { + .SUCCESS => off += @intCast(rc), + .INTR => {}, + else => break, + } + } +} + +// The window tag: the shell's cwd followed by the builtin command names. Plain, +// selectable text (acme-style). Caller frees with `alloc`. +fn tagLine(alloc: std.mem.Allocator, t: *Term) ![]u8 { + var link: [1024]u8 = undefined; + var pathbuf: [64]u8 = undefined; + const cwd: []const u8 = cwd: { + const path = std.fmt.bufPrintZ(&pathbuf, "/proc/{d}/cwd", .{t.pid}) catch break :cwd "?"; + const rc = std.os.linux.readlinkat(std.os.linux.AT.FDCWD, path, &link, link.len); + break :cwd switch (std.posix.errno(rc)) { + .SUCCESS => link[0..rc], + else => "?", + }; + }; + return std.fmt.allocPrint(alloc, "{s} {s}", .{ cwd, builtins_str }); } // TIOCSWINSZ: set the pty size; the kernel raises SIGWINCH in the child so it @@ -120,11 +174,12 @@ fn readPty(io: std.Io, gpa: std.mem.Allocator, pty: std.Io.File, id: usize, loop // 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 child = 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, + .pty = child.file, + .pid = child.pid, .term = try ghostty_vt.Terminal.init(gpa, .{ .cols = init_cols, .rows = init_rows }), .stream = undefined, .reader = .{ .any_future = null, .result = {} }, // no-op until startReader @@ -239,19 +294,42 @@ fn computeGeom( 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 + // text area: tag line on row 0 (same row as the box), terminal body below. const text = root.child(.{ .x_off = r.x + GUTTER, .y_off = r.y, .width = r.w - GUTTER, .height = r.h }); text.clear(); + + // tag: cwd + builtins, drawn as a dim bar; plain selectable text. + const tag = try tagLine(alloc, t); + const tagw = text.child(.{ .height = BOX_H }); + tagw.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = gray.bar } } }); + _ = tagw.printSegment(.{ .text = tag, .style = .{ .fg = .{ .rgb = gray.txt }, .bg = .{ .rgb = gray.bar } } }, .{ .wrap = .none }); + + // FUTURE: hide shell prompts, render only command output. ghostty-vt tracks + // OSC 133 semantic prompts and stores the state we'd need: + // - per cell: page.Cell.semantic_content -> .output | .input | .prompt + // - per row: page.Row.semantic_prompt -> .none | .prompt | .prompt_continuation + // - cursor drives it via OSC 133 A/B/C/D (stream_terminal -> Terminal.semanticPrompt). + // Ready-made helpers (all public through ghostty_vt): Screen.selectOutput(pin), + // PageList.highlightSemanticContent(pin, .output), screen.pages.rowIterator(...), + // pin.rowAndCell().cell.semantic_content, pin.promptIterator/cellIterator. + // To do it: swap plainString for a row walk that skips rows where + // row.semantic_prompt != .none (coarse) or non-.output cells (fine): + // var it = screen.pages.rowIterator(.right_down, .{ .viewport = .{} }, null); + // while (it.next()) |pin| { if (pin.rowAndCell().row.semantic_prompt != .none) continue; ... } + // CATCH: only populated if the shell emits OSC 133 (shell integration). bash + // doesn't by default — every cell stays the default .output, so nothing would + // be hidden until we source ghostty's integration script or wrap PS1/ + // PROMPT_COMMAND with the 133;A/B/C/D markers in each spawned pty. Best as a + // toggle (a Hide/Show tag builtin) so it degrades gracefully without integration. 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 }); + _ = text.printSegment(.{ .text = line }, .{ .row_offset = BOX_H + i }); } - // recolor selected cells, one pass per button — later buttons win on overlap. - // only while actively dragging; a released selection is no longer drawn. + // recolor selected cells (pane-local visual coords), one pass per button — + // later buttons win on overlap; only while actively dragging. for (t.sel, 0..) |sl, b| { if (sl.state != .dragging) continue; const r0 = @min(sl.r0, sl.r1); @@ -260,31 +338,30 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a 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; + if (@as(i32, row) < r0 or @as(i32, row) > 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] }; + nc.style.bg = .{ .rgb = SEL_BG[b] }; + nc.style.fg = .{ .rgb = sel_fg }; text.writeCell(cc, row, nc); } } } } - if (active) text.showCursor(t.term.screens.active.cursor.x, t.term.screens.active.cursor.y); + if (active) text.showCursor(t.term.screens.active.cursor.x, t.term.screens.active.cursor.y + BOX_H); // 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; + const box_bg = if (active) red else red_dim; 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 } } }); + gut.writeCell(bx, by, .{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = 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; @@ -295,10 +372,10 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a 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; + const st_bg = if (thumb) gray.mid else gray.bar; var cx: u16 = 0; while (cx < GUTTER) : (cx += 1) - gut.writeCell(cx, @intCast(BOX_H + gy), .{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .index = st_bg } } }); + gut.writeCell(cx, @intCast(BOX_H + gy), .{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = st_bg } } }); } } } @@ -440,15 +517,9 @@ pub fn main(init: std.process.Init) !void { } 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, - }; + const sc: i32 = @as(i32, mcol) - @as(i32, r.x + GUTTER); + const v: i32 = @as(i32, mrow) - @as(i32, r.y); + t.sel[@intFromEnum(Button.left)] = .{ .state = .dragging, .c0 = sc, .c1 = sc, .r0 = v, .r1 = v }; drag = .{ .select = .{ .id = id, .button = .left } }; } } @@ -462,15 +533,9 @@ pub fn main(init: std.process.Init) !void { 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, - }; + const c: i32 = @as(i32, mcol) - @as(i32, r.x + GUTTER); + const v: i32 = @as(i32, mrow) - @as(i32, r.y); + t.sel[@intFromEnum(b)] = .{ .state = .dragging, .c0 = c, .c1 = c, .r0 = v, .r1 = v }; drag = .{ .select = .{ .id = id, .button = b } }; } } @@ -503,10 +568,8 @@ pub fn main(init: std.process.Init) !void { .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; + t.sel[@intFromEnum(s.button)].c1 = @as(i32, mcol) - @as(i32, r.x + GUTTER); + t.sel[@intFromEnum(s.button)].r1 = @as(i32, mrow) - @as(i32, r.y); } }, .none => {}, @@ -548,7 +611,57 @@ pub fn main(init: std.process.Init) !void { 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); + switch (onSelect(s.button, txt)) { + .none => {}, + .send => { + // run the selection in the terminal it came from + termWrite(t, std.mem.trim(u8, txt, " \t\r\n")); + termWrite(t, "\r"); + }, + .newcol => { + // new terminal in a fresh column + const slot: ?usize = for (terms, 0..) |sl, i| { + if (sl == null) break i; + } else null; + if (slot) |free| if (layout.ncol < MAX_COLS) { + if (forkTerm(gpa, screen_w, screen_h)) |nt| { + terms[free] = nt; + startReader(io, gpa, &loop, nt, free) catch {}; + layoutAppendColumn(&layout, free); + active = free; + } else |_| {} + }; + }, + .del => { + // close just this window + layoutRemove(&layout, s.id); + t.deinit(io, gpa); + gpa.destroy(t); + terms[s.id] = null; + if (active == s.id) active = for (terms, 0..) |sl, i| { + if (sl != null) break i; + } else break; + }, + .delcol => { + // close every terminal in this one's column + if (layoutFindTerm(&layout, s.id)) |f| { + var ids: [MAX_TERMS]usize = undefined; + const nids = layout.col_n[f.col]; + for (0..nids) |k| ids[k] = layout.col_terms[f.col][k]; + for (ids[0..nids]) |tid| { + if (terms[tid]) |tt| { + layoutRemove(&layout, tid); + tt.deinit(io, gpa); + gpa.destroy(tt); + terms[tid] = null; + } + } + if (terms[active] == null) active = for (terms, 0..) |sl, i| { + if (sl != null) break i; + } else break; + } + }, + } } else |_| {} } }, @@ -600,19 +713,7 @@ pub fn main(init: std.process.Init) !void { 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, - } - } - } + if (bytes) |b| termWrite(at, b); } } }, @@ -650,7 +751,7 @@ pub fn main(init: std.process.Init) !void { if (slot.*) |t| { const r = rects[id]; const cols = @max(1, r.w -| GUTTER); - const rows = @max(1, r.h); + const rows = @max(1, r.h -| BOX_H); // the tag steals the top row if (cols != t.cols or rows != t.rows) { t.term.resize(gpa, cols, rows) catch {}; pty_set_size(t.pty, rows, cols); @@ -676,7 +777,7 @@ pub fn main(init: std.process.Init) !void { 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 } } }); + win.writeCell(sx, ry, .{ .char = .{ .grapheme = g }, .style = .{ .fg = .{ .rgb = gray.dim } } }); } } const n = layout.col_n[c]; @@ -688,7 +789,7 @@ pub fn main(init: std.process.Init) !void { 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 } } }); + win.writeCell(rx, sy, .{ .char = .{ .grapheme = g }, .style = .{ .fg = .{ .rgb = gray.dim } } }); } } } @@ -698,13 +799,13 @@ pub fn main(init: std.process.Init) !void { .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 } } }); + win.writeCell(d.cur_x, ry, .{ .char = .{ .grapheme = "╎" }, .style = .{ .fg = .{ .rgb = gray.lit } } }); }, .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 } } }); + win.writeCell(rx, d.cur_y, .{ .char = .{ .grapheme = "╌" }, .style = .{ .fg = .{ .rgb = gray.lit } } }); }, .move => |d| { // insertion indicator: dashed line at cur_y across the target column @@ -717,8 +818,8 @@ pub fn main(init: std.process.Init) !void { } 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 } } }); + win.writeCell(rx, d.cur_y, .{ .char = .{ .grapheme = "╌" }, .style = .{ .fg = .{ .rgb = gray.mid } } }); + win.writeCell(d.cur_x, d.cur_y, .{ .char = .{ .grapheme = "▌" }, .style = .{ .fg = .{ .rgb = gray.mid } } }); }, else => {}, } @@ -740,8 +841,8 @@ pub fn main(init: std.process.Init) !void { 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 } } }, .{}); + ov.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = gray.bar } } }); + _ = ov.printSegment(.{ .text = s, .style = .{ .fg = .{ .rgb = gray.txt } } }, .{}); } try vx.render(tty.writer()); @@ -791,23 +892,27 @@ fn moveTerm( layoutInsert(l, tc, ti, id); } -// Build the block-selected text, newline-joined per row; caller frees. +// Build the block-selected text, newline-joined per row; caller frees. Visual +// row 0 is the tag line, rows >= BOX_H are the terminal body — same layout the +// pane renders, so a selection over the tag yields the tag's text. 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); + const tag = try tagLine(gpa, t); + defer gpa.free(tag); + const body = try t.term.plainString(gpa); + defer gpa.free(body); + const pane = try std.fmt.allocPrint(gpa, "{s}\n{s}", .{ tag, body }); + defer gpa.free(pane); var out: std.ArrayList(u8) = .empty; errdefer out.deinit(gpa); - var it = std.mem.splitAny(u8, ps, "\n"); - var i: i32 = 0; + var it = std.mem.splitAny(u8, pane, "\n"); + var v: i32 = 0; var first = true; - while (it.next()) |line| : (i += 1) { - const j = i + off; - if (j < r0 or j > r1) continue; + while (it.next()) |line| : (v += 1) { + if (v < r0 or v > r1) continue; if (!first) try out.append(gpa, '\n'); first = false; const b0 = @min(c0, line.len); @@ -817,9 +922,14 @@ fn selectionText(gpa: std.mem.Allocator, t: *Term, sl: Term.Sel) ![]u8 { 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; +// Decide what a selection means. Middle button: a builtin name (comptime-matched +// against `builtins`) runs that builtin; anything else is sent to the terminal. +fn onSelect(button: Button, text: []const u8) SelectAction { + if (button != .middle) return .none; + const cmd = std.mem.trim(u8, text, " \t\r\n"); + if (cmd.len == 0) return .none; + inline for (builtins) |b| { + if (std.mem.eql(u8, cmd, b.name)) return b.action; + } + return .send; } |
