summaryrefslogtreecommitdiff
path: root/main.zig
diff options
context:
space:
mode:
Diffstat (limited to 'main.zig')
-rw-r--r--main.zig258
1 files changed, 204 insertions, 54 deletions
diff --git a/main.zig b/main.zig
index 181c2f58..17ee6b87 100644
--- a/main.zig
+++ b/main.zig
@@ -18,6 +18,8 @@ extern "c" fn execv(
) c_int;
extern "c" fn _exit(status: c_int) noreturn;
+extern "c" fn chdir(path: [*:0]const u8) c_int;
+extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*:0]u8;
// basic file navigation
// basic shell io
@@ -89,9 +91,10 @@ const red_dim = [3]u8{ 0x66, 0x2a, 0x2a };
// char-mode inserted text (bright on a faint box so it reads as an overlay).
const edit_fg = [3]u8{ 0xea, 0xea, 0xea };
const edit_bg = [3]u8{ 0x3a, 0x3a, 0x3a };
-// 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 };
+// per-button selection highlight: distinct (muted) colors so left/middle/right
+// selections are easy to tell apart against the otherwise-monochrome chrome.
+const SEL_BG = [3][3]u8{ .{ 0x2c, 0x46, 0x74 }, .{ 0x2c, 0x60, 0x38 }, .{ 0x64, 0x2c, 0x58 } };
+const sel_fg = [3]u8{ 0xd2, 0xd2, 0xd2 };
// 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.
@@ -108,12 +111,43 @@ const builtins_str = blk: {
break :blk s;
};
-fn fork_pty(argv: [:0]const u8, ws: std.posix.winsize) struct { file: std.Io.File, pid: std.posix.pid_t } {
+// Path of the generated bash rc that adds OSC 133 semantic prompts (shell
+// integration). bash --rcfile reads it for interactive shells; it sources the
+// user's ~/.bashrc first, then wraps PS1 so each prompt emits 133;A..B. That
+// marks the prompt rows so we can hide them in text mode.
+const rc_path = "/tmp/pardes-osc133.bash";
+const bash_rc =
+ \\[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc"
+ \\PS1='\[\e]133;A\a\]'"$PS1"'\[\e]133;B\a\]'
+ \\PROMPT_COMMAND='printf "\e]133;D\a"'"${PROMPT_COMMAND:+;$PROMPT_COMMAND}"
+ \\trap 'printf "\e]133;C\a"' DEBUG
+ \\
+;
+
+fn writeFile(path: [*:0]const u8, data: []const u8) void {
+ const l = std.os.linux;
+ const rc = l.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644);
+ const sfd: isize = @bitCast(rc);
+ if (sfd < 0) return;
+ const fd: i32 = @intCast(sfd);
+ defer _ = l.close(fd);
+ var off: usize = 0;
+ while (off < data.len) {
+ const w = l.write(fd, data[off..].ptr, data.len - off);
+ const sw: isize = @bitCast(w);
+ if (sw <= 0) break;
+ off += @intCast(sw);
+ }
+}
+
+fn fork_pty(argv: []const ?[*:0]const u8, cwd: ?[*: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) {
- const child_argv = [_:null]?[*:0]const u8{ argv.ptr, null };
- _ = execv(argv.ptr, &child_argv);
+ if (cwd) |c| _ = chdir(c); // start the shell in this directory
+ var child_argv: [8]?[*:0]const u8 = @splat(null);
+ for (argv, 0..) |a, i| child_argv[i] = a;
+ _ = execv(argv[0].?, @ptrCast(&child_argv));
_exit(127);
}
return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid };
@@ -131,19 +165,33 @@ fn termWrite(t: *Term, bytes: []const u8) void {
}
}
+// The shell's current working directory (via /proc/<pid>/cwd). `buf` holds it.
+fn termCwd(t: *Term, buf: []u8) []const u8 {
+ var pathbuf: [64]u8 = undefined;
+ const path = std.fmt.bufPrintZ(&pathbuf, "/proc/{d}/cwd", .{t.pid}) catch return "?";
+ const rc = std.os.linux.readlinkat(std.os.linux.AT.FDCWD, path, buf.ptr, buf.len);
+ return switch (std.posix.errno(rc)) {
+ .SUCCESS => buf[0..rc],
+ else => "?",
+ };
+}
+
+// True if `path` exists and is a directory. open(O_DIRECTORY) fails with ENOTDIR
+// for anything else, so no stat struct needed.
+fn isDir(path: [*:0]const u8) bool {
+ const l = std.os.linux;
+ const rc = l.open(path, .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true }, 0);
+ const sfd: isize = @bitCast(rc);
+ if (sfd < 0) return false;
+ _ = l.close(@intCast(sfd));
+ return true;
+}
+
// 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 => "?",
- };
- };
+ const cwd = termCwd(t, &link);
// leading 2-char mode indicator: "ch" (char/edit) or "sy" (sym/terminal).
const mode = if (t.editing) "ch" else "sy";
return std.fmt.allocPrint(alloc, "{s} {s} {s}", .{ mode, cwd, builtins_str });
@@ -178,8 +226,9 @@ fn readPty(io: std.Io, gpa: std.mem.Allocator, pty: std.Io.File, id: usize, loop
// 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 child = fork_pty("/usr/bin/bash", .{ .row = init_rows, .col = init_cols, .xpixel = 0, .ypixel = 0 });
+fn forkTerm(gpa: std.mem.Allocator, init_cols: u16, init_rows: u16, cwd: ?[*:0]const u8) !*Term {
+ const argv = [_]?[*:0]const u8{ "/usr/bin/bash", "--rcfile", rc_path };
+ const child = fork_pty(&argv, cwd, .{ .row = init_rows, .col = init_cols, .xpixel = 0, .ypixel = 0 });
const t = try gpa.create(Term);
errdefer gpa.destroy(t);
t.* = .{
@@ -309,24 +358,9 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a
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.
// body = tty rows with insertion runs spliced in (text shifts, not overdrawn).
+ // In text mode bodyText also hides OSC 133 prompt rows (see promptFlags); the
+ // shells emit OSC 133 via the rc we drop at startup (writeFile/bash_rc).
const off: i32 = @intCast(t.term.screens.active.pages.scrollbar().offset);
const body = try bodyText(alloc, t);
var it = std.mem.splitAny(u8, body, "\n");
@@ -382,9 +416,13 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a
}
if (active) {
if (t.editing) {
- // char mode: cursor at the rendered cursor position (scroll-adjusted).
- const prow = t.cur_row - off + @as(i32, BOX_H);
- const cx = renderedCol(t, t.cur_row, t.cur_col, t.cur_off);
+ // text mode: cursor at the (scroll-adjusted) edit position. until the
+ // user pins it (click/type) it tracks the shell cursor.
+ const crow = if (t.cur_pinned) t.cur_row else @as(i32, @intCast(t.term.screens.active.cursor.y)) + off;
+ const ccol = if (t.cur_pinned) t.cur_col else @as(i32, @intCast(t.term.screens.active.cursor.x));
+ const coff = if (t.cur_pinned) t.cur_off else 0;
+ const prow = crow - off + @as(i32, BOX_H);
+ const cx = renderedCol(t, crow, ccol, coff);
if (prow >= 0 and cx >= 0 and prow < @as(i32, r.h) and cx < @as(i32, r.w - GUTTER))
text.showCursor(@intCast(cx), @intCast(prow));
} else {
@@ -459,13 +497,16 @@ pub fn main(init: std.process.Init) !void {
.col_n = @splat(0),
};
+ // drop the bash shell-integration rc so spawned shells emit OSC 133.
+ writeFile(rc_path, bash_rc);
+
// 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);
+ terms[0] = try forkTerm(gpa, screen_w, screen_h, null);
+ terms[1] = try forkTerm(gpa, screen_w, screen_h, null);
+ terms[2] = try forkTerm(gpa, screen_w, screen_h, null);
layout.ncol = 2;
layout.col_n[0] = 2;
layout.col_terms[0][0] = 0;
@@ -647,9 +688,55 @@ pub fn main(init: std.process.Init) !void {
.select => |s| {
if (terms[s.id]) |t| {
t.sel[@intFromEnum(s.button)].state = .done;
+ // a no-drag middle/right click expands to the word
+ // under the cursor (acme button-2/3 behavior).
+ if (s.button != .left) expandSel(gpa, t, &t.sel[@intFromEnum(s.button)]);
if (selectionText(gpa, t, t.sel[@intFromEnum(s.button)])) |txt| {
defer gpa.free(txt);
- switch (onSelect(s.button, txt)) {
+ if (s.button == .right) {
+ // acme "look": if the selected text resolves
+ // to a directory, open (or focus) a terminal
+ // there in the same column and ls it.
+ const word = std.mem.trim(u8, txt, " \t\r\n");
+ var cwdbuf: [1024]u8 = undefined;
+ var joinbuf: [2048]u8 = undefined;
+ var realbuf: [4096]u8 = undefined;
+ const cwd = termCwd(t, &cwdbuf);
+ const joined: ?[:0]u8 = if (word.len == 0) null else if (word[0] == '/')
+ (std.fmt.bufPrintZ(&joinbuf, "{s}", .{word}) catch null)
+ else
+ (std.fmt.bufPrintZ(&joinbuf, "{s}/{s}", .{ cwd, word }) catch null);
+ if (joined) |jz| if (realpath(jz.ptr, &realbuf)) |rp| if (isDir(rp)) {
+ const dir = std.mem.span(rp);
+ // focus an existing terminal on this dir
+ var found: ?usize = null;
+ for (&terms, 0..) |*slot, i| if (slot.*) |tt| {
+ var cb: [1024]u8 = undefined;
+ if (std.mem.eql(u8, termCwd(tt, &cb), dir)) {
+ found = i;
+ break;
+ }
+ };
+ if (found) |id| {
+ active = id;
+ if (terms[id]) |tt| termWrite(tt, "ls\r");
+ } else {
+ const slot: ?usize = for (terms, 0..) |sl, i| {
+ if (sl == null) break i;
+ } else null;
+ if (slot) |free| {
+ if (forkTerm(gpa, screen_w, screen_h, rp)) |nt| {
+ terms[free] = nt;
+ startReader(io, gpa, &loop, nt, free) catch {};
+ const f = layoutFindTerm(&layout, s.id).?;
+ layoutInsert(&layout, f.col, f.idx + 1, free);
+ active = free;
+ termWrite(nt, "ls\r");
+ } else |_| {}
+ }
+ }
+ };
+ } else switch (onSelect(s.button, txt)) {
.none => {},
.send => {
// run the selection in the terminal it came from
@@ -662,7 +749,7 @@ pub fn main(init: std.process.Init) !void {
if (sl == null) break i;
} else null;
if (slot) |free| if (layout.ncol < MAX_COLS) {
- if (forkTerm(gpa, screen_w, screen_h)) |nt| {
+ if (forkTerm(gpa, screen_w, screen_h, null)) |nt| {
terms[free] = nt;
startReader(io, gpa, &loop, nt, free) catch {};
layoutAppendColumn(&layout, free);
@@ -714,6 +801,7 @@ pub fn main(init: std.process.Init) !void {
t.cur_row = body_vis + off;
t.cur_col = sl.c1;
t.cur_off = if (runAt(t, t.cur_row, t.cur_col)) |i| t.edits[i].len else 0;
+ t.cur_pinned = true;
t.editing = true;
}
}
@@ -736,7 +824,7 @@ pub fn main(init: std.process.Init) !void {
if (s == null) break i;
} else null;
if (free_slot) |slot| {
- if (forkTerm(gpa, screen_w, screen_h)) |nt| {
+ if (forkTerm(gpa, screen_w, screen_h, null)) |nt| {
terms[slot] = nt;
startReader(io, gpa, &loop, nt, slot) catch {};
const f = layoutFindTerm(&layout, active).?;
@@ -762,6 +850,15 @@ pub fn main(init: std.process.Init) !void {
// the current run); arrows move the cursor; backspace/etc.
// are no-ops for now (deletion comes later).
if (key.text) |text| {
+ // first keystroke pins the cursor (it was tracking the
+ // shell cursor until now).
+ if (!at.cur_pinned) {
+ const coff: i32 = @intCast(at.term.screens.active.pages.scrollbar().offset);
+ at.cur_row = @as(i32, @intCast(at.term.screens.active.cursor.y)) + coff;
+ at.cur_col = @intCast(at.term.screens.active.cursor.x);
+ at.cur_off = 0;
+ at.cur_pinned = true;
+ }
// find or create the run at the cursor
var idx: ?usize = runAt(at, at.cur_row, at.cur_col);
if (idx == null and at.nedits < Term.MAX_EDITS) {
@@ -1055,38 +1152,59 @@ fn spliceRow(alloc: std.mem.Allocator, out: *std.ArrayList(u8), t: *Term, abs_ro
// The terminal body with all insertion runs spliced in, newline-joined. This is
// the actual on-screen text: render and selection both read it, so there's no
// tty-vs-typed distinction. Caller frees.
+// Per visible row, whether it's a shell prompt/input row (OSC 133). Used to hide
+// prompts in text mode. Rows beyond the iterator (blank tail) stay false.
+fn promptFlags(t: *Term, flags: []bool) void {
+ @memset(flags, false);
+ var it = t.term.screens.active.pages.rowIterator(.right_down, .{ .viewport = .{} }, null);
+ var i: usize = 0;
+ while (it.next()) |pin| : (i += 1) {
+ if (i >= flags.len) break;
+ flags[i] = pin.rowAndCell().row.semantic_prompt != .none;
+ }
+}
+
fn bodyText(alloc: std.mem.Allocator, t: *Term) ![]u8 {
const off: i32 = @intCast(t.term.screens.active.pages.scrollbar().offset);
const body = try t.term.plainString(alloc);
defer alloc.free(body);
+ // text mode (editing) hides shell prompt rows; tty mode shows everything.
+ var flagbuf: [512]bool = undefined;
+ const nrows: usize = @min(t.rows, flagbuf.len);
+ const flags = flagbuf[0..nrows];
+ if (t.editing) promptFlags(t, flags) else @memset(flags, false);
var out: std.ArrayList(u8) = .empty;
errdefer out.deinit(alloc);
// pad to the full grid height: plainString trims trailing blank rows, but we
// must still be able to type (and splice) onto those blank rows.
var it = std.mem.splitAny(u8, body, "\n");
- const nrows: i32 = @intCast(t.rows);
- var i: i32 = 0;
+ var i: usize = 0;
while (i < nrows) : (i += 1) {
if (i > 0) try out.append(alloc, '\n');
- const line = it.next() orelse "";
- try spliceRow(alloc, &out, t, i + off, line); // line i shows absolute row i+off
+ const raw = it.next() orelse "";
+ const line = if (flags[i]) "" else raw; // hidden prompt -> blank (edits still splice)
+ try spliceRow(alloc, &out, t, @as(i32, @intCast(i)) + off, line);
}
return out.toOwnedSlice(alloc);
}
+// The full rendered pane: tag at visual row 0, spliced body below. Caller frees.
+fn paneText(gpa: std.mem.Allocator, t: *Term) ![]u8 {
+ const tag = try tagLine(gpa, t);
+ defer gpa.free(tag);
+ const body = try bodyText(gpa, t);
+ defer gpa.free(body);
+ return std.fmt.allocPrint(gpa, "{s}\n{s}", .{ tag, body });
+}
+
// Block-selected text, newline-joined per row; caller frees. Reads the rendered
-// screen (tag at visual row 0, spliced body below), so selecting typed text and
-// tty text works identically.
+// screen, so selecting typed text and tty text works identically.
fn selectionText(gpa: std.mem.Allocator, t: *Term, sl: Term.Sel) ![]u8 {
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 tag = try tagLine(gpa, t);
- defer gpa.free(tag);
- const body = try bodyText(gpa, t);
- defer gpa.free(body);
- const pane = try std.fmt.allocPrint(gpa, "{s}\n{s}", .{ tag, body });
+ const pane = try paneText(gpa, t);
defer gpa.free(pane);
var out: std.ArrayList(u8) = .empty;
errdefer out.deinit(gpa);
@@ -1104,6 +1222,38 @@ fn selectionText(gpa: std.mem.Allocator, t: *Term, sl: Term.Sel) ![]u8 {
return out.toOwnedSlice(gpa);
}
+// file-ish word chars (acme isfilec): alnum + . - + / : @ _ ~
+fn isFileChar(c: u8) bool {
+ return std.ascii.isAlphanumeric(c) or switch (c) {
+ '.', '-', '+', '/', ':', '@', '_', '~' => true,
+ else => false,
+ };
+}
+
+// acme-style: a no-drag middle/right click expands to the file-ish word under the
+// cursor, in place. A real (dragged) selection is left untouched.
+fn expandSel(gpa: std.mem.Allocator, t: *Term, sl: *Term.Sel) void {
+ if (sl.c0 != sl.c1 or sl.r0 != sl.r1) return;
+ const pane = paneText(gpa, t) catch return;
+ defer gpa.free(pane);
+ var it = std.mem.splitAny(u8, pane, "\n");
+ var v: i32 = 0;
+ while (it.next()) |line| : (v += 1) {
+ if (v != sl.r0) continue;
+ const col: usize = @intCast(@max(0, sl.c0));
+ if (col > line.len) return;
+ var hi = col;
+ while (hi < line.len and isFileChar(line[hi])) hi += 1;
+ var lo = col;
+ while (lo > 0 and isFileChar(line[lo - 1])) lo -= 1;
+ if (hi > lo) {
+ sl.c0 = @intCast(lo);
+ sl.c1 = @intCast(hi - 1);
+ }
+ return;
+ }
+}
+
// 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 {