diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-30 23:35:45 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | 19f7322100062b7c1adcde3376063ce6c1d8c72d (patch) | |
| tree | b76347dffe0347c29a38b4cad6031678683dba74 /src/tty/tty.zig | |
| parent | 753d5451ca7d09dbc5ff44f3c2dcf9a47fb87f96 (diff) | |
| download | pardes-19f7322100062b7c1adcde3376063ce6c1d8c72d.tar.gz pardes-19f7322100062b7c1adcde3376063ce6c1d8c72d.zip | |
structure: backends into src/{tty,gui,lsp}, pane kinds and builtins into their own files
The core now lies FLAT at src/ and every subdirectory is one backend, so a
file being in no directory at all is what says it is core. Pane-kind bodies
leave pardes.zig for term_pane.zig / file_pane.zig / output_pane.zig, leaving
it the layout, the event/effect machine and the generic render loop.
Builtins are one struct each in builtins.zig, and the enum is folded out of
the file's own declaration list at comptime — a zig file IS a struct, so the
list of builtins and the builtins themselves are the same text. Adding one is
writing a struct. Key paths deliberately stay one table for the config pass.
Pure refactor: no golden moved.
Diffstat (limited to 'src/tty/tty.zig')
| -rw-r--r-- | src/tty/tty.zig | 624 |
1 files changed, 624 insertions, 0 deletions
diff --git a/src/tty/tty.zig b/src/tty/tty.zig new file mode 100644 index 00000000..007abd49 --- /dev/null +++ b/src/tty/tty.zig @@ -0,0 +1,624 @@ +//! The terminal shell: owns the event loop and all IO. Translates vaxis +//! events into core events, performs the core's effects (fork ptys, write +//! them, resize them), and hands the core's Surface to vaxis cell-for-cell — +//! the canonical interface rendered with no interpretation. +const std = @import("std"); +const builtin = @import("builtin"); +const posix = std.posix; +const libc = std.c; +const vaxis = @import("vaxis"); +const pardes = @import("../pardes.zig"); +const tracy = @import("../tracy.zig"); +const look = @import("../look.zig"); + +extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int; +extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; +extern "c" fn chdir(path: [*:0]const u8) c_int; +extern "c" fn _exit(status: c_int) noreturn; +extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; + +// absolute shell path per OS: execv must not search PATH (no allocation +// between fork and exec) +const bash_path: [*:0]const u8 = if (builtin.os.tag == .linux) "/usr/bin/bash" else "/bin/bash"; +// TIOCSWINSZ: absent from std.c.T on darwin — _IOW('t', 103, winsize) +const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467)); + +pub const Command = struct { + pub var value: union(enum) { + nop, + quit, + tick, + key_press: vaxis.Key, + pty_read: struct { id: usize, bytes: []u8 }, + pty_eof: struct { id: usize, gen: u32 }, + winsize: vaxis.Winsize, + mouse: vaxis.Mouse, + paste: []const u8, + /// a language query finished on a worker; rows are gpa-owned + lsp_done: struct { id: u32, rows: []u8 }, + } = .nop; +}; +const Loop = vaxis.Loop(@TypeOf(Command.value)); + +/// One language query, owned by the worker that runs it. Everything the +/// backend may read is copied in here before the worker starts: the core goes +/// on editing the moment the effect is drained, so a borrowed slice would be a +/// use-after-free the length of one keystroke. +const LspJob = struct { + id: u32, + kind: pardes.lsp.Kind, + offset: u32, + path: []u8, + source: [:0]u8, + arg: []u8, + root: []u8, + + fn free(j: *LspJob, gpa: std.mem.Allocator) void { + gpa.free(j.path); + gpa.free(j.source); + gpa.free(j.arg); + gpa.free(j.root); + gpa.destroy(j); + } +}; + +const Pty = struct { + file: std.Io.File, + pid: posix.pid_t, + reader: std.Io.Future(anyerror!void), +}; + +pub fn run(init: std.process.Init, opts: pardes.Options) !void { + const io = init.io; + const gpa = init.gpa; + + // SIGWINCH must never run vaxis's signal handler: it posts the winsize + // event through std.Io.Mutex/Condition, and when the signal lands on a + // thread blocked inside an Io.Threaded syscall region (pty readers in + // read(2), the main thread parked in queue.pop) a contended lock re-enters + // the Io machinery and Syscall.start hits `unreachable` — panic, then the + // panic-time terminal restore used to write through the same Io and + // recurse until stack overflow. Reproduced by resizing the outer terminal + // (e.g. a font-size change) while shells run. Block it here, before any + // thread exists (threads inherit the mask, so vaxis's handler never + // fires), and take it synchronously on the sigwait thread below instead. + var winch_set = posix.sigemptyset(); + posix.sigaddset(&winch_set, posix.SIG.WINCH); + posix.sigprocmask(posix.SIG.BLOCK, &winch_set, null); + + var tty_buf: [0x10000]u8 = undefined; + var tty = try vaxis.Tty.init(io, &tty_buf); + defer tty.deinit(); // restore cooked termios LAST, after vx flushed its resets + var vx = try vaxis.init(io, gpa, init.environ_map, .{ .system_clipboard_allocator = gpa }); + defer vx.deinit(gpa, tty.writer()); + try vx.enterAltScreen(tty.writer()); + defer vx.exitAltScreen(tty.writer()) catch {}; + // requests 1002;1003;1004;1006 (cell-coordinate SGR; called pre-query, so + // vaxis never upgrades to 1016 pixel mode). Note: ghostty's GTK apprt drops + // middle press+release BEFORE mouse reporting when the desktop sets + // gtk-enable-primary-paste=false — no mode we request can surface middle + // clicks there (see test/snapshots/ghostty-mid.snap). + try vx.setMouseMode(tty.writer(), true); + + var frame_arena: std.heap.ArenaAllocator = .init(gpa); + defer frame_arena.deinit(); + + // stb_image allocator for image panes + pardes.image.start(io, gpa); + defer pardes.image.stop(); + + var core = if (opts.load_path) |lp| blk: { + const bytes = try look.readFile(gpa, lp); + defer gpa.free(bytes); + break :blk try pardes.Pardes.initFromDump(gpa, opts, bytes); + } else try pardes.Pardes.init(gpa, opts); + defer core.deinit(); + + // shells emit OSC 133 prompt marks via this rc (prompt hiding, click-move) + writeFile(pardes.bash_rc); + // macos: apple's bash 3.2 prints the zsh-deprecation banner into every + // pane unless this is in the env BEFORE bash starts (the rc is too late) + if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1); + + var kitty_handles: [pardes.MAX_PANES]?vaxis.Image = @splat(null); + var ptys: [pardes.MAX_PANES]?Pty = @splat(null); + // per-slot spawn generation: a reused pane id ignores the old shell's + // late pty_eof (which would otherwise close the NEW pty on that slot) + var gens: [pardes.MAX_PANES]u32 = @splat(0); + // the single in-flight language query (see the .lsp effect) + var lsp_task: ?std.Io.Future(anyerror!void) = null; + var loop: Loop = .init(io, &tty, &vx); + defer { + // reap the reader tasks (cancel interrupts a blocked read) before + // closing the masters — the runtime joins those threads on exit and a + // reader stuck in read(2) would hang the process — then drain the + // queue: leftover events own gpa bytes and would dump as leaks. + for (&ptys) |*slot| if (slot.*) |*pt| { + pt.reader.cancel(io) catch {}; + _ = libc.close(pt.file.handle); + slot.* = null; + }; + // join the query worker BEFORE the drain below, or its late post + // lands in a queue nobody empties again and the rows leak + if (lsp_task) |*t| { + t.cancel(io) catch {}; + lsp_task = null; + } + while (loop.tryEvent() catch null) |ev| switch (ev) { + .pty_read => |pr| gpa.free(pr.bytes), + .paste => |b| gpa.free(@constCast(b)), + .lsp_done => |d| gpa.free(d.rows), + else => {}, + }; + } + + // Perform the initial spawns BEFORE any worker thread exists: forkpty from + // a multithreaded process can wedge the child before exec. + drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, &lsp_task, false); + + try loop.start(); + defer loop.stop(); + // resize watcher: plain detached thread (not io.concurrent — teardown + // joins those, and sigwait never returns); dies with the process + (try std.Thread.spawn(.{}, winchWatch, .{ &loop, &vx, &tty })).detach(); + vx.queryTerminal(tty.writer(), std.Io.Duration.fromMilliseconds(2)) catch {}; + + // now threads are fine: start a reader task per pty + for (&ptys, 0..) |*slot, id| if (slot.*) |*pt| { + pt.reader = try io.concurrent(readPty, .{ io, gpa, pt.file, id, gens[id], &loop }); + }; + + var pending: ?@TypeOf(Command.value) = .tick; + while (!core.quit) { + var event = if (pending) |ev| blk: { + pending = null; + break :blk ev; + } else try loop.nextEvent(); + const tz_event = tracy.zone(@src(), "event"); + // Apply every queued INPUT event, then render ONCE — the gui shell + // drains SDL's queue the same way. Without this a wheel flick is fifty + // full render+repaint (and re-highlight) cycles instead of one. + // pty output keeps a frame per chunk: progress paints as it arrives. + var stop = false; + var batch: usize = 0; + while (true) { + var output = false; + switch (event) { + .nop, .tick => {}, + .quit => stop = true, + .winsize => |ws| { + try vx.resize(gpa, tty.writer(), ws); + core.update(.{ .resize = .{ .cols = @intCast(ws.cols), .rows = @intCast(ws.rows) } }); + }, + .pty_read => |pr| { + core.update(.{ .output = .{ .pane = @intCast(pr.id), .bytes = pr.bytes } }); + gpa.free(pr.bytes); + output = true; + }, + .pty_eof => |e| if (gens[e.id] == e.gen) { + if (ptys[e.id]) |*pt| { + pt.reader.await(io) catch {}; // reader just finished; join it or its future leaks + _ = libc.close(pt.file.handle); + ptys[e.id] = null; + } + core.update(.{ .eof = .{ .pane = @intCast(e.id) } }); + }, + .key_press => |key| core.update(.{ .key = .{ + .cp = mapKey(effCp(key)), + .text = key.text orelse "", + .ctrl = key.mods.ctrl, + .alt = key.mods.alt, + .shift = key.mods.shift, + } }), + .mouse => |m| { + const button: ?pardes.Mouse.Button = switch (m.button) { + .left => .left, + .middle => .middle, + .right => .right, + .wheel_up => .wheel_up, + .wheel_down => .wheel_down, + .wheel_left => .wheel_left, + .wheel_right => .wheel_right, + .none => .none, // button-less motion: hover tracking + else => null, + }; + if (button) |b| core.update(.{ .mouse = .{ + .button = b, + .kind = switch (m.type) { + .press => .press, + .release => .release, + .motion => .motion, + .drag => .drag, + }, + .col = @intCast(m.col), + .row = @intCast(m.row), + .ctrl = m.mods.ctrl, + } }); + }, + .paste => |bytes| { + core.update(.{ .paste = bytes }); + gpa.free(@constCast(bytes)); + }, + .lsp_done => |d| { + core.update(.{ .lsp_resp = .{ .id = d.id, .rows = d.rows } }); + gpa.free(d.rows); + // the worker is finished; join it so its future does not + // leak (same contract as pty_eof above) + if (lsp_task) |*t| { + t.await(io) catch {}; + lsp_task = null; + } + }, + } + batch += 1; + if (stop or output or batch >= 64) break; + event = (try loop.tryEvent()) orelse break; + } + tz_event.end(); + if (stop) break; + + drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, &lsp_task, true); + if (core.quit) break; + + // Restore builtin: swap in a core rebuilt from the dump; the live + // shells die with their masters (readers canceled, gens bumped so + // their late eofs never touch the replay panes) + if (core.takeRestore()) |rp| blk: { + const bytes = look.readFile(gpa, rp) catch break :blk; + defer gpa.free(bytes); + var o = core.opts; + o.cols = core.screen_w; + o.rows = core.screen_h; // pre-size: dump panes never greet + const nc = pardes.Pardes.initFromDump(gpa, o, bytes) catch break :blk; + for (&ptys, 0..) |*slot, pid| if (slot.*) |*pt| { + pt.reader.cancel(io) catch {}; + _ = libc.close(pt.file.handle); + slot.* = null; + gens[pid] +%= 1; + }; + kitty_handles = @splat(null); + core.deinit(); + core = nc; + } + + // live cwd for tags/look: cheap per-pane lookup, per frame + for (&ptys, 0..) |*slot, id| if (slot.*) |pt| { + var lbuf: [1024]u8 = undefined; + if (look.shellCwd(pt.pid, &lbuf)) |cwd| core.setCwd(id, cwd); + }; + + // ---- render: surface -> vaxis, cell for cell ---- + _ = frame_arena.reset(.retain_capacity); + const tz_core = tracy.zone(@src(), "core.render"); + const surface = try core.render(frame_arena.allocator()); + tz_core.end(); + const tz_cells = tracy.zone(@src(), "surface->vaxis"); + const win = vx.window(); + win.clear(); + var y: u16 = 0; + while (y < surface.rows) : (y += 1) { + var x: u16 = 0; + while (x < surface.cols) : (x += 1) { + const cell = surface.at(x, y); + if (cell.default) continue; + win.writeCell(x, y, .{ + .char = .{ .grapheme = cell.grapheme() }, + .style = vaxisStyle(cell.style), + }); + } + } + tz_cells.end(); + // pixel attachments (kitty graphics): transmit once per pane, then + // re-place every frame (placements aren't persistent) + for (surface.images[0..surface.nimages]) |maybe| { + const place = maybe orelse continue; + if (kitty_handles[place.pane] == null and vx.caps.kitty_graphics) { + const enc = std.base64.standard.Encoder; + if (gpa.alloc(u8, enc.calcSize(place.rgba.len))) |b64| { + defer gpa.free(b64); + _ = enc.encode(b64, place.rgba); + kitty_handles[place.pane] = vx.transmitPreEncodedImage(tty.writer(), b64, @intCast(place.iw), @intCast(place.ih), .rgba) catch null; + } else |_| {} + } + if (kitty_handles[place.pane]) |h| { + const child = win.child(.{ .x_off = place.x, .y_off = place.y, .width = place.w, .height = place.h }); + h.draw(child, .{ .scale = .contain }) catch {}; + } + } + if (surface.cursor) |cur| { + win.showCursor(cur.x, cur.y); + // insert = beam, everything else = the terminal's default shape + win.setCursorShape(if (cur.bar) .beam else .default); + } + const tz_render = tracy.zone(@src(), "vx.render"); + try vx.render(tty.writer()); + tz_render.end(); + tracy.frameMark(); + } +} + +fn drainEffects( + core: *pardes.Pardes, + ptys: *[pardes.MAX_PANES]?Pty, + gens: *[pardes.MAX_PANES]u32, + io: std.Io, + gpa: std.mem.Allocator, + loop: *Loop, + vx: *vaxis.Vaxis, + tty: *vaxis.Tty, + lsp_task: *?std.Io.Future(anyerror!void), + threads_ok: bool, +) void { + while (core.nextEffect()) |effect| switch (effect) { + .spawn => |sp| { + // the core reuses pane ids and there is no close effect: a deleted + // pane's shell lives in its slot until a respawn lands here — reap + // it (cancel joins the reader; its late eof is ignored by gen) + if (ptys[sp.pane]) |*old| { + old.reader.cancel(io) catch {}; + _ = libc.close(old.file.handle); + ptys[sp.pane] = null; + } + gens[sp.pane] +%= 1; + const cwd = sp.cwd.slice(); + var cwd_buf: [256:0]u8 = undefined; + var cwd_z: ?[*:0]const u8 = null; + if (cwd.len > 0) { + @memcpy(cwd_buf[0..cwd.len], cwd); + cwd_buf[cwd.len] = 0; + cwd_z = @ptrCast(&cwd_buf); + } + const child = forkShell(cwd_z, core.screen_h, core.screen_w); + ptys[sp.pane] = .{ .file = child.file, .pid = child.pid, .reader = .{ .any_future = null, .result = {} } }; + // report the pane's starting directory back to the core (tags) + var lbuf: [1024]u8 = undefined; + if (look.shellCwd(child.pid, &lbuf)) |wd| core.setCwd(sp.pane, wd); + if (threads_ok) { + if (ptys[sp.pane]) |*pt| { + pt.reader = io.concurrent(readPty, .{ io, gpa, pt.file, @as(usize, sp.pane), gens[sp.pane], loop }) catch pt.reader; + } + } + }, + .write => |w| { + if (ptys[w.pane]) |pt| writeFd(pt.file.handle, w.bytes.slice()); + }, + .resize_pty => |rs| { + if (ptys[rs.pane]) |pt| { + const ws: posix.winsize = .{ .row = rs.rows, .col = rs.cols, .xpixel = 0, .ypixel = 0 }; + _ = posix.system.ioctl(pt.file.handle, TIOCSWINSZ, @intFromPtr(&ws)); + } + }, + .open_link => |url| look.openLink(url.slice()), // desktop browser + .save_file => |sf| { + const pane = core.panes[sf.pane] orelse continue; + const f = pane.file orelse continue; + var pathbuf: [4096:0]u8 = undefined; + if (f.path.len >= pathbuf.len) continue; + @memcpy(pathbuf[0..f.path.len], f.path); + pathbuf[f.path.len] = 0; + const fd = libc.open(pathbuf[0..f.path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); + if (fd < 0) continue; + writeFd(fd, f.content); + _ = libc.close(fd); + }, + .write_dump => { + const out = core.dump_out orelse continue; + var pbuf: [1024:0]u8 = undefined; + const path = pardes.dump.outPath(&pbuf) orelse continue; + const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); + if (fd < 0) continue; + writeFd(fd, out); + _ = libc.close(fd); + core.setLastDump(path); + }, + .set_clipboard => { + // mirror the core's yank register out via OSC 52 + if (core.yank) |y| vx.copyToSystemClipboard(tty.writer(), y, gpa) catch {}; + }, + .lsp => |q| { + if (!threads_ok) continue; // pre-loop drain: nothing to answer to yet + const pane = core.panes[q.pane] orelse continue; + // a pane with no file still asks `status` (it is about the backend, + // not the buffer): empty path and source, root from the pane's cwd + const f = pane.file; + const job = gpa.create(LspJob) catch continue; + job.* = .{ + .id = q.id, + .kind = q.kind, + .offset = q.offset, + .path = gpa.dupe(u8, if (f) |ff| ff.path else "") catch { + gpa.destroy(job); + continue; + }, + .source = gpa.dupeZ(u8, if (f) |ff| ff.content else "") catch { + gpa.free(job.path); + gpa.destroy(job); + continue; + }, + .arg = gpa.dupe(u8, q.arg.slice()) catch { + gpa.free(job.path); + gpa.free(job.source); + gpa.destroy(job); + continue; + }, + .root = gpa.dupe(u8, if (f) |ff| (std.fs.path.dirname(ff.path) orelse "/") else pane.cwdSlice()) catch { + gpa.free(job.path); + gpa.free(job.source); + gpa.free(job.arg); + gpa.destroy(job); + continue; + }, + }; + // ponytail: ONE query in flight, so one future slot. Replacing it + // cancels-then-joins the previous worker, which for a backend that + // ignores cancellation means waiting out a query the user already + // abandoned. Queries are milliseconds; make this a real pool the + // day a backend takes long enough to notice. + if (lsp_task.*) |*old| { + old.cancel(io) catch {}; + lsp_task.* = null; + } + lsp_task.* = io.concurrent(lspWorker, .{ gpa, job, loop }) catch { + job.free(gpa); + continue; + }; + }, + .quit => {}, + }; +} + +/// Answer a language query off the event loop and post the rows back. This is +/// the whole async execution model: the same shape as readPty — do the slow +/// thing on a worker, hand the result to the loop as an event, let the core +/// stay a state machine that never blocks. +fn lspWorker(gpa: std.mem.Allocator, job: *LspJob, loop: *Loop) anyerror!void { + defer job.free(gpa); + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + // the shell owns the result buffer; the backend only ever writes to it + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + pardes.lsp.query(gpa, arena.allocator(), .{ + .kind = job.kind, + .path = job.path, + .source = job.source, + .offset = job.offset, + .arg = job.arg, + .root = job.root, + }, &out.writer); + const rows = gpa.dupe(u8, out.written()) catch return; + loop.postEvent(.{ .lsp_done = .{ .id = job.id, .rows = rows } }) catch gpa.free(rows); + return; +} + +/// Consume SIGWINCH synchronously (it is blocked in every thread) and post +/// the new size as a winsize event from normal thread context — the one place +/// vaxis's Io-backed queue is safe to touch on a resize. +fn winchWatch(loop: *Loop, vx: *vaxis.Vaxis, tty: *vaxis.Tty) void { + var set = posix.sigemptyset(); + posix.sigaddset(&set, posix.SIG.WINCH); + while (true) { + var sig: c_int = 0; + if (libc.sigwait(&set, &sig) != 0) continue; + if (vx.state.in_band_resize) continue; // terminal reports via CSI 48 + const ws = tty.getWinsize() catch continue; + loop.postEvent(.{ .winsize = ws }) catch {}; + } +} + +fn forkShell(cwd: ?[*:0]const u8, rows: u16, cols: u16) struct { file: std.Io.File, pid: posix.pid_t } { + var master: c_int = undefined; + const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; + const pid = forkpty(&master, null, null, &ws); + if (pid == 0) { + // the blocked-SIGWINCH mask survives fork AND exec — unblock it or + // bash/vim in the pane would never see resizes (sigprocmask is + // async-signal-safe) + var set = posix.sigemptyset(); + posix.sigaddset(&set, posix.SIG.WINCH); + posix.sigprocmask(posix.SIG.UNBLOCK, &set, null); + if (cwd) |c| _ = chdir(c); + const argv: [4:null]?[*:0]const u8 = .{ bash_path, "--rcfile", "/tmp/pardes-osc133.bash", null }; + _ = execv(bash_path, &argv); + _exit(127); + } + return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid }; +} + +fn readPty(io: std.Io, gpa: std.mem.Allocator, pty: std.Io.File, id: usize, gen: u32, loop: *Loop) anyerror!void { + var read_buf: [0x10000]u8 = undefined; + var reader = pty.readerStreaming(io, &read_buf); + while (true) { + var buf: [0x10000]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; + }; + } + // non-blocking: a teardown cancel only unblocks one wait, so a blocking + // post into a full queue here could hang the exit + _ = loop.tryPostEvent(.{ .pty_eof = .{ .id = id, .gen = gen } }) catch {}; +} + +/// The effective codepoint the way vaxis Key.matches sees it: a single-char +/// text wins (shift resolved by the terminal), else the shifted codepoint. +fn effCp(key: vaxis.Key) u21 { + if (key.text) |t| { + const view = std.unicode.Utf8View.init(t) catch return key.codepoint; + var it = view.iterator(); + if (it.nextCodepoint()) |c| { + if (it.nextCodepoint() == null) return c; + } + } + return key.shifted_codepoint orelse key.codepoint; +} + +/// vaxis functional-key codepoints -> core Key constants (ASCII ones already +/// coincide: enter/tab/escape/backspace pass through). +fn mapKey(cp: u21) u21 { + return switch (cp) { + vaxis.Key.up => pardes.Key.up, + vaxis.Key.down => pardes.Key.down, + vaxis.Key.left => pardes.Key.left, + vaxis.Key.right => pardes.Key.right, + vaxis.Key.home => pardes.Key.home, + vaxis.Key.end => pardes.Key.end, + vaxis.Key.page_up => pardes.Key.page_up, + vaxis.Key.page_down => pardes.Key.page_down, + vaxis.Key.delete => pardes.Key.delete, + else => cp, + }; +} + +fn vaxisStyle(s: pardes.CellStyle) vaxis.Style { + return .{ + .fg = vaxisColor(s.fg), + .bg = vaxisColor(s.bg), + .bold = s.bold, + .dim = s.dim, + .italic = s.italic, + .blink = s.blink, + .reverse = s.reverse, + .invisible = s.invisible, + .strikethrough = s.strikethrough, + .ul_style = switch (s.ul) { + .off => .off, + .single => .single, + .double => .double, + .curly => .curly, + .dotted => .dotted, + .dashed => .dashed, + }, + }; +} + +fn vaxisColor(c: pardes.Color) vaxis.Color { + return switch (c) { + .default => .default, + .index => |i| .{ .index = i }, + .rgb => |rgb| .{ .rgb = rgb }, + }; +} + +fn writeFile(data: []const u8) void { + const fd = libc.open("/tmp/pardes-osc133.bash", .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); + if (fd < 0) return; + defer _ = libc.close(fd); + writeFd(fd, data); +} + +fn writeFd(fd: c_int, data: []const u8) void { + var off: usize = 0; + while (off < data.len) { + const n = libc.write(fd, data[off..].ptr, data.len - off); + if (n < 0) { + if (libc.errno(n) == .INTR) continue; + return; + } + off += @intCast(n); + } +} |
