From f3f0e27372d8cf111269dde674d4581cf4cb0c41 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Fri, 3 Jul 2026 23:37:10 -0300 Subject: sdl: main + ui_sdl rework, build.zig updates --- build.zig | 33 ++++++- main.zig | 324 +++++++++++++++++++++++++++++++++++++++++++------------------ term.zig | 11 ++- ui_sdl.zig | 201 +++++++++++++++++++++++++------------- 4 files changed, 400 insertions(+), 169 deletions(-) diff --git a/build.zig b/build.zig index fa987360..b3ae8e37 100644 --- a/build.zig +++ b/build.zig @@ -57,18 +57,21 @@ pub fn build(b: *std.Build) void { , .{ sysroot, sysroot, sysroot })); } else null; + const exe_name = targetBinaryName(b, "pardes", target.result); + const replay_name = targetBinaryName(b, "pardes-replay", target.result); const exe = b.addExecutable(.{ - .name = "pardes", + .name = exe_name, .linkage = exe_linkage, .root_module = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("main.zig"), .strip = strip, + .link_libc = true, }), }); const replay = b.addExecutable(.{ - .name = "pardes-replay", + .name = replay_name, .linkage = exe_linkage, .root_module = b.createModule(.{ .target = target, @@ -146,12 +149,16 @@ pub fn build(b: *std.Build) void { // You'll want to use a lazy dependency here so that ghostty is only // downloaded if you actually need it. var ghostty_vt_for_tests: ?*std.Build.Module = null; + const ghostty_simd = switch (target.result.os.tag) { + .emscripten, .openbsd => false, + else => true, + }; if (b.lazyDependency("ghostty", .{ // Setting simd to false will force a pure static build that // doesn't even require libc, but it has a significant performance // penalty. If your embedding app requires libc anyway, you should // always keep simd enabled. - .simd = !is_emscripten, + .simd = ghostty_simd, })) |dep| { // Accessing ghostty's modules runs its build(), which (because vaxis is // a shared dependency, deduplicated to the same module) wires ghostty's @@ -664,6 +671,26 @@ fn detectEmscriptenSysroot(b: *std.Build) ?[]const u8 { return null; } +fn targetBinaryName(b: *std.Build, base: []const u8, target: std.Target) []const u8 { + return switch (target.os.tag) { + .linux, .emscripten => base, + .macos, .freebsd, .openbsd, .netbsd, .dragonfly => b.fmt("{s}-{s}-{s}", .{ + base, + @tagName(target.os.tag), + archName(target.cpu.arch), + }), + else => base, + }; +} + +fn archName(arch: std.Target.Cpu.Arch) []const u8 { + return switch (arch) { + .x86_64 => "x64", + .aarch64 => "arm64", + else => @tagName(arch), + }; +} + fn envPath(b: *std.Build, name: []const u8) ?[]const u8 { return b.graph.environ_map.get(name); } diff --git a/main.zig b/main.zig index 742d43b4..dbd35f68 100644 --- a/main.zig +++ b/main.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const builtin = @import("builtin"); const vaxis = @import("ui"); const ghostty_vt = @import("ghostty-vt"); const Term = @import("term.zig"); @@ -8,6 +9,11 @@ const dump = @import("dump.zig"); const paint = @import("paint.zig"); const pane_ui = @import("pane_ui.zig"); +const os_tag = builtin.os.tag; +const posix = std.posix; +const linux = std.os.linux; +const shell_reports_cwd = os_tag == .macos; + // A TUI can't let std.log hit stderr — it's the same tty as our alt-screen, so the // message lands on top of the UI (this is the stray text you saw when opening yazi: // ghostty-vt's parser warns via std.log about sequences it doesn't implement, e.g. @@ -15,7 +21,7 @@ const pane_ui = @import("pane_ui.zig"); // stays clean and the messages are still there to inspect. pub const std_options: std.Options = .{ .logFn = fileLog }; -var log_fd: i32 = -1; +var log_fd: posix.fd_t = -1; fn fileLog( comptime level: std.log.Level, comptime scope: @EnumLiteral(), @@ -23,21 +29,85 @@ fn fileLog( args: anytype, ) void { if (log_fd < 0) { - const rc = std.os.linux.open("/tmp/pardes.log", .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true, .APPEND = true }, 0o644); - const sfd: isize = @bitCast(rc); - if (sfd < 0) return; // can't open the log: drop the message (never touch stderr) - log_fd = @intCast(sfd); + log_fd = openCreateTrunc("/tmp/pardes.log", true) orelse return; } var buf: [4096]u8 = undefined; const prefix = "[" ++ @tagName(level) ++ "] " ++ @tagName(scope) ++ ": "; const line = std.fmt.bufPrint(&buf, prefix ++ format ++ "\n", args) catch return; - _ = std.os.linux.write(log_fd, line.ptr, line.len); + writeFdBestEffort(log_fd, line); } fn nowNs() u64 { - var ts: std.os.linux.timespec = undefined; - _ = std.os.linux.clock_gettime(std.os.linux.CLOCK.MONOTONIC, &ts); - return @intCast(ts.sec * 1_000_000_000 + ts.nsec); + switch (os_tag) { + .linux => { + var ts: linux.timespec = undefined; + _ = linux.clock_gettime(linux.CLOCK.MONOTONIC, &ts); + return @intCast(ts.sec * 1_000_000_000 + ts.nsec); + }, + else => { + var ts: posix.timespec = undefined; + switch (posix.errno(posix.system.clock_gettime(posix.CLOCK.MONOTONIC, &ts))) { + .SUCCESS => return @intCast(ts.sec * 1_000_000_000 + ts.nsec), + else => return 0, + } + }, + } +} + +fn openCreateTrunc(path: [*:0]const u8, append: bool) ?posix.fd_t { + switch (os_tag) { + .linux => { + const rc = linux.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true, .APPEND = append }, 0o644); + const sfd: isize = @bitCast(rc); + return if (sfd < 0) null else @intCast(sfd); + }, + else => return posix.openatZ(posix.AT.FDCWD, path, .{ + .ACCMODE = .WRONLY, + .CREAT = true, + .TRUNC = true, + .APPEND = append, + }, 0o644) catch null, + } +} + +fn closeFdBestEffort(fd: posix.fd_t) void { + switch (os_tag) { + .linux => _ = linux.close(fd), + else => switch (posix.errno(posix.system.close(fd))) { + .SUCCESS, .INTR => {}, + else => {}, + }, + } +} + +fn writeFdBestEffort(fd: posix.fd_t, bytes: []const u8) void { + var off: usize = 0; + while (off < bytes.len) { + switch (os_tag) { + .linux => { + const rc = linux.write(fd, bytes[off..].ptr, bytes.len - off); + const n: isize = @bitCast(rc); + if (n < 0) { + if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue; + return; + } + if (n == 0) return; + off += @intCast(n); + }, + else => { + const rc = posix.system.write(fd, bytes[off..].ptr, bytes.len - off); + switch (posix.errno(rc)) { + .SUCCESS => { + const n: usize = @intCast(rc); + if (n == 0) return; + off += n; + }, + .INTR => continue, + else => return, + } + }, + } + } } extern "c" fn forkpty( @@ -268,13 +338,7 @@ const file_pane_tail = " " ++ file_pane_builtins_str; const topbar_str = "Kill Newcol Tutor Debug Colors NextColor Dump"; fn writeStdoutBestEffort(bytes: []const u8) void { - var off: usize = 0; - while (off < bytes.len) { - const rc = std.os.linux.write(1, bytes[off..].ptr, bytes.len - off); - const n: isize = @bitCast(rc); - if (n <= 0) return; - off += @intCast(n); - } + writeFdBestEffort(1, bytes); } fn paintStartupTopbar() void { @@ -296,7 +360,13 @@ fn writeStartupPerfMarker(start_ns: u64) void { // repositioning of the shell cursor within the typed input) so enterTty can // land the shell cursor where the modal cursor navigated (start vs end). const rc_path = "/tmp/pardes-osc133.bash"; -const bash_rc = +const bash_rc = if (shell_reports_cwd) + \\[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc" + \\PS1='\[\e]7;file://localhost$PWD\a\]\[\e]133;A;cl=line\a\]'"$PS1"'\[\e]133;B\a\]' + \\PROMPT_COMMAND='printf "\e]133;D\a"'"${PROMPT_COMMAND:+;$PROMPT_COMMAND}" + \\trap 'printf "\e]133;C\a"' DEBUG + \\ +else \\[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc" \\PS1='\[\e]133;A;cl=line\a\]'"$PS1"'\[\e]133;B\a\]' \\PROMPT_COMMAND='printf "\e]133;D\a"'"${PROMPT_COMMAND:+;$PROMPT_COMMAND}" @@ -305,19 +375,9 @@ const bash_rc = ; 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); - } + const fd = openCreateTrunc(path, false) orelse return; + defer closeFdBestEffort(fd); + writeFdBestEffort(fd, data); } fn saveFile(io: std.Io, f: *Term.File) !void { @@ -341,17 +401,7 @@ fn fork_pty(argv: []const ?[*:0]const u8, cwd: ?[*:0]const u8, ws: std.posix.win fn termWrite(t: *Term, bytes: []const u8) void { if (t.pty.handle < 0) return; // file pane: no pty - var off: usize = 0; - while (off < bytes.len) { - const rc = std.os.linux.write(t.pty.handle, bytes[off..].ptr, bytes.len - off); - // raw syscall: -errno, not the libc -1 sentinel. Check the sign. - const n: isize = @bitCast(rc); - if (n < 0) { - if (n == -@as(isize, @intFromEnum(std.os.linux.E.INTR))) continue; - break; - } - off += @intCast(n); - } + writeFdBestEffort(t.pty.handle, bytes); } fn fileLineCount(content: []const u8) usize { @@ -446,30 +496,47 @@ fn padOutputBelowEdits(t: *Term) void { while (i < pad) : (i += 1) termWrite(t, "\r"); } -// The shell's current working directory (via /proc//cwd). `buf` holds it. -// A raw linux syscall returns -errno; check the sign directly. (std.posix.errno -// is the LIBC convention here — `rc == -1` — and we link libc, so it misreads a -// raw -2/ENOENT as success and we'd slice buf[0..~2^64]. file panes (pid 0) and -// exited shells both hit this.) +// The shell's current working directory. Linux keeps the strongest existing +// behavior by reading /proc//cwd, which follows external `cd` commands +// without shell cooperation. macOS uses OSC 7 emitted by our bash rc. BSDs use +// the spawn cwd cached on Term until a native live-cwd implementation is added. fn termCwd(t: *Term, buf: []u8) []const u8 { if (!t.isTerminal()) return "?"; // file/image pane: no shell, no cwd - 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); - const n: isize = @bitCast(rc); - if (n < 0) return "?"; - return buf[0..@intCast(n)]; + switch (os_tag) { + .linux => { + var pathbuf: [64]u8 = undefined; + const path = std.fmt.bufPrintZ(&pathbuf, "/proc/{d}/cwd", .{t.pid}) catch return "?"; + const rc = linux.readlinkat(linux.AT.FDCWD, path, buf.ptr, buf.len); + const n: isize = @bitCast(rc); + if (n < 0) return "?"; + return buf[0..@intCast(n)]; + }, + .macos, .freebsd, .openbsd, .netbsd, .dragonfly => return t.cwd orelse "?", + else => return t.cwd orelse "?", + } } // 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; + switch (os_tag) { + .linux => { + const rc = linux.open(path, .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true }, 0); + const sfd: isize = @bitCast(rc); + if (sfd < 0) return false; + _ = linux.close(@intCast(sfd)); + return true; + }, + else => { + const fd = posix.openatZ(posix.AT.FDCWD, path, .{ + .ACCMODE = .RDONLY, + .DIRECTORY = true, + .CLOEXEC = true, + }, 0) catch return false; + closeFdBestEffort(fd); + return true; + }, + } } // The LIVE prefix of a window tag: mode indicator + cwd/path. Regenerated every @@ -736,8 +803,20 @@ fn wordAtCol(str: []const u8, col: u16) []const u8 { // 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)); + const ws: posix.winsize = .{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; + _ = posix.system.ioctl(pty.handle, tiocswinszReq(), @intFromPtr(&ws)); +} + +fn ioctlReq(bits: u32) c_int { + return @bitCast(bits); +} + +fn tiocswinszReq() c_int { + return switch (os_tag) { + .linux => @intCast(posix.T.IOCSWINSZ), + .macos, .freebsd, .openbsd, .netbsd, .dragonfly => ioctlReq(0x80087467), + else => @intCast(posix.T.IOCSWINSZ), + }; } // One reader task per terminal. Tags each chunk with the terminal's slot id so @@ -763,6 +842,7 @@ fn feedPtyRead(gpa: std.mem.Allocator, terms: *[MAX_TERMS]?*Term, active: usize, defer gpa.free(pr.bytes); if (pr.id >= MAX_TERMS) return; if (terms[pr.id]) |t| { + if (shell_reports_cwd) updateCwdFromOsc7(gpa, t, pr.bytes); t.tty_stream.appendSlice(gpa, pr.bytes) catch {}; t.stream.nextSlice(pr.bytes); // follow new output to the bottom, so a command's result shows even in a @@ -773,11 +853,83 @@ fn feedPtyRead(gpa: std.mem.Allocator, terms: *[MAX_TERMS]?*Term, active: usize, } } +fn updateCwdFromOsc7(gpa: std.mem.Allocator, t: *Term, bytes: []const u8) void { + if (!t.isTerminal()) return; + for (bytes) |b| switch (t.cwd_osc_state) { + .normal => { + if (b == 0x1b) t.cwd_osc_state = .esc; + }, + .esc => { + t.cwd_osc_state = if (b == ']') .osc else if (b == 0x1b) .esc else .normal; + }, + .osc => { + t.cwd_osc_state = if (b == '7') .osc7 else if (b == 0x1b) .esc else .normal; + }, + .osc7 => { + if (b == ';') { + t.cwd_osc.clearRetainingCapacity(); + t.cwd_osc_state = .collect; + } else { + t.cwd_osc_state = if (b == 0x1b) .esc else .normal; + } + }, + .collect => switch (b) { + 0x07 => { + finishCwdOsc(gpa, t); + }, + 0x1b => { + t.cwd_osc_state = .st_esc; + }, + else => { + if (t.cwd_osc.items.len < std.fs.max_path_bytes) { + t.cwd_osc.append(gpa, b) catch { + t.cwd_osc_state = .normal; + }; + } else { + t.cwd_osc.clearRetainingCapacity(); + t.cwd_osc_state = .normal; + } + }, + }, + .st_esc => { + if (b == '\\') finishCwdOsc(gpa, t) else t.cwd_osc_state = .normal; + }, + }; +} + +fn finishCwdOsc(gpa: std.mem.Allocator, t: *Term) void { + defer { + t.cwd_osc.clearRetainingCapacity(); + t.cwd_osc_state = .normal; + } + const path = osc7Path(t.cwd_osc.items) orelse return; + if (path.len == 0 or path[0] != '/') return; + const next = gpa.dupe(u8, path) catch return; + if (t.cwd) |old| gpa.free(old); + t.cwd = next; +} + +fn osc7Path(raw: []const u8) ?[]const u8 { + if (std.mem.startsWith(u8, raw, "file://")) { + const rest = raw["file://".len..]; + const slash = std.mem.indexOfScalar(u8, rest, '/') orelse return null; + return rest[slash..]; + } + if (std.mem.startsWith(u8, raw, "file:")) return raw["file:".len..]; + return raw; +} + // 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, cwd: ?[*:0]const u8) !*Term { +fn forkTerm(io: std.Io, 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 cwd_copy = if (cwd) |c| try gpa.dupe(u8, std.mem.span(c)) else blk: { + var current: [std.fs.max_path_bytes]u8 = undefined; + const n = try std.process.currentPath(io, ¤t); + break :blk try gpa.dupe(u8, current[0..n]); + }; + errdefer gpa.free(cwd_copy); 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); @@ -787,6 +939,7 @@ fn forkTerm(gpa: std.mem.Allocator, init_cols: u16, init_rows: u16, cwd: ?[*:0]c .term = try ghostty_vt.Terminal.init(gpa, .{ .cols = init_cols, .rows = init_rows, .max_scrollback = 16 * 1024 * 1024 }), .stream = undefined, .reader = .{ .any_future = null, .result = {} }, // no-op until startReader + .cwd = cwd_copy, .cols = init_cols, .rows = init_rows, }; @@ -839,29 +992,8 @@ fn parsePathLine(tok: []const u8) struct { path: []const u8, line: usize } { return .{ .path = tok, .line = 0 }; } -// slurp a whole file via linux syscalls (kept flat; no std.fs). Caller frees. -fn readFileAlloc(gpa: std.mem.Allocator, path: [*:0]const u8) ![]u8 { - const l = std.os.linux; - const rc = l.open(path, .{ .ACCMODE = .RDONLY, .CLOEXEC = true }, 0); - const sfd: isize = @bitCast(rc); - if (sfd < 0) return error.OpenFailed; - const fd: i32 = @intCast(sfd); - defer _ = l.close(fd); - var buf: std.ArrayList(u8) = .empty; - errdefer buf.deinit(gpa); - var chunk: [64 * 1024]u8 = undefined; - while (true) { - const rrc = l.read(fd, &chunk, chunk.len); - // raw syscall: -errno, not the libc -1 sentinel. Check the sign. - const n: isize = @bitCast(rrc); - if (n < 0) { - if (n == -@as(isize, @intFromEnum(l.E.INTR))) continue; - return error.ReadFailed; - } - if (n == 0) break; - try buf.appendSlice(gpa, chunk[0..@intCast(n)]); - } - return buf.toOwnedSlice(gpa); +fn readFileAlloc(io: std.Io, gpa: std.mem.Allocator, path: [*:0]const u8) ![]u8 { + return std.Io.Dir.cwd().readFileAlloc(io, std.mem.span(path), gpa, .limited(std.math.maxInt(usize))); } fn refreshFileHighlightsVisible(gpa: std.mem.Allocator, f: *Term.File, rows: usize) void { @@ -927,8 +1059,8 @@ fn setFileContentStaleSyntax(gpa: std.mem.Allocator, f: *Term.File, new: []u8) v // layout/selection/edit machinery. No pty (handle -1), no reader, a stub 1x1 // emulator (only its allocator-owned bits are touched). `line` (1-based, 0=none) // scrolls the target near the top. Caller wires it into the layout + frees later. -fn openFileView(gpa: std.mem.Allocator, rp: [*:0]const u8, line: usize, cols: u16, rows: u16) !*Term { - const content = try readFileAlloc(gpa, rp); +fn openFileView(io: std.Io, gpa: std.mem.Allocator, rp: [*:0]const u8, line: usize, cols: u16, rows: u16) !*Term { + const content = try readFileAlloc(io, gpa, rp); errdefer gpa.free(content); const path = try gpa.dupe(u8, std.mem.span(rp)); errdefer gpa.free(path); @@ -1102,7 +1234,7 @@ fn ttyForDir( } else return null; var dirbuf: [1024]u8 = undefined; const dirz = std.fmt.bufPrintZ(&dirbuf, "{s}", .{dir}) catch return null; - const nt = forkTerm(gpa, sw, sh, dirz.ptr) catch return null; + const nt = forkTerm(io, gpa, sw, sh, dirz.ptr) catch return null; terms[free] = nt; startReader(io, gpa, loop, nt, free) catch {}; const rc = if (layout.ncol > 0) layout.ncol - 1 else 0; @@ -2449,7 +2581,7 @@ fn actOnSelection( if (sl == null) break i; } else null; if (slot) |free| { - if (forkTerm(gpa, sw, sh, rp)) |nt| { + if (forkTerm(io, gpa, sw, sh, rp)) |nt| { terms[free] = nt; startReader(io, gpa, loop, nt, free) catch {}; const f = layoutFindTerm(layout, id).?; @@ -2492,7 +2624,7 @@ fn actOnSelection( const opened: ?*Term = if (as_image) (openImageView(gpa, rp, sw, sh, !images_ok) catch null) // petscii by default when no kitty else - (openFileView(gpa, rp, pl.line, sw, sh) catch null); + (openFileView(io, gpa, rp, pl.line, sw, sh) catch null); if (opened) |nt| { terms[free] = nt; // If a doc pane (file/image) already exists, SPLIT it @@ -2579,7 +2711,7 @@ fn actOnSelection( if (sl == null) break i; } else null; if (slot) |free| if (layout.ncol < MAX_COLS) { - if (forkTerm(gpa, sw, sh, null)) |nt| { + if (forkTerm(io, gpa, sw, sh, null)) |nt| { terms[free] = nt; startReader(io, gpa, loop, nt, free) catch {}; layoutAppendColumn(layout, free); @@ -2740,16 +2872,16 @@ pub fn main(init: std.process.Init) !void { // it while still single-threaded avoids that. if (tty_only) { // tmux-style: a single shell, already in tty mode, filling one column. - terms[0] = try forkTerm(gpa, screen_w, screen_h, null); + terms[0] = try forkTerm(io, gpa, screen_w, screen_h, null); terms[0].?.mode = .tty; layout.ncol = 1; layout.col_n[0] = 1; layout.col_terms[0][0] = 0; } else { // editor default: two columns, the left one split into two windows. - 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); + terms[0] = try forkTerm(io, gpa, screen_w, screen_h, null); + terms[1] = try forkTerm(io, gpa, screen_w, screen_h, null); + terms[2] = try forkTerm(io, gpa, screen_w, screen_h, null); // greet each fresh shell with `ls` once it's been sized (see the resize // loop): sending it now wraps the listing to the full-screen width. for (terms[0..3]) |slot| if (slot) |t| { @@ -3115,7 +3247,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, null)) |nt| { + if (forkTerm(io, gpa, screen_w, screen_h, null)) |nt| { terms[slot] = nt; startReader(io, gpa, &loop, nt, slot) catch {}; const f = layoutFindTerm(&layout, active).?; @@ -3646,7 +3778,7 @@ fn dumpSnapshot( }, } else if (t.image) |iv| blk: { const pz = try std.fmt.allocPrintSentinel(alloc, "{s}", .{iv.path}, 0); - const bytes = readFileAlloc(alloc, pz) catch ""; + const bytes = readFileAlloc(io, alloc, pz) catch ""; break :blk .{ .kind = .image, .tag = tag, diff --git a/term.zig b/term.zig index cb451b7c..7201c7c7 100644 --- a/term.zig +++ b/term.zig @@ -84,6 +84,8 @@ pub const EditSnap = struct { cur_off: u16, }; +pub const CwdOscState = enum { normal, esc, osc, osc7, collect, st_esc }; + /// Persistent per-terminal state: the pty handle, the ghostty emulator and its /// (stateful) VT parser, the background reader task, plus its layout weight and /// text selection. @@ -94,10 +96,15 @@ pub const EditSnap = struct { pub const Term = @This(); pty: std.Io.File, -pid: std.posix.pid_t, // the shell child; used to read /proc//cwd for the tag +pid: std.posix.pid_t, // shell child; Linux uses /proc//cwd for live cwd tags term: ghostty_vt.Terminal, stream: ghostty_vt.TerminalStream, reader: std.Io.Future(anyerror!void), +/// Last known shell cwd for platforms without Linux procfs. macOS updates this +/// from OSC 7 emitted by our shell rc; BSDs use the spawn cwd as a fallback. +cwd: ?[]u8 = null, +cwd_osc_state: CwdOscState = .normal, +cwd_osc: std.ArrayList(u8) = .empty, /// When set, this pane is a (read/editable) text FILE view, not a terminal: its /// body comes from `content` instead of the emulator, and all the tty-specific @@ -196,6 +203,8 @@ pub fn deinit(self: *Term, io: std.Io, gpa: std.mem.Allocator) void { self.pty.close(io); } self.tty_stream.deinit(gpa); + if (self.cwd) |cwd| gpa.free(cwd); + self.cwd_osc.deinit(gpa); self.ed_undo.deinit(gpa); self.ed_redo.deinit(gpa); self.tag_tail.deinit(gpa); diff --git a/ui_sdl.zig b/ui_sdl.zig index 0b2a2fd2..ee7f118c 100644 --- a/ui_sdl.zig +++ b/ui_sdl.zig @@ -25,6 +25,7 @@ const vaxis = @import("vaxis"); const ui_sdl_config = @import("ui_sdl_config"); const posix = std.posix; const linux = std.os.linux; +const os_tag = builtin.os.tag; const is_emscripten = builtin.os.tag == .emscripten; pub const c = @cImport({ @@ -150,7 +151,7 @@ capture_xfer_size: u32 = 0, capture_frame: u64 = 0, trace_enabled: bool = false, -trace_fd: c_int = -1, +trace_fd: posix.fd_t = -1, trace_every: u32 = 1, trace_frame: u64 = 0, @@ -233,9 +234,93 @@ fn envFlag(env_map: *std.process.Environ.Map, name: []const u8) bool { fn nowNs() i128 { if (is_emscripten) return @intCast(c.SDL_GetTicksNS()); - var ts: linux.timespec = undefined; - _ = linux.clock_gettime(linux.CLOCK.MONOTONIC, &ts); - return @as(i128, ts.sec) * 1_000_000_000 + @as(i128, ts.nsec); + switch (os_tag) { + .linux => { + var ts: linux.timespec = undefined; + _ = linux.clock_gettime(linux.CLOCK.MONOTONIC, &ts); + return @as(i128, ts.sec) * 1_000_000_000 + @as(i128, ts.nsec); + }, + else => { + var ts: posix.timespec = undefined; + switch (posix.errno(posix.system.clock_gettime(posix.CLOCK.MONOTONIC, &ts))) { + .SUCCESS => return @as(i128, ts.sec) * 1_000_000_000 + @as(i128, ts.nsec), + else => return 0, + } + }, + } +} + +fn openCreateTrunc(path: [*:0]const u8) ?posix.fd_t { + switch (os_tag) { + .linux => { + const fd_rc = linux.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644); + const fd_s: isize = @bitCast(fd_rc); + return if (fd_s < 0) null else @intCast(fd_s); + }, + else => return posix.openatZ(posix.AT.FDCWD, path, .{ + .ACCMODE = .WRONLY, + .CREAT = true, + .TRUNC = true, + }, 0o644) catch null, + } +} + +fn closeFdBestEffort(fd: posix.fd_t) void { + switch (os_tag) { + .linux => _ = linux.close(fd), + else => switch (posix.errno(posix.system.close(fd))) { + .SUCCESS, .INTR => {}, + else => {}, + }, + } +} + +fn writeAllFd(fd: posix.fd_t, bytes: []const u8) !void { + var off: usize = 0; + while (off < bytes.len) { + switch (os_tag) { + .linux => { + const rc = linux.write(fd, bytes[off..].ptr, bytes.len - off); + const n: isize = @bitCast(rc); + if (n < 0) { + if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue; + return error.WriteFailed; + } + if (n == 0) return error.WriteFailed; + off += @intCast(n); + }, + else => { + const rc = posix.system.write(fd, bytes[off..].ptr, bytes.len - off); + switch (posix.errno(rc)) { + .SUCCESS => { + const n: usize = @intCast(rc); + if (n == 0) return error.WriteFailed; + off += n; + }, + .INTR => continue, + else => return error.WriteFailed, + } + }, + } + } +} + +fn writeAllFdIgnore(fd: posix.fd_t, bytes: []const u8) void { + writeAllFd(fd, bytes) catch {}; +} + +fn renameZ(old_path: [*:0]const u8, new_path: [*:0]const u8) !void { + switch (os_tag) { + .linux => { + const rr = linux.rename(old_path, new_path); + const rs: isize = @bitCast(rr); + if (rs < 0) return error.RenameFailed; + }, + else => switch (posix.errno(posix.system.rename(old_path, new_path))) { + .SUCCESS => {}, + else => return error.RenameFailed, + }, + } } fn nsToUs(delta: i128) u64 { @@ -302,7 +387,7 @@ pub fn init( } const trace_enabled = envFlag(env_map, "PARDES_SDL_TRACE"); const trace_every = envU32(env_map, "PARDES_SDL_TRACE_EVERY") orelse 1; - var trace_fd: c_int = -1; + var trace_fd: posix.fd_t = -1; if (!is_emscripten and trace_enabled) { if (env_map.get("PARDES_SDL_TRACE_FILE")) |path| { trace_fd = openTraceFile(path); @@ -463,7 +548,7 @@ fn initGl( test_mode: bool, capture_dir: ?[]const u8, trace_enabled: bool, - trace_fd: c_int, + trace_fd: posix.fd_t, trace_every: u32, ) !SdlBackend { _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_PROFILE_MASK, c.SDL_GL_CONTEXT_PROFILE_ES); @@ -606,7 +691,7 @@ pub fn deinit(self: *SdlBackend, alloc: ?std.mem.Allocator, writer: *std.Io.Writ } if (self.capture_tex) |t| c.SDL_ReleaseGPUTexture(self.device, t); if (self.capture_xfer) |x| c.SDL_ReleaseGPUTransferBuffer(self.device, x); - if (self.trace_fd >= 0 and self.trace_fd != 2) _ = linux.close(self.trace_fd); + if (self.trace_fd >= 0 and self.trace_fd != 2) closeFdBestEffort(self.trace_fd); c.SDL_ReleaseGPUBuffer(self.device, self.vbuf); c.SDL_ReleaseGPUTransferBuffer(self.device, self.vxfer); self.alloc.free(self.cell_keys); @@ -994,17 +1079,7 @@ fn dumpGrid(self: *SdlBackend) !void { } buf.appendSlice(self.alloc, "---ENDFRAME---\n") catch return; - // write to stdout (fd 1) - var off: usize = 0; - while (off < buf.items.len) { - const rc = linux.write(1, buf.items[off..].ptr, buf.items.len - off); - const n: isize = @bitCast(rc); - if (n < 0) { - if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue; - return; - } - off += @intCast(n); - } + try writeAllFd(1, buf.items); } fn captureSwapchain(self: *SdlBackend, cmd: *c.SDL_GPUCommandBuffer, target: *c.SDL_GPUTexture, sw: u32, sh: u32) !void { @@ -1094,11 +1169,8 @@ fn writeCapturePpm(self: *SdlBackend, pixels: []const u8, width: u32, height: u3 const tmp_path = std.fmt.bufPrintZ(&tmp_buf, "{s}/latest.ppm.tmp", .{dir}) catch return error.CapturePathTooLong; const final_path = std.fmt.bufPrintZ(&final_buf, "{s}/latest.ppm", .{dir}) catch return error.CapturePathTooLong; - const fd_rc = linux.open(tmp_path.ptr, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644); - const fd_s: isize = @bitCast(fd_rc); - if (fd_s < 0) return error.CaptureWriteFailed; - const fd: c_int = @intCast(fd_s); - defer _ = linux.close(fd); + const fd = openCreateTrunc(tmp_path.ptr) orelse return error.CaptureWriteFailed; + defer closeFdBestEffort(fd); var header: [64]u8 = undefined; const hdr = std.fmt.bufPrint(&header, "P6\n{d} {d}\n255\n", .{ width, height }) catch return error.CaptureWriteFailed; @@ -1134,47 +1206,14 @@ fn writeCapturePpm(self: *SdlBackend, pixels: []const u8, width: u32, height: u3 try writeAllFd(fd, row_rgb); } - const rr = linux.rename(tmp_path.ptr, final_path.ptr); - const rs: isize = @bitCast(rr); - if (rs < 0) return error.CaptureWriteFailed; + renameZ(tmp_path.ptr, final_path.ptr) catch return error.CaptureWriteFailed; self.capture_frame += 1; } -fn writeAllFd(fd: c_int, bytes: []const u8) !void { - var off: usize = 0; - while (off < bytes.len) { - const rc = linux.write(fd, bytes[off..].ptr, bytes.len - off); - const n: isize = @bitCast(rc); - if (n < 0) { - if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue; - return error.CaptureWriteFailed; - } - if (n == 0) return error.CaptureWriteFailed; - off += @intCast(n); - } -} - -fn openTraceFile(path: []const u8) c_int { +fn openTraceFile(path: []const u8) posix.fd_t { var buf: [4096]u8 = undefined; const z = std.fmt.bufPrintZ(&buf, "{s}", .{path}) catch return 2; - const fd_rc = linux.open(z.ptr, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644); - const fd_s: isize = @bitCast(fd_rc); - if (fd_s < 0) return 2; - return @intCast(fd_s); -} - -fn writeAllFdIgnore(fd: c_int, bytes: []const u8) void { - var off: usize = 0; - while (off < bytes.len) { - const rc = linux.write(fd, bytes[off..].ptr, bytes.len - off); - const n: isize = @bitCast(rc); - if (n < 0) { - if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue; - return; - } - if (n == 0) return; - off += @intCast(n); - } + return openCreateTrunc(z.ptr) orelse 2; } fn tracef(self: *SdlBackend, comptime fmt: []const u8, args: anytype) void { @@ -1759,7 +1798,7 @@ fn SdlLoop(comptime EventType: type) type { if (!is_emscripten and self.vx.test_mode) { // read winsize from stdin (fd 0 = the pty slave) var ws: posix.winsize = std.mem.zeroes(posix.winsize); - const iorc = posix.system.ioctl(0, posix.T.IOCGWINSZ, @intFromPtr(&ws)); + const iorc = posix.system.ioctl(0, tiocgwinszReq(), @intFromPtr(&ws)); if (iorc < 0) return; // fd 0 is not a pty const cols: u16 = ws.col; const rows: u16 = ws.row; @@ -1968,14 +2007,9 @@ fn SdlLoop(comptime EventType: type) type { _ = posix.poll(&fds, 50) catch continue; if ((fds[0].revents & posix.POLL.IN) == 0) continue; - const rc = linux.read(0, buf[read_start..].ptr, buf.len - read_start); - const n: isize = @bitCast(rc); - if (n < 0) { - if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue; - break; - } + const n = readStdin(buf[read_start..]) catch break; if (n == 0) break; - const len: usize = read_start + @as(usize, @intCast(n)); + const len: usize = read_start + n; var seq_start: usize = 0; while (seq_start < len) { const result = parser.parse(buf[seq_start..len], null) catch break; @@ -2023,11 +2057,40 @@ fn setStdinRaw() !void { term.lflag.ICANON = false; term.lflag.IEXTEN = false; term.lflag.ISIG = false; - term.cc[@intFromEnum(linux.V.MIN)] = 1; - term.cc[@intFromEnum(linux.V.TIME)] = 0; + term.cc[@intFromEnum(posix.V.MIN)] = 1; + term.cc[@intFromEnum(posix.V.TIME)] = 0; try posix.tcsetattr(0, .NOW, term); } +fn ioctlReq(bits: u32) c_int { + return @bitCast(bits); +} + +fn tiocgwinszReq() c_int { + return switch (os_tag) { + .linux => @intCast(posix.T.IOCGWINSZ), + .macos, .freebsd, .openbsd, .netbsd, .dragonfly => ioctlReq(0x40087468), + else => @intCast(posix.T.IOCGWINSZ), + }; +} + +fn readStdin(buf: []u8) !usize { + switch (os_tag) { + .linux => { + while (true) { + const rc = linux.read(0, buf.ptr, buf.len); + const n: isize = @bitCast(rc); + if (n < 0) { + if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue; + return error.ReadFailed; + } + return @intCast(n); + } + }, + else => return posix.read(0, buf), + } +} + // ---- test-mode SIGWINCH handler ---- // Signal handlers can't take closures, so we just set a flag; the stdin reader // (SdlLoop.stdinRunTyped) checks it and reads + posts the new winsize. -- cgit v1.3