From 19f7322100062b7c1adcde3376063ce6c1d8c72d Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Thu, 30 Jul 2026 23:35:45 -0300 Subject: structure: backends into src/{tty,gui,lsp}, pane kinds and builtins into their own files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/builtins.zig | 443 ++++++++ src/crt.zig | 43 - src/deck.zig | 204 ---- src/file_pane.zig | 196 ++++ src/gui.zig | 3103 --------------------------------------------------- src/gui/crt.zig | 43 + src/gui/deck.zig | 204 ++++ src/gui/gui.zig | 3103 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/lsp.zig | 162 --- src/lsp/lsp.zig | 162 +++ src/lsp/lsp_zls.zig | 1073 ++++++++++++++++++ src/lsp_zls.zig | 1073 ------------------ src/main.zig | 6 +- src/output_pane.zig | 72 ++ src/pardes.zig | 796 ++----------- src/term_pane.zig | 222 ++++ src/tty.zig | 624 ----------- src/tty/tty.zig | 624 +++++++++++ 18 files changed, 6239 insertions(+), 5914 deletions(-) create mode 100644 src/builtins.zig delete mode 100644 src/crt.zig delete mode 100644 src/deck.zig create mode 100644 src/file_pane.zig delete mode 100644 src/gui.zig create mode 100644 src/gui/crt.zig create mode 100644 src/gui/deck.zig create mode 100644 src/gui/gui.zig delete mode 100644 src/lsp.zig create mode 100644 src/lsp/lsp.zig create mode 100644 src/lsp/lsp_zls.zig delete mode 100644 src/lsp_zls.zig create mode 100644 src/output_pane.zig create mode 100644 src/term_pane.zig delete mode 100644 src/tty.zig create mode 100644 src/tty/tty.zig (limited to 'src') diff --git a/src/builtins.zig b/src/builtins.zig new file mode 100644 index 00000000..7143a860 --- /dev/null +++ b/src/builtins.zig @@ -0,0 +1,443 @@ +//! The builtins: one struct each, and nothing hand-maintained about them. +//! +//! Executing a builtin's NAME (middle-click / Tab) runs it through the one +//! dispatcher (Pardes.runBuiltin), no matter where the name appears. The +//! struct's DECL NAME is the user-visible word — the one in the topbar, the +//! one sitting in a tag, the one Help prints, the one you execute — so +//! `std.meta.stringToEnum` is the lookup and there is no name table to sync. +//! +//! A zig file IS a struct, so THIS FILE'S declarations are the list: `all` +//! walks them at comptime and `Builtin` folds an enum out of that, in source +//! order. Adding a builtin is writing one struct here — there is no list to +//! append to and no switch prong to add, so there is nothing to forget. The +//! hand-written enum and the four-hundred-line-away switch this replaces were +//! two lists that had to agree; now the code IS the data. +//! +//! What makes a declaration a builtin is its SHAPE: a struct declaring +//! `pub fn run(Ctx) void`. That is strict enough that Ctx and the two folds +//! below fall out by construction rather than by a blocklist — a helper can +//! never accidentally become a builtin, and a builtin whose run has the wrong +//! signature silently vanishes instead of half-working, which the leader +//! table catches immediately (see leader_path: a missing key path is a +//! compile error). +//! +//! What is NOT here: the key bindings. `leader_path` stays ONE table in +//! pardes.zig next to `topbar_str` — the whole remapping surface belongs in +//! one place a user can read top to bottom, not scattered a line at a time +//! across thirty-three structs. +const std = @import("std"); +const pardes = @import("pardes.zig"); +const Pardes = pardes.Pardes; +const Pane = pardes.Pane; +const output_pane = @import("output_pane.zig"); + +/// What a builtin gets to act on. One bundle rather than five parameters +/// because most builtins want two of them and zig rejects the unused rest. +/// `txt` is the executed text (Restore reads its path back out of it) and +/// `arg` the execute's ARGUMENT — text typed after the name, or the selection +/// a mouse chord kept, which is why Grep and Find run straight away when there +/// is one instead of asking. The leader passes "" and null: a key path names a +/// builtin, never an argument. +pub const Ctx = struct { + p: *Pardes, + /// pane `id`, already resolved — the dispatcher's null check is the one + /// guard every builtin used to share. + pane: *Pane, + id: usize, + txt: []const u8, + arg: ?[]const u8, +}; + +/// Every builtin, in the order they are written below — which is the enum's +/// numeric order. Nothing reads that order: the topbar picks its own subset by +/// name and Help sorts by key path, and nothing persists a builtin's integer +/// (the dump stores tag WORDS), so reordering this file is free. +/// +/// A FUNCTION and not a const, and so is Builtin below, for one reason: both +/// walk this file's own declaration list, and a const doing that is a +/// declaration whose value depends on itself — zig rejects it outright. As +/// functions they are only ever a signature to the walk, never a value, so +/// they are not in their own way. +/// +/// The walk only ever sees `pub` decls, so the imports above are invisible to +/// it; what it does see and turn away is `all`/`word`/`Builtin` (not types) +/// and `Ctx` (a type, but with no `run`). +pub fn all() []const type { + // the whole body is comptime: a []const type only exists there, and it is + // what makes the decl walk's `d` a compile-time name rather than a value + comptime { + @setEvalBranchQuota(4000); // one pass per decl, and @hasDecl builds a map each + var list: []const type = &.{}; + for (@typeInfo(@This()).@"struct".decls) |d| { + // @TypeOf never evaluates its operand, so this turns away `all` + // and `Builtin` by their SIGNATURES — asking for either one's + // VALUE here would be a declaration that depends on itself + if (@TypeOf(@field(@This(), d.name)) != type) continue; + const T = @field(@This(), d.name); + if (@typeInfo(T) != .@"struct") continue; + if (!@hasDecl(T, "run")) continue; + if (@TypeOf(T.run) != fn (Ctx) void) continue; + list = list ++ &[_]type{T}; + } + return list; + } +} + +/// A builtin's user-visible word: the LAST dotted segment of `@typeName`, +/// because @typeName spells a file-scope struct fully qualified +/// ("builtins.Kill"). Deriving it beats a `pub const name` field per struct, +/// which would be the same word written twice with nothing keeping the two +/// honest. A name that is not a legal identifier would be spelled `@"..."`. +pub fn word(comptime T: type) []const u8 { + const n = @typeName(T); + const dot = std.mem.lastIndexOfScalar(u8, n, '.') orelse return n; + return n[dot + 1 ..]; +} + +/// The enum: field name = struct name, value = index into `all()`. Everything +/// downstream (leader_path's EnumArray, leader_rows, the topbar's comptime +/// check, stringToEnum) speaks it exactly as it did when it was hand-written. +pub fn Builtin() type { + const cmds = all(); + const Tag = std.math.IntFittingRange(0, cmds.len - 1); + var names: [cmds.len][]const u8 = undefined; + for (cmds, 0..) |T, i| names[i] = word(T); + return @Enum(Tag, .exhaustive, &names, &std.simd.iota(Tag, cmds.len)); +} + +// ---- session ---- + +pub const Kill = struct { + pub fn run(c: Ctx) void { + c.p.quit = true; + c.p.emit(.quit); + } +}; + +pub const Dump = struct { + pub fn run(c: Ctx) void { + c.p.dumpState() catch {}; + } +}; + +pub const Restore = struct { + pub fn run(c: Ctx) void { + var it = std.mem.tokenizeAny(u8, c.txt, " \t"); + _ = it.next(); // the word "Restore" + const path = it.next() orelse (c.p.last_dump orelse return); + if (path.len > c.p.restore_buf.len) return; + @memcpy(c.p.restore_buf[0..path.len], path); + c.p.restore_req = c.p.restore_buf[0..path.len]; + } +}; + +// ---- display toggles ---- + +pub const Debug = struct { + pub fn run(c: Ctx) void { + c.p.show_debug = !c.p.show_debug; + } +}; + +pub const Colors = struct { + pub fn run(c: Ctx) void { + c.p.colors_on = !c.p.colors_on; + } +}; + +pub const NextColor = struct { + pub fn run(c: Ctx) void { + c.p.theme_idx = (c.p.theme_idx + 1) % pardes.themes.len; + } +}; + +pub const Crt = struct { + pub fn run(c: Ctx) void { + c.p.crt_on = !c.p.crt_on; + } +}; + +// The image pane's three renderer toggles. They used to be words the image tag +// printed and actOnSelection matched by hand; as builtins they are executable +// anywhere, pressable under SPC and listed by `SPC ?`, which is the whole +// reason the tag no longer carries them. Each acts on the pane it runs in and +// is inert anywhere else, the way Save is on a terminal — flipping the field is +// the whole toggle: drawImage re-matches the glyph grid when it sees +// grid_mode/grid_ascii disagree with the live ones. + +/// glyph art over the host's pixels +pub const Petscii = struct { + pub fn run(c: Ctx) void { + if (c.pane.image) |*iv| { + iv.petscii = !iv.petscii; + } + } +}; + +/// the C64 palette or the terminal's own 16 +pub const Palette = struct { + pub fn run(c: Ctx) void { + if (c.pane.image) |*iv| { + iv.pmode = if (iv.pmode == .commodore) .terminal else .commodore; + } + } +}; + +/// add the printable ASCII bitmaps to the matcher's glyph set +pub const Ascii = struct { + pub fn run(c: Ctx) void { + if (c.pane.image) |*iv| { + iv.ascii = !iv.ascii; + } + } +}; + +// ---- panes and columns ---- + +pub const Save = struct { + pub fn run(c: Ctx) void { + // an output buffer has no file behind it — nothing to write + if (c.pane.file) |f| if (!f.output) c.p.emit(.{ .save_file = .{ .pane = @intCast(c.id) } }); + } +}; + +pub const Newcol = struct { + pub fn run(c: Ctx) void { + const free = c.p.freeSlot() orelse return; + if (c.p.ncol >= pardes.MAX_COLS) return; + const nt = c.p.newShell(free, "") catch return; + nt.greet = true; + c.p.layoutAppendColumn(free); + c.p.active = free; + } +}; + +pub const Del = struct { + pub fn run(c: Ctx) void { + c.p.absorbVWeight(c.id); + c.p.layoutRemove(c.id); + c.p.deinitPane(c.pane); + c.p.panes[c.id] = null; + if (c.p.active == c.id) c.p.active = c.p.prevFocus(c.id) orelse { + c.p.quit = true; + c.p.emit(.quit); + return; + }; + } +}; + +pub const Delcol = struct { + pub fn run(c: Ctx) void { + const f = c.p.layoutFindTerm(c.id) orelse return; + var ids: [pardes.MAX_PANES]usize = undefined; + const nids = c.p.col_n[f.col]; + for (0..nids) |k| ids[k] = c.p.col_terms[f.col][k]; + for (ids[0..nids]) |tid| { + if (c.p.panes[tid]) |tt| { + c.p.layoutRemove(tid); + c.p.deinitPane(tt); + c.p.panes[tid] = null; + } + } + if (c.p.panes[c.p.active] == null) c.p.active = c.p.prevFocus(c.p.active) orelse { + c.p.quit = true; + c.p.emit(.quit); + return; + }; + } +}; + +pub const Tutor = struct { + pub fn run(c: Ctx) void { + const free = c.p.freeSlot() orelse return; + const nt = c.p.openTutorView(free) catch return; + c.p.placeDoc(c.id, free, nt); // a doc like any other + } +}; + +pub const Help = struct { + pub fn run(c: Ctx) void { + output_pane.openHelp(c.p, c.id, ""); + } +}; + +// ---- search ---- + +// The two builtins that ASK for something — Find walks file NAMES under this +// pane's directory, Grep file CONTENTS under every pane's. With an argument +// there is nothing to ask: it IS the pattern, so the walk runs now (this is +// what a `Grep` executed with a selection chorded to it means). Without one +// they arm the same tag input `/` does, and Enter runs it (submitSearch). + +pub const Find = struct { + pub fn run(c: Ctx) void { + const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); + if (pat.len > 0) return c.p.runSearch(c.id, pat, .find); + c.p.startSearch(c.pane, Pardes.find_marker); + } +}; + +/// Find's sibling: Find matches file NAMES under this pane's directory, Grep +/// matches file CONTENTS under every pane's directory at once. +pub const Grep = struct { + pub fn run(c: Ctx) void { + const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); + if (pat.len > 0) return c.p.runSearch(c.id, pat, .grep); + c.p.startSearch(c.pane, Pardes.grep_marker); + } +}; + +// ---- the window group ---- + +// The DESTINATION is the name — a word, the way a tag holds Del or Save — +// because these names live in the same vocabulary as everything else here: +// `Wh` would be a leader key path leaking into the text you can middle-click. +// Plain English words are safe for exactly these five: focus is the cheapest +// thing to change by accident (nothing is edited, closed or written) and the +// way back is the opposite word. + +pub const Left = struct { + pub fn run(c: Ctx) void { + c.p.focusDir(c.id, .left); + } +}; + +pub const Down = struct { + pub fn run(c: Ctx) void { + c.p.focusDir(c.id, .down); + } +}; + +pub const Up = struct { + pub fn run(c: Ctx) void { + c.p.focusDir(c.id, .up); + } +}; + +pub const Right = struct { + pub fn run(c: Ctx) void { + c.p.focusDir(c.id, .right); + } +}; + +/// The file<->terminal hop. "Latest" is already recorded: focus_hist is the +/// MRU sync() rebuilds every update (most recent last), the same list a +/// closing pane hands focus back through — so this walks it instead of keeping +/// a second one. Which side is which: only a shell is a terminal; a file, an +/// image and an output buffer (+Search/+Help) are all DOCS you read, so +/// isTerminal is the whole test. Landing pushes this pane to the top of that +/// same history, which is why the hop back is the same key. +pub const Toggleterm = struct { + pub fn run(c: Ctx) void { + const want_term = !c.pane.isTerminal(); + var t: ?usize = null; + // any live pane of the other kind: a pane you have never focused (the + // file you started with) is in no history at all + for (c.p.panes, 0..) |slot, k| { + const op = slot orelse continue; + if (k != c.id and op.isTerminal() == want_term) t = k; + } + // ...but the most recently focused one wins + var i = c.p.nfocus; + while (i > 0) { + i -= 1; + const hid = c.p.focus_hist[i]; + const hp = c.p.panes[hid] orelse continue; + if (hid != c.id and hp.isTerminal() == want_term) { + t = hid; + break; + } + } + if (t) |target| { + c.p.active = target; + c.p.panes[target].?.pending = 0; + } + } +}; + +// ---- the language group ---- + +// Reached as `SPC l ` — see leader_path for why the prefix +// exists. They are builtins rather than bare keys for the same reason Save is +// one: the word is executable wherever it appears, so a middle-click on +// `Hover` in a tag does what `SPC l k` does. The five GOTOS are not here — +// helix binds them under `g` as motions, and a motion has no business being a +// word you can click. +// +// Most of them are one call: ask, and let the answer land in lspResponse. +// Nothing here blocks or knows what a backend is — swapping backends changes +// lsp.query and not one line below. + +pub const Hover = struct { + pub fn run(c: Ctx) void { + c.p.lspRequest(c.id, .hover, ""); + } +}; + +pub const CodeAction = struct { + pub fn run(c: Ctx) void { + c.p.lspRequest(c.id, .code_action, ""); + } +}; + +pub const SelectRefs = struct { + pub fn run(c: Ctx) void { + c.p.lspRequest(c.id, .select_refs, ""); + } +}; + +pub const Symbols = struct { + pub fn run(c: Ctx) void { + c.p.lspRequest(c.id, .document_symbols, ""); + } +}; + +pub const Diagnostics = struct { + pub fn run(c: Ctx) void { + c.p.lspRequest(c.id, .diagnostics, ""); + } +}; + +pub const WsDiagnostics = struct { + pub fn run(c: Ctx) void { + c.p.lspRequest(c.id, .workspace_diagnostics, ""); + } +}; + +// The two that need a word from the user, handled exactly the way Find and +// Grep are: an argument means run it now (a selection chorded onto the name), +// no argument arms the tag input and Enter submits (submitSearch). + +pub const Rename = struct { + pub fn run(c: Ctx) void { + const a = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); + if (a.len > 0) return c.p.lspRequest(c.id, .rename, a); + c.p.startSearch(c.pane, Pardes.rename_marker); + } +}; + +pub const WsSymbols = struct { + pub fn run(c: Ctx) void { + const a = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); + if (a.len > 0) return c.p.lspRequest(c.id, .workspace_symbols, a); + c.p.startSearch(c.pane, Pardes.symbol_marker); + } +}; + +// Introspection. A language backend that answers nothing looks exactly like +// one that is broken — from the outside, `gd` doing nothing is both "there is +// no definition" and "the analyser threw and we swallowed it". These two are +// how you tell: Lspinfo says what the backend IS, Lspwhy says what it just DID +// and where it stopped. + +pub const Lspinfo = struct { + pub fn run(c: Ctx) void { + c.p.lspRequest(c.id, .status, ""); + } +}; + +pub const Lspwhy = struct { + pub fn run(c: Ctx) void { + c.p.lspRequest(c.id, .explain, ""); + } +}; diff --git a/src/crt.zig b/src/crt.zig deleted file mode 100644 index 9cf81593..00000000 --- a/src/crt.zig +++ /dev/null @@ -1,43 +0,0 @@ -//! The Crt builtin's screen geometry, shared between the gui shell's input -//! mapping and its shader: crt.frag.glsl shows the scene point -//! uv' = cc*(1 + barrel*r2) at screen point cc, so a physical mouse position -//! must pass through the SAME forward transform before it becomes a cell. -//! Pure std, so the unit-test step can pin it against the shader formula. - -const std = @import("std"); - -/// MUST match BARREL in shaders/crt.frag.glsl -pub const barrel: f32 = 0.012; - -pub const Point = struct { x: f32, y: f32 }; - -/// physical window pixels -> the scene pixels the shader displays there, -/// clamped to the window. The shader's phosphor quantization and chromatic -/// offset displace at most a few pixels — well under a cell — and are -/// ignored here. -pub fn map(x: f32, y: f32, w: f32, h: f32) Point { - const ccx = x / w * 2.0 - 1.0; - const ccy = y / h * 2.0 - 1.0; - const s = 1.0 + barrel * (ccx * ccx + ccy * ccy); - return .{ - .x = std.math.clamp((ccx * s + 1.0) * 0.5 * w, 0, w - 1), - .y = std.math.clamp((ccy * s + 1.0) * 0.5 * h, 0, h - 1), - }; -} - -test "map matches the shader's barrel formula" { - // center is a fixed point - try std.testing.expectApproxEqAbs(@as(f32, 500), map(500, 250, 1000, 500).x, 0.001); - try std.testing.expectApproxEqAbs(@as(f32, 250), map(500, 250, 1000, 500).y, 0.001); - // corners: cc=(±1,±1), r2=2, scale 1.024 pushes past the tube -> clamped - try std.testing.expectEqual(@as(f32, 0), map(0, 0, 1000, 500).x); - try std.testing.expectEqual(@as(f32, 0), map(0, 0, 1000, 500).y); - try std.testing.expectEqual(@as(f32, 999), map(1000, 500, 1000, 500).x); - try std.testing.expectEqual(@as(f32, 499), map(1000, 500, 1000, 500).y); - // (100,100) of 1000x500: cc=(-0.8,-0.6), r2=1.0, s=1.012 -> - // cc'=(-0.8096,-0.6072) -> (95.2, 98.2) - try std.testing.expectApproxEqAbs(@as(f32, 95.2), map(100, 100, 1000, 500).x, 0.01); - try std.testing.expectApproxEqAbs(@as(f32, 98.2), map(100, 100, 1000, 500).y, 0.01); - // (900,250): cc=(0.8,0), r2=0.64, s=1.00768 -> x'=903.072 - try std.testing.expectApproxEqAbs(@as(f32, 903.072), map(900, 250, 1000, 500).x, 0.01); -} diff --git a/src/deck.zig b/src/deck.zig deleted file mode 100644 index 1859bae8..00000000 --- a/src/deck.zig +++ /dev/null @@ -1,204 +0,0 @@ -//! Steam Deck controls -> editor input, as a pure state machine: gui.zig -//! feeds SDL gamepad events in and applies the returned actions (cursor -//! moves, synthetic clicks, wheel ticks, keys, rumble), so the whole -//! mapping replays off-device from deckcap recordings (test/deck/). -//! -//! Layout: right pad moves the pointer (relative, laptop-touchpad-style) and -//! a firm press on it = select (button 1); left pad scrolls and a firm press -//! = look (button 3); R2 = select, L2 = execute (button 2), L1 = look, dpad -//! down/up = n/N, A = Enter, Y = Tab, the back paddles (L4/R4/L5/R5) toggle -//! the terminal (Ctrl-b); execute, look and pad-clicks request a brief rumble -//! ack. (The GUI mirrors this on the sticks: right stick = pointer, left -//! stick = scroll.) - -const std = @import("std"); - -// SDL_GamepadButton / SDL_GamepadAxis / touchpad indices (SDL3), verified -// against real deck recordings (test/deck/*.zon) and the pinned SDL's -// hidapi deck driver (touchpad 0 = left pad, 1 = right pad). -pub const BTN_A = 0; // SOUTH -pub const BTN_Y = 3; // NORTH -pub const BTN_L1 = 9; // LEFT_SHOULDER -pub const BTN_DPAD_UP = 11; -pub const BTN_DPAD_DOWN = 12; -// rear grip paddles (from a deckcap deckmap capture): the two-digit indices -pub const BTN_R4 = 16; -pub const BTN_L4 = 17; -pub const BTN_R5 = 18; -pub const BTN_L5 = 19; -pub const AXIS_L2 = 4; // LEFT_TRIGGER, analog 0..32767 -pub const AXIS_R2 = 5; // RIGHT_TRIGGER -pub const PAD_LEFT = 0; -pub const PAD_RIGHT = 1; - -/// feel knobs: a full-pad finger sweep covers this many screen pixels -/// (the deck's screen is 1280x800) / this many wheel ticks -pub const POINTER_SCALE: f32 = 600.0; -pub const SCROLL_SCALE: f32 = 24.0; -/// the pinned SDL 3.4.4 deck driver skips upstream's pad-Y negation, so -/// touch y arrives 0 = pad bottom; flip this when the sdl dep is bumped -pub const PAD_Y_SIGN: f32 = -1.0; -// analog triggers click at 30% and release at 20%: the gap keeps a value -// hovering near one threshold from chattering press/release pairs -pub const TRIG_PRESS: i16 = 9830; -pub const TRIG_RELEASE: i16 = 6553; -// a firm pad press (pressure 0..1) counts as a click, with the same release -// hysteresis. Real light-touch drags read ~0.03 in captures, so this is well -// clear of them; tune on-device against a real click capture. -pub const PAD_CLICK_PRESS: f32 = 0.10; -pub const PAD_CLICK_RELEASE: f32 = 0.06; - -pub const Input = union(enum) { - button: struct { idx: u8, down: bool }, - axis: struct { idx: u8, value: i16 }, - touch: struct { pad: u8, phase: enum { down, motion, up }, x: f32, y: f32, pressure: f32 }, -}; - -pub const Click = enum { select, execute, look }; // mouse buttons 1/2/3 -pub const Keytap = enum { n, cap_n, enter, tab, tty_toggle }; - -pub const Action = union(enum) { - move: struct { dx: f32, dy: f32 }, // pointer delta in screen pixels - click: struct { which: Click, down: bool }, - wheel: i32, // whole ticks, positive = wheel_down - key: Keytap, - rumble, -}; - -pub const Deck = struct { - rpad_on: bool = false, - rpad_x: f32 = 0, - rpad_y: f32 = 0, - lpad_on: bool = false, - lpad_y: f32 = 0, - scroll: f32 = 0, // fractional wheel ticks carried between motions - l2_held: bool = false, - r2_held: bool = false, - look_held: bool = false, - rpad_click: bool = false, // firm-press click state, per pad (hysteresis) - lpad_click: bool = false, - - pub fn feed(d: *Deck, in: Input, out: *[3]Action) usize { - var n: usize = 0; - switch (in) { - .button => |b| switch (b.idx) { - BTN_A => if (b.down) { - out[0] = .{ .key = .enter }; - n = 1; - }, - BTN_Y => if (b.down) { - out[0] = .{ .key = .tab }; - n = 1; - }, - BTN_DPAD_UP => if (b.down) { - out[0] = .{ .key = .cap_n }; - n = 1; - }, - BTN_DPAD_DOWN => if (b.down) { - out[0] = .{ .key = .n }; - n = 1; - }, - // any back paddle toggles the terminal (Ctrl-b), applied in gui.zig - BTN_L4, BTN_R4, BTN_L5, BTN_R5 => if (b.down) { - out[0] = .{ .key = .tty_toggle }; - n = 1; - }, - BTN_L1 => { - d.look_held = b.down; - out[0] = .{ .click = .{ .which = .look, .down = b.down } }; - n = 1; - if (b.down) { - out[1] = .rumble; - n = 2; - } - }, - else => {}, - }, - .axis => |a| { - if (a.idx != AXIS_L2 and a.idx != AXIS_R2) return 0; - const held = if (a.idx == AXIS_L2) &d.l2_held else &d.r2_held; - const which: Click = if (a.idx == AXIS_L2) .execute else .select; - if (!held.* and a.value >= TRIG_PRESS) { - held.* = true; - out[0] = .{ .click = .{ .which = which, .down = true } }; - n = 1; - if (which == .execute) { - out[1] = .rumble; - n = 2; - } - } else if (held.* and a.value <= TRIG_RELEASE) { - held.* = false; - out[0] = .{ .click = .{ .which = which, .down = false } }; - n = 1; - } - }, - .touch => |t| { - switch (t.pad) { - PAD_RIGHT => switch (t.phase) { - // a fresh touch only re-anchors — deltas start from the - // landing point, so the pointer never jumps to it - .down => { - d.rpad_on = true; - d.rpad_x = t.x; - d.rpad_y = t.y; - }, - .motion => if (d.rpad_on) { - out[n] = .{ .move = .{ - .dx = (t.x - d.rpad_x) * POINTER_SCALE, - .dy = (t.y - d.rpad_y) * PAD_Y_SIGN * POINTER_SCALE, - } }; - n += 1; - d.rpad_x = t.x; - d.rpad_y = t.y; - }, - .up => d.rpad_on = false, - }, - PAD_LEFT => switch (t.phase) { - .down => { - d.lpad_on = true; - d.lpad_y = t.y; - }, - .motion => if (d.lpad_on) { - d.scroll += (t.y - d.lpad_y) * PAD_Y_SIGN * SCROLL_SCALE; - d.lpad_y = t.y; - const ticks: i32 = @intFromFloat(d.scroll); - if (ticks != 0) { - d.scroll -= @floatFromInt(ticks); - out[n] = .{ .wheel = ticks }; - n += 1; - } - }, - .up => d.lpad_on = false, - }, - else => {}, - } - // a firm press on either pad is a click: right pad -> select - // (left button), left pad -> look (right button). The press - // rumbles as click feedback, mirroring the stock driver (SDL - // exposes no per-pad haptic, so it's a short whole-pad buzz). - if (t.pad == PAD_RIGHT or t.pad == PAD_LEFT) { - const which: Click = if (t.pad == PAD_RIGHT) .select else .look; - const held = if (t.pad == PAD_RIGHT) &d.rpad_click else &d.lpad_click; - if (t.phase == .up) { - if (held.*) { - held.* = false; - out[n] = .{ .click = .{ .which = which, .down = false } }; - n += 1; - } - } else if (!held.* and t.pressure >= PAD_CLICK_PRESS) { - held.* = true; - out[n] = .{ .click = .{ .which = which, .down = true } }; - n += 1; - out[n] = .rumble; - n += 1; - } else if (held.* and t.pressure <= PAD_CLICK_RELEASE) { - held.* = false; - out[n] = .{ .click = .{ .which = which, .down = false } }; - n += 1; - } - } - }, - } - return n; - } -}; diff --git a/src/file_pane.zig b/src/file_pane.zig new file mode 100644 index 00000000..4c02e983 --- /dev/null +++ b/src/file_pane.zig @@ -0,0 +1,196 @@ +//! File panes: everything a Pane does BECAUSE it has `file: ?File` set — the +//! disk read, the content swap undo/redo commits through, the tree-sitter +//! highlight window, and the two render passes only a file has (the line +//! number gutter and the syntax recolor). The rest of a file pane's behaviour +//! is the pane machinery in pardes.zig, which does not care what kind it is. +const std = @import("std"); +const pardes = @import("pardes.zig"); +const Pardes = pardes.Pardes; +const Pane = pardes.Pane; +const File = pardes.File; +const FileSnap = pardes.FileSnap; +const modal = @import("modal.zig"); +const look = @import("look.zig"); +const syntax = @import("syntax.zig"); +const tracy = @import("tracy.zig"); + +const SYNTAX_CONTEXT_BEFORE_ROWS: usize = 0; +const SYNTAX_CONTEXT_AFTER_ROWS: usize = 2; + +pub fn lineCount(content: []const u8) usize { + return std.mem.count(u8, content, "\n") + 1; +} + +pub fn open(p: *Pardes, id: usize, path: []const u8, line: usize) !*Pane { + const content = try look.readFile(p.gpa, path); + errdefer p.gpa.free(content); + const path_copy = try p.gpa.dupe(u8, path); + errdefer p.gpa.free(path_copy); + const pane = try p.newDocPane(id); + const total = lineCount(content); + const scroll: usize = if (line > 0 and line <= total) line - 1 else 0; + pane.file = .{ .path = path_copy, .content = content, .scroll = scroll }; + pane.cur_pinned = true; + pane.cur_row = @intCast(scroll); + return pane; +} + +pub fn setContent(p: *Pardes, f: *File, new: []u8) void { + p.gpa.free(f.content); + f.content = new; + f.syntax_dirty = true; +} + +/// undo/redo restores the selection recorded with the snapshot (helix keeps +/// selections in its history transactions), clamped: the content it was taken +/// against may be shorter than the one it is being restored onto. +pub fn restoreSnap(pane: *Pane, f: *File, snap: FileSnap) void { + const n = modal.lineCount(f.content); + const row: usize = @min(@as(usize, @intCast(@max(0, snap.cur_row))), if (n == 0) 0 else n - 1); + const llen = modal.lineSlice(f.content, row).len; + pane.cur_row = @intCast(row); + pane.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, snap.cur_col))), llen)); + pane.vsel = snap.vsel; + pane.msel.active = false; + pane.cur_pinned = true; + pane.sticky_col = -1; + pane.ensureCursorVisible(); +} + +/// re-highlight the visible window of any file whose syntax went stale +/// (edit, scroll, load) — visible-range-first so big files stay snappy +pub fn refreshHighlights(p: *Pardes) void { + const tz = tracy.zone(@src(), "refreshHighlights"); + defer tz.end(); + for (p.panes) |slot| { + const pane = slot orelse continue; + if (pane.file == null) continue; + const f = &pane.file.?; + if (!f.syntax_dirty) continue; + if (!p.colors_on) { + if (f.highlights.len > 0) p.gpa.free(f.highlights); + f.highlights = &.{}; + f.highlight_start = 0; + f.syntax_dirty = false; + continue; + } + const start_row = f.scroll -| SYNTAX_CONTEXT_BEFORE_ROWS; + const start = modal.lineStartOffset(f.content, start_row); + const end_row = start_row + SYNTAX_CONTEXT_BEFORE_ROWS + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS; + const end = @max(start, modal.lineStartOffset(f.content, end_row)); + const new_highlights = syntax.highlightFileRange(p.gpa, f.path, f.content, start, end) catch { + f.syntax_dirty = false; + continue; + }; + if (f.highlights.len > 0) p.gpa.free(f.highlights); + f.highlights = new_highlights; + f.highlight_start = if (f.highlights.len > 0) start else 0; + f.syntax_dirty = false; + } +} + +/// the body a file pane renders: `pane.rows` content lines from the scroll +/// offset, each behind its right-aligned line number, then cut by hscroll. +pub fn bodyText(arena: std.mem.Allocator, pane: *Pane, f: *File) ![]const u8 { + var out: std.ArrayList(u8) = .empty; + var flines = std.mem.splitScalar(u8, f.content, '\n'); + var skipped: usize = 0; + while (skipped < f.scroll) : (skipped += 1) { + if (flines.next() == null) break; + } + var i: usize = 0; + while (i < pane.rows) : (i += 1) { + if (i > 0) try out.append(arena, '\n'); + const abs: i32 = @as(i32, @intCast(f.scroll)) + @as(i32, @intCast(i)); + const row_start = out.items.len; + if (flines.next()) |text| { + var lbuf: [16]u8 = undefined; + // unsigned: {d} prints a leading '+' for signed ints + const lineno: usize = @intCast(abs + 1); + const prefix = std.fmt.bufPrint(&lbuf, "{d: >4} ", .{lineno}) catch " "; + var base: std.ArrayList(u8) = .empty; + try base.appendSlice(arena, prefix); + try base.appendSlice(arena, text); + try out.appendSlice(arena, base.items); + } + // horizontal scroll: cut hscroll columns after the gutter + if (pane.hscroll > 0) { + const cut_at = row_start + pardes.PREFIX_W; + if (out.items.len > cut_at) { + const cut = @min(@as(usize, @intCast(pane.hscroll)), out.items.len - cut_at); + out.replaceRangeAssumeCapacity(cut_at, cut, &.{}); + } + } + } + return out.items; +} + +/// line-number gutter: mute the first PREFIX_W columns. Cheap chrome, not +/// gated on colors_on; selection/cursor passes still win. The cursor row's +/// number takes the tag style (same row math as renderPane's cursor pass) so +/// the eye finds the current line. +pub fn drawGutter(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, active: bool) void { + const s = &p.surface; + const th = p.theme(); + const goff = pane.scroll(); + const gcur = pane.vt.screens.active.cursor; + const gcrow = if (pane.cur_pinned) pane.cur_row else @as(i32, @intCast(gcur.y)) + goff; + const cur_vr: i32 = if (active and !pane.tag_edit) gcrow - goff else -1; + var vr: u16 = 0; + while (vr < body_h) : (vr += 1) { + const on_cursor = @as(i32, vr) == cur_vr; + var c: u16 = 0; + while (c < pardes.PREFIX_W and c < tw) : (c += 1) { + const cell = s.at(tx + c, r.y + pardes.BOX_H + vr); + cell.default = false; // paints blank gutter rows too + if (on_cursor) { + cell.style.fg = .{ .rgb = th.tag_fg }; + cell.style.bg = .{ .rgb = th.tag_bg }; + } else cell.style.fg = .{ .rgb = th.lineno }; + } + } +} + +const SynStyle = struct { fg: [3]u8, bold: bool }; + +fn synStyle(p: *Pardes, sy: syntax.Syn) ?SynStyle { + return switch (sy) { + .none => null, + .keyword => .{ .fg = p.theme().kw, .bold = true }, + .string => .{ .fg = p.theme().str, .bold = false }, + .number => .{ .fg = p.theme().num, .bold = false }, + .comment => .{ .fg = p.theme().comment, .bold = true }, + }; +} + +/// syntax colors: recolor each content cell from its tree-sitter style byte; +/// content starts after the lineno gutter +pub fn recolorSyntax(p: *Pardes, pane: *Pane, f: *File, r: pardes.Rect, tx: u16, tw: u16, body_h: u16) void { + if (f.highlights.len == 0) return; + const s = &p.surface; + const tz_recolor = tracy.zone(@src(), "synRecolor"); + defer tz_recolor.end(); + var flines = std.mem.splitScalar(u8, f.content, '\n'); + var skip: usize = 0; + while (skip < f.scroll) : (skip += 1) { + if (flines.next() == null) break; + } + const hs: usize = @intCast(@max(0, pane.hscroll)); + var vr: u16 = 0; + while (vr < body_h) : (vr += 1) { + const line = flines.next() orelse break; + const base = @intFromPtr(line.ptr) - @intFromPtr(f.content.ptr); + var c: usize = 0; + while (hs + c < line.len and pardes.PREFIX_W + c < tw) : (c += 1) { + const idx = base + hs + c; + if (idx < f.highlight_start) continue; + const hidx = idx - f.highlight_start; + if (hidx >= f.highlights.len) continue; + const ss = synStyle(p, @enumFromInt(f.highlights[hidx])) orelse continue; + const cell = s.at(tx + @as(u16, @intCast(pardes.PREFIX_W + c)), r.y + pardes.BOX_H + vr); + if (cell.default) continue; + cell.style.fg = .{ .rgb = ss.fg }; + cell.style.bold = ss.bold; + } + } +} diff --git a/src/gui.zig b/src/gui.zig deleted file mode 100644 index 4fa5d20e..00000000 --- a/src/gui.zig +++ /dev/null @@ -1,3103 +0,0 @@ -//! The SDL3 GPU shell: owns an SDL window + event loop, translates SDL input -//! into core events, performs the core's effects (fork ptys, write them, -//! resize them — same duties as tty.zig, this is also native), and -//! rasterizes the core's Surface: one instanced quad per cell, glyphs from an -//! stb_truetype R8 atlas. Test modes: PARDES_TEST_GRID=1 is headless (no SDL, -//! stdin escape sequences in, text grid frames out); PARDES_TEST=1 keeps the -//! real renderer, drives input from stdin, and captures frames to PPM. -//! -//! The same file is the BROWSER shell (-Dplatform=web, wasm32-emscripten): -//! WebGL2 instead of the GPU device, an embedded dump instead of ptys, and a -//! requestAnimationFrame callback instead of a blocking loop. Comptime -//! branches on is_emscripten, the way the stdlib branches on os.tag. -const std = @import("std"); -const builtin = @import("builtin"); -const posix = std.posix; -const libc = std.c; -const vaxis = @import("vaxis"); // test modes only: the stdin escape-seq parser -const ghostty_vt = @import("ghostty-vt"); // 256-color palette for .index cells -const pardes = @import("pardes.zig"); -const look = @import("look.zig"); -const deck = @import("deck.zig"); -const crt = @import("crt.zig"); - -const is_emscripten = builtin.os.tag == .emscripten; - -pub const c = @cImport({ - @cDefine("SDL_DISABLE_OLD_NAMES", "1"); - @cInclude("SDL3/SDL.h"); - if (is_emscripten) @cInclude("GLES3/gl3.h"); - @cInclude("font.h"); -}); - -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; -extern "c" fn emscripten_get_element_css_size(target: [*:0]const u8, width: *f64, height: *f64) c_int; -const EMSCRIPTEN_RESULT_SUCCESS: c_int = 0; - -// 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)); - -const log = std.log.scoped(.gui); - -const font_ttf = @embedFile("AdwaitaMono-Regular.ttf"); -const vert_spv = if (is_emscripten) "" else @embedFile("ui.vert.spv"); -const frag_spv = if (is_emscripten) "" else @embedFile("ui.frag.spv"); -const overlay_vert_spv = if (is_emscripten) "" else @embedFile("overlay.vert.spv"); -const overlay_frag_spv = if (is_emscripten) "" else @embedFile("overlay.frag.spv"); -const crt_vert_spv = if (is_emscripten) "" else @embedFile("crt.vert.spv"); -const crt_frag_spv = if (is_emscripten) "" else @embedFile("crt.frag.spv"); -// web: GL ES shader SOURCES, compiled by WebGL at init (no SPIR-V there) -const vert_glsl_es = if (is_emscripten) @embedFile("ui.vert.es.glsl") else ""; -const frag_glsl_es = if (is_emscripten) @embedFile("ui.frag.es.glsl") else ""; -const overlay_vert_glsl_es = if (is_emscripten) @embedFile("overlay.vert.es.glsl") else ""; -const overlay_frag_glsl_es = if (is_emscripten) @embedFile("overlay.frag.es.glsl") else ""; -// web: no ptys — the whole state replays from a dump embedded at build time -const embedded_dump = if (is_emscripten) @embedFile("embedded.dump.zon") else ""; - -// default page colors — the same "terminal native dark" the prototype used -const bg_default = [3]u8{ 18, 18, 18 }; -const fg_default = [3]u8{ 204, 204, 204 }; - -// the plan9 arrow cursor, bytes verbatim from 9front /sys/src/9/port/ -// devmouse.c (Cursor arrow, 16x16 MSB-first): clr is the white outline, set -// the black ink, offset {-1,-1} puts the hot point at (1,1) in the bitmap. -// SDL_CreateCursor's scheme: data=1,mask=1 black; data=0,mask=1 white; -// mask=0 transparent — so data = set and mask = set|clr. -const p9_arrow_clr = [32]u8{ - 0xFF, 0xFF, 0x80, 0x01, 0x80, 0x02, 0x80, 0x0C, - 0x80, 0x10, 0x80, 0x10, 0x80, 0x08, 0x80, 0x04, - 0x80, 0x02, 0x80, 0x01, 0x80, 0x02, 0x8C, 0x04, - 0x92, 0x08, 0x91, 0x10, 0xA0, 0xA0, 0xC0, 0x40, -}; -const p9_arrow_set = [32]u8{ - 0x00, 0x00, 0x7F, 0xFE, 0x7F, 0xFC, 0x7F, 0xF0, - 0x7F, 0xE0, 0x7F, 0xE0, 0x7F, 0xF0, 0x7F, 0xF8, - 0x7F, 0xFC, 0x7F, 0xFE, 0x7F, 0xFC, 0x73, 0xF8, - 0x61, 0xF0, 0x60, 0xE0, 0x40, 0x40, 0x00, 0x00, -}; -const p9_arrow_mask = blk: { - var m: [32]u8 = undefined; - for (&m, p9_arrow_set, p9_arrow_clr) |*b, s, cl| b.* = s | cl; - break :blk m; -}; - -// web: glyphs raster at 1.5x and the canvas backs at CSS×1.5 so text stays -// sharp on scaled canvases (the prototype's -Dweb-render-scale, which never -// shipped non-default — hardcoded here instead of a build option). -const web_render_scale: f32 = 1.5; - -// 2048 fits ~2500 glyphs at the 2x native cell (~20x40); web needs it too -const atlas_w: u32 = 2048; -const atlas_h: u32 = 2048; -const Slot = struct { u: u32, v: u32 }; - -const max_touch_points: usize = 16; -const max_touch_trail_points: usize = 8; -const overlay_circle_vertices: usize = 16 * 3; -const touch_click_flash_max_frames: u8 = 14; -const touch_click_flash_vertices: usize = 1400; -const touch_scroll_tick: f32 = 0.02; -/// Web tap-vs-scroll heuristic, measured after normalized coordinates have -/// been mapped into backing pixels (12px = 8 CSS px at web_render_scale). -const touch_tap_slop_px: f32 = 12.0; -const max_overlay_vertices: usize = touch_click_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices); - -// One instance per cell; the vertex shader expands it to a 2-triangle quad -// with gl_VertexIndex. Coords are NDC (y up), uv into the atlas, colors 0..1. -const CellInstance = extern struct { - x0: f32, - y0: f32, - x1: f32, - y1: f32, - u0: f32, - v0: f32, - u1: f32, - v1: f32, - fr: f32, - fg: f32, - fb: f32, - br: f32, - bg: f32, - bb: f32, -}; - -const OverlayVertex = extern struct { x: f32, y: f32, r: f32, g: f32, b: f32, a: f32 }; -const OverlayColor = struct { r: f32, g: f32, b: f32, a: f32 }; - -// ---- touch: per-finger tracking + shared scroll/tap machines ---- - -const TouchSample = struct { x: f32 = 0, y: f32 = 0, pressure: f32 = 1 }; - -const TouchPoint = struct { - active: bool = false, - id: u64 = 0, - x: f32 = 0, - y: f32 = 0, - pressure: f32 = 1, - trail: [max_touch_trail_points]TouchSample = @splat(.{}), - trail_len: usize = 0, - trail_next: usize = 0, -}; - -const TouchNormPoint = struct { x: f32 = 0, y: f32 = 0 }; - -/// A finger event with SDL's normalized 0..1 coordinates — the one shape both -/// real SDL_EVENT_FINGER_* and the synthetic test OSC feed into the machine. -const Finger = struct { kind: enum { down, motion, up, cancel }, id: u64, x: f32, y: f32, pressure: f32 }; - -const Touch = struct { - points: [max_touch_points]TouchPoint = @splat(.{}), - scroll: struct { - active: bool = false, - ids: [2]u64 = .{ 0, 0 }, - accum_y: f32 = 0, - scrolled: bool = false, - last_center: TouchNormPoint = .{}, - } = .{}, - // Web policy: one primary finger is either a body tap/scroll or a latched - // left-mouse gesture when it begins on layout chrome. A second simultaneous - // finger cancels a body tap rather than producing a click. - single: struct { - active: bool = false, - id: u64 = 0, - start: TouchNormPoint = .{}, - last: TouchNormPoint = .{}, - accum_y: f32 = 0, - scrolling: bool = false, - mouse_drag: bool = false, - multitouch: bool = false, - } = .{}, - click_count: u32 = 0, - click_button: pardes.Mouse.Button = .middle, - click_flash_frames: u8 = 0, - - fn findSlot(t: *const Touch, id: u64) ?usize { - for (&t.points, 0..) |*tp, i| if (tp.active and tp.id == id) return i; - return null; - } - - fn updatePoint(t: *Touch, f: Finger) void { - switch (f.kind) { - .up, .cancel => if (t.findSlot(f.id)) |i| { - t.points[i].active = false; - }, - .down, .motion => { - const idx = t.findSlot(f.id) orelse blk: { - for (&t.points, 0..) |*tp, i| if (!tp.active) break :blk i; - break :blk @as(usize, @intCast(f.id % max_touch_points)); - }; - const tp = &t.points[idx]; - if (!tp.active or tp.id != f.id) tp.* = .{ .active = true, .id = f.id }; - tp.x = f.x; - tp.y = f.y; - tp.pressure = f.pressure; - tp.trail[tp.trail_next] = .{ .x = f.x, .y = f.y, .pressure = f.pressure }; - tp.trail_next = (tp.trail_next + 1) % max_touch_trail_points; - if (tp.trail_len < max_touch_trail_points) tp.trail_len += 1; - }, - } - } - - fn activeCount(t: *const Touch) usize { - var count: usize = 0; - for (&t.points) |*tp| count += @intFromBool(tp.active); - return count; - } - - fn pairCenter(t: *const Touch, ids: [2]u64) ?TouchNormPoint { - var sum: TouchNormPoint = .{}; - for (ids) |id| { - const i = t.findSlot(id) orelse return null; - sum.x += t.points[i].x; - sum.y += t.points[i].y; - } - return .{ .x = sum.x * 0.5, .y = sum.y * 0.5 }; - } - - /// keep scroll state in sync with the set of active fingers: exactly two - /// active fingers begin (or re-key) a pair; anything else resets it. - fn syncPair(t: *Touch) void { - var ids: [2]u64 = .{ 0, 0 }; - var count: usize = 0; - for (&t.points) |*tp| { - if (!tp.active) continue; - if (count >= 2) { - t.scroll = .{}; - return; - } - ids[count] = tp.id; - count += 1; - } - if (count != 2) { - t.scroll = .{}; - return; - } - if (ids[0] > ids[1]) std.mem.swap(u64, &ids[0], &ids[1]); - if (t.scroll.active and t.scroll.ids[0] == ids[0] and t.scroll.ids[1] == ids[1]) return; - t.scroll = .{ .active = true, .ids = ids, .last_center = t.pairCenter(ids) orelse .{} }; - } - - /// finger lift: a pair that never scrolled is a two-finger TAP — returns - /// its center (computed with the lifting finger's final position). - fn finishPair(t: *Touch, f: Finger) ?TouchNormPoint { - if (!t.scroll.active or (t.scroll.ids[0] != f.id and t.scroll.ids[1] != f.id)) return null; - const tap = f.kind == .up and !t.scroll.scrolled; - var center: ?TouchNormPoint = null; - if (tap) { - var sum: TouchNormPoint = .{}; - var ok = true; - for (t.scroll.ids) |id| { - if (id == f.id) { - sum.x += f.x; - sum.y += f.y; - } else if (t.findSlot(id)) |i| { - sum.x += t.points[i].x; - sum.y += t.points[i].y; - } else ok = false; - } - center = if (ok) .{ .x = sum.x * 0.5, .y = sum.y * 0.5 } else t.scroll.last_center; - } - t.scroll = .{}; - return center; - } - - fn noteMotion(t: *Touch, f: Finger) ?struct { center: TouchNormPoint, ticks: i32, delta_y: f32 } { - if (!t.scroll.active or (t.scroll.ids[0] != f.id and t.scroll.ids[1] != f.id)) return null; - const center = t.pairCenter(t.scroll.ids) orelse t.scroll.last_center; - const delta_y = center.y - t.scroll.last_center.y; - t.scroll.last_center = center; - t.scroll.accum_y += delta_y; - const ticks = takeScrollTicks(&t.scroll.accum_y); - if (ticks == 0) return null; - t.scroll.scrolled = true; - return .{ .center = center, .ticks = ticks, .delta_y = @as(f32, @floatFromInt(ticks)) * touch_scroll_tick }; - } - - fn beginSingle(t: *Touch, f: Finger, mouse_drag: bool) void { - if (t.single.active) { - if (t.single.id != f.id) t.single.multitouch = true; - return; - } - if (t.activeCount() != 1) return; - const point = TouchNormPoint{ .x = f.x, .y = f.y }; - t.single = .{ .active = true, .id = f.id, .start = point, .last = point, .mouse_drag = mouse_drag }; - } - - fn noteSingleMotion(t: *Touch, f: Finger, win_w: f32, win_h: f32) ?struct { center: TouchNormPoint, ticks: i32, delta_y: f32 } { - if (!t.single.active or t.single.id != f.id or t.single.multitouch) return null; - const center = TouchNormPoint{ .x = f.x, .y = f.y }; - var delta_y = center.y - t.single.last.y; - t.single.last = center; - if (!t.single.scrolling) { - const dx = (center.x - t.single.start.x) * win_w; - const dy = (center.y - t.single.start.y) * win_h; - if (dx * dx + dy * dy < touch_tap_slop_px * touch_tap_slop_px) return null; - // Once the pointer leaves the tap slop it can never click. Include - // all travel since finger-down so the first scroll tick is prompt. - t.single.scrolling = true; - delta_y = center.y - t.single.start.y; - } - t.single.accum_y += delta_y; - const ticks = takeScrollTicks(&t.single.accum_y); - if (ticks == 0) return null; - return .{ .center = center, .ticks = ticks, .delta_y = @as(f32, @floatFromInt(ticks)) * touch_scroll_tick }; - } - - fn finishSingle(t: *Touch, f: Finger, win_w: f32, win_h: f32) ?TouchNormPoint { - if (!t.single.active or t.single.id != f.id) return null; - const point = TouchNormPoint{ .x = f.x, .y = f.y }; - const dx = (point.x - t.single.start.x) * win_w; - const dy = (point.y - t.single.start.y) * win_h; - const left_slop = dx * dx + dy * dy >= touch_tap_slop_px * touch_tap_slop_px; - const tap = f.kind == .up and !left_slop and !t.single.scrolling and !t.single.mouse_drag and !t.single.multitouch; - t.single = .{}; - return if (tap) point else null; - } -}; - -fn takeScrollTicks(accum: *f32) i32 { - var ticks: i32 = 0; - while (accum.* >= touch_scroll_tick) : (ticks += 1) accum.* -= touch_scroll_tick; - while (accum.* <= -touch_scroll_tick) : (ticks -= 1) accum.* += touch_scroll_tick; - return ticks; -} - -/// The SDL boundary owns touch policy: a web body uses one-finger natural -/// scrolling and tap-as-LOOK, while layout chrome latches a left-mouse drag; -/// native keeps two-finger scroll/tap-as-execute. Both feed the same core mouse -/// events and coordinate mapping. -fn handleFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void { - std.debug.assert(cell_w > 0 and cell_h > 0); - if (is_emscripten) - return handleSingleFinger(t, core, f, win_w, win_h, cell_w, cell_h); - return handlePairFinger(t, core, f, win_w, win_h, cell_w, cell_h); -} - -fn handleSingleFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void { - switch (f.kind) { - .down => { - t.updatePoint(f); - const point = TouchNormPoint{ .x = f.x, .y = f.y }; - const col = normCell(point.x, win_w, cell_w); - const row = normCell(point.y, win_h, cell_h); - const chrome = webChromeTarget(core, col, row); - t.beginSingle(f, chrome != null); - if (chrome) |target| - core.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = target.col, .row = target.row } }); - }, - .motion => { - t.updatePoint(f); - if (t.single.active and t.single.id == f.id and t.single.mouse_drag) { - core.update(.{ .mouse = .{ - .button = .left, - .kind = .drag, - .col = normCell(f.x, win_w, cell_w), - .row = normCell(f.y, win_h, cell_h), - } }); - return; - } - const res = t.noteSingleMotion(f, win_w, win_h) orelse return; - emitTouchScroll(core, res.center, res.ticks, res.delta_y, win_w, win_h, cell_w, cell_h); - }, - .up, .cancel => { - const mouse_drag = t.single.active and t.single.id == f.id and t.single.mouse_drag; - const tap = t.finishSingle(f, win_w, win_h); - t.updatePoint(f); - if (mouse_drag) { - core.update(.{ .mouse = .{ - .button = .left, - .kind = .release, - .col = normCell(f.x, win_w, cell_w), - .row = normCell(f.y, win_h, cell_h), - } }); - } else if (tap) |point| emitTouchClick(t, core, point, .right, win_w, win_h, cell_w, cell_h); - }, - } -} - -const WebChromeTarget = struct { col: u16, row: u16 }; - -/// Fat-finger hit test for web layout chrome. A gesture is classified once at -/// finger-down, then never changes into scrolling. Handle coordinates are -/// snapped onto the core's one-cell resize edge; body rows remain untouched. -fn webChromeTarget(core: *const pardes.Pardes, col: u16, row: u16) ?WebChromeTarget { - if (row < pardes.TOPBAR_H) return .{ .col = col, .row = row }; - - // The move box wins over a horizontal handle when a pane is tag-only, - // matching the core's own mouse hit-test priority. - for (core.panes, 0..) |slot, id| { - if (slot == null) continue; - const rect = core.rects[id]; - if (row == rect.y and col >= rect.x and col < rect.x + rect.w and col < rect.x + pardes.GUTTER) - return .{ .col = col, .row = rect.y }; - } - - for (0..core.ncol -| 1) |column| { - const handle = core.col_x[column] + core.col_w[column] -| 1; - if (col == handle) return .{ .col = handle, .row = row }; - } - for (0..core.ncol) |column| { - if (col < core.col_x[column] or col >= core.col_x[column] + core.col_w[column]) continue; - for (0..core.col_n[column] -| 1) |index| { - const rect = core.rects[core.col_terms[column][index]]; - const handle = rect.y + rect.h -| 1; - if (row == handle) return .{ .col = col, .row = handle }; - } - } - - // The remainder of each tag row is tag editing, not a scrollable body. - for (core.panes, 0..) |slot, id| { - if (slot == null) continue; - const rect = core.rects[id]; - if (row == rect.y and col >= rect.x and col < rect.x + rect.w) - return .{ .col = col, .row = rect.y }; - } - - // One-cell tolerance around thin separators, after exact tag rows have had - // first refusal so a fat-finger tag edit never turns into a resize. - for (0..core.ncol -| 1) |column| { - const handle = core.col_x[column] + core.col_w[column] -| 1; - if (cellDistance(col, handle) == 1) return .{ .col = handle, .row = row }; - } - for (0..core.ncol) |column| { - if (col < core.col_x[column] or col >= core.col_x[column] + core.col_w[column]) continue; - for (0..core.col_n[column] -| 1) |index| { - const rect = core.rects[core.col_terms[column][index]]; - const handle = rect.y + rect.h -| 1; - if (cellDistance(row, handle) == 1) return .{ .col = col, .row = handle }; - } - } - return null; -} - -fn cellDistance(a: u16, b: u16) u16 { - return if (a > b) a - b else b - a; -} - -fn handlePairFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void { - switch (f.kind) { - .down => { - t.updatePoint(f); - t.syncPair(); - }, - .motion => { - const had_pair = t.scroll.active; - t.updatePoint(f); - t.syncPair(); - if (!had_pair) return; - const res = t.noteMotion(f) orelse return; - emitTouchScroll(core, res.center, res.ticks, res.delta_y, win_w, win_h, cell_w, cell_h); - }, - .up, .cancel => { - const tap_center = t.finishPair(f); - t.updatePoint(f); - t.syncPair(); - if (tap_center) |center| emitTouchClick(t, core, center, .middle, win_w, win_h, cell_w, cell_h); - }, - } -} - -fn emitTouchScroll(core: *pardes.Pardes, center: TouchNormPoint, ticks: i32, delta_y: f32, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void { - const col = normCell(center.x, win_w, cell_w); - const row = normCell(center.y, win_h, cell_h); - const button: pardes.Mouse.Button = if (ticks > 0) .wheel_up else .wheel_down; - var remaining: u32 = @abs(ticks); - while (remaining > 0) : (remaining -= 1) - core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = col, .row = row } }); - core.update(.{ .touch_scroll = delta_y }); -} - -fn emitTouchClick(t: *Touch, core: *pardes.Pardes, point: TouchNormPoint, button: pardes.Mouse.Button, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void { - t.click_count +%= 1; - t.click_button = button; - t.click_flash_frames = touch_click_flash_max_frames; - const col = normCell(point.x, win_w, cell_w); - const row = normCell(point.y, win_h, cell_h); - core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = col, .row = row } }); - core.update(.{ .mouse = .{ .button = button, .kind = .release, .col = col, .row = row } }); -} - -fn normCell(norm: f32, win: f32, cell: f32) u16 { - const px = std.math.clamp(norm, 0.0, 1.0) * win; - return @intFromFloat(@max(0, @floor(px / cell))); -} - -// ---- pty plumbing: reader threads feed a mutex-protected queue ---- - -const Pty = struct { fd: c_int, pid: libc.pid_t }; - -const Msg = union(enum) { - output: struct { pane: u8, gen: u32, bytes: []u8 }, - eof: struct { pane: u8, gen: u32, fd: c_int }, - /// a language query finished on its own thread (see lspThread) - lsp: struct { id: u32, rows: []u8 }, -}; - -/// One language query, owned by the thread running it — the gui twin of -/// tty.zig's LspJob, and copied for the same reason: the core edits on. -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 Queue = struct { - gpa: std.mem.Allocator, - sdl_wake: bool, // wake a blocking SDL_WaitEventTimeout on cross-thread push - // 0.16 has no std.Thread.Mutex; critical sections here are a few - // instructions, so spinning on the lock-free std.atomic.Mutex is enough - mutex: std.atomic.Mutex = .unlocked, - items: std.ArrayList(Msg) = .empty, - closed: bool = false, - - fn lock(q: *Queue) void { - while (!q.mutex.tryLock()) std.atomic.spinLoopHint(); - } - - fn push(q: *Queue, m: Msg) void { - q.lock(); - if (q.closed) { - q.mutex.unlock(); - switch (m) { - .output => |o| q.gpa.free(o.bytes), - .lsp => |l| q.gpa.free(l.rows), - .eof => {}, - } - return; - } - q.items.append(q.gpa, m) catch { - q.mutex.unlock(); - switch (m) { - .output => |o| q.gpa.free(o.bytes), - .lsp => |l| q.gpa.free(l.rows), - .eof => {}, - } - return; - }; - q.mutex.unlock(); - if (q.sdl_wake) { - var sev = std.mem.zeroes(c.SDL_Event); - sev.type = c.SDL_EVENT_USER; - _ = c.SDL_PushEvent(&sev); - } - } - - /// take the pending messages (caller iterates + deinits outside the lock) - fn take(q: *Queue) std.ArrayList(Msg) { - q.lock(); - defer q.mutex.unlock(); - const local = q.items; - q.items = .empty; - return local; - } - - fn close(q: *Queue) void { - q.lock(); - defer q.mutex.unlock(); - q.closed = true; - for (q.items.items) |m| switch (m) { - .output => |o| q.gpa.free(o.bytes), - .lsp => |l| q.gpa.free(l.rows), - .eof => {}, - }; - q.items.deinit(q.gpa); - } -}; - -fn readPtyThread(gpa: std.mem.Allocator, fd: c_int, pane: u8, gen: u32, q: *Queue) void { - var buf: [0x10000]u8 = undefined; - while (true) { - const n = libc.read(fd, &buf, buf.len); - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - break; // EIO when the child exits: treat as EOF - } - if (n == 0) break; - const bytes = gpa.dupe(u8, buf[0..@intCast(n)]) catch break; - q.push(.{ .output = .{ .pane = pane, .gen = gen, .bytes = bytes } }); - } - q.push(.{ .eof = .{ .pane = pane, .gen = gen, .fd = fd } }); -} - -fn spawnReader(gpa: std.mem.Allocator, pt: Pty, pane: u8, gen: u32, q: *Queue) void { - const th = std.Thread.spawn(.{}, readPtyThread, .{ gpa, pt.fd, pane, gen, q }) catch return; - th.detach(); -} - -/// Answer a language query off the render loop and push the rows to the queue -/// — the async execution model, spelled in the plumbing this shell already has -/// (a detached thread and the mutex queue the pty readers use). -fn lspThread(gpa: std.mem.Allocator, job: *LspJob, q: *Queue) 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; - q.push(.{ .lsp = .{ .id = job.id, .rows = rows } }); -} - -/// Copy the query out of the core and hand it to a thread. A detached thread -/// per query is fine at this rate: one keystroke, one query, and the queue -/// already tolerates a late push after close. -fn spawnLsp(core: *pardes.Pardes, gpa: std.mem.Allocator, q: *Queue, e: anytype) void { - const pane = core.panes[e.pane] orelse return; - // 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 return; - job.* = .{ - .id = e.id, - .kind = e.kind, - .offset = e.offset, - .path = gpa.dupe(u8, if (f) |ff| ff.path else "") catch { - gpa.destroy(job); - return; - }, - .source = gpa.dupeZ(u8, if (f) |ff| ff.content else "") catch { - gpa.free(job.path); - gpa.destroy(job); - return; - }, - .arg = gpa.dupe(u8, e.arg.slice()) catch { - gpa.free(job.path); - gpa.free(job.source); - gpa.destroy(job); - return; - }, - .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); - return; - }, - }; - const th = std.Thread.spawn(.{}, lspThread, .{ gpa, job, q }) catch { - job.free(gpa); - return; - }; - th.detach(); -} - -// ---- the renderer state ---- - -const Gui = struct { - window: *c.SDL_Window, - device: *c.SDL_GPUDevice, - swapchain_format: c.SDL_GPUTextureFormat, - pipeline: *c.SDL_GPUGraphicsPipeline, - overlay_pipeline: *c.SDL_GPUGraphicsPipeline, - crt_pipeline: *c.SDL_GPUGraphicsPipeline, - atlas_tex: *c.SDL_GPUTexture, - atlas_sampler: *c.SDL_GPUSampler, - atlas_xfer: *c.SDL_GPUTransferBuffer, - vbuf: ?*c.SDL_GPUBuffer = null, - vxfer: ?*c.SDL_GPUTransferBuffer = null, - vbuf_cells: u32 = 0, - overlay_vbuf: *c.SDL_GPUBuffer, - overlay_vxfer: *c.SDL_GPUTransferBuffer, - overlay_vertices: []OverlayVertex, - - font: *c.UIFont, - scale: f32, - cell_w: u32, - cell_h: u32, - ascent: i32, - - // glyph atlas: CPU staging bitmap + codepoint → texel slot, pen-walk alloc - atlas_stage: []u8, - glyphs: std.AutoHashMap(u32, Slot), - pen_x: u32 = 0, - pen_y: u32 = 0, - atlas_dirty: bool = true, - space_slot: Slot = .{ .u = 0, .v = 0 }, - - touch: Touch = .{}, - mouse_x: f32 = 0, - mouse_y: f32 = 0, - live_ctrl: bool = false, - live_alt: bool = false, - - // Crt builtin: the scene renders into this texture, then a fullscreen - // CRT pass warps it onto the real target - scene_tex: ?*c.SDL_GPUTexture = null, - scene_tex_w: u32 = 0, - scene_tex_h: u32 = 0, - - // PARDES_TEST frame capture (render into an offscreen target, dump PPM) - capture: bool = false, - capture_dir: []const u8 = "", - capture_tex: ?*c.SDL_GPUTexture = null, - capture_tex_w: u32 = 0, - capture_tex_h: u32 = 0, - capture_xfer: ?*c.SDL_GPUTransferBuffer = null, - capture_xfer_size: u32 = 0, - - // steamdeck: gamepad-driven virtual mouse cursor (pixel position) - gamepad: ?*c.SDL_Gamepad = null, - pad_x: f32 = 0, - pad_y: f32 = 0, - pad_scroll: f32 = 0, // fractional vertical wheel ticks (left stick) - pad_scroll_h: f32 = 0, // fractional horizontal wheel ticks (left stick) - deck: deck.Deck = .{}, - - // web: WebGL2 handles (plain GL ints) + the CPU-side instance staging - gl_program: c_uint = 0, - gl_overlay_program: c_uint = 0, - gl_atlas_tex: c_uint = 0, - gl_vbo: c_uint = 0, - gl_overlay_vbo: c_uint = 0, - gl_u_atlas: c_int = -1, - gl_instances: []CellInstance = &.{}, -}; - -/// A cell's on-screen rect. Native: exactly cell_w×cell_h at (0,0). Web: the -/// canvas resize and the grid resize land on different frames, so the grid is -/// scaled to fit and centered (letterboxed) instead of clipping. -const CellLayout = struct { w: f32, h: f32, x_off: f32, y_off: f32 }; - -fn fixedCellLayout(g: *const Gui) CellLayout { - return .{ .w = @floatFromInt(g.cell_w), .h = @floatFromInt(g.cell_h), .x_off = 0, .y_off = 0 }; -} - -fn cellLayout(g: *const Gui, cols: u16, rows: u16, sw: u32, sh: u32) CellLayout { - const fixed = fixedCellLayout(g); - if (!is_emscripten) return fixed; - if (cols == 0 or rows == 0) return fixed; - const grid_w = @as(f32, @floatFromInt(cols)) * fixed.w; - const grid_h = @as(f32, @floatFromInt(rows)) * fixed.h; - const win_w: f32 = @floatFromInt(sw); - const win_h: f32 = @floatFromInt(sh); - const scale = @min(win_w / grid_w, win_h / grid_h); - return .{ - .w = fixed.w * scale, - .h = fixed.h * scale, - .x_off = @max(0, (win_w - grid_w * scale) * 0.5), - .y_off = @max(0, (win_h - grid_h * scale) * 0.5), - }; -} - -// ===================================================================== -// entry -// ===================================================================== - -pub const run = if (is_emscripten) runWeb else runNative; - -fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { - const io = init.io; - const gpa = init.gpa; - const env = init.environ_map; - if (env.get("PARDES_TEST_GRID") != null) return runGrid(init, opts_in); - - const test_mode = env.get("PARDES_TEST") != null; - const capture_dir = env.get("PARDES_TEST_CAPTURE_DIR"); - if (test_mode and capture_dir == null) { - log.err("PARDES_TEST requires PARDES_TEST_CAPTURE_DIR", .{}); - return error.SdlInit; - } - - // The trackpads only exist through SDL's built-in HIDAPI Steam Deck - // driver (it registers the two touchpads and disables "lizard mode"). - // It's default-on on Linux; pin it so intent is explicit. NOTE: this is - // NOT enough in Game Mode — Steam Input there hands the app a - // touchpad-less virtual gamepad instead of the real Neptune controller, - // so the pads go dead no matter what the app does. The only fix is to - // set "Disable Steam Input" on pardes (Steam -> Properties -> Controller), - // after which the real controller enumerates and the touchpad events flow. - _ = c.SDL_SetHint("SDL_JOYSTICK_HIDAPI", "1"); // SDL_HINT_JOYSTICK_HIDAPI - _ = c.SDL_SetHint("SDL_JOYSTICK_HIDAPI_STEAMDECK", "1"); // ..._STEAMDECK - if (!c.SDL_Init(c.SDL_INIT_VIDEO | c.SDL_INIT_GAMEPAD)) { - log.err("SDL_Init: {s}", .{c.SDL_GetError()}); - return error.SdlInit; - } - var win_flags: c.SDL_WindowFlags = c.SDL_WINDOW_RESIZABLE; - if (!test_mode) win_flags |= c.SDL_WINDOW_HIGH_PIXEL_DENSITY; - const window = c.SDL_CreateWindow("pardes", 1120, 720, win_flags) orelse { - log.err("SDL_CreateWindow: {s}", .{c.SDL_GetError()}); - return error.SdlInit; - }; - if (c.SDL_CreateCursor(&p9_arrow_set, &p9_arrow_mask, 16, 16, 1, 1)) |cur| { - _ = c.SDL_SetCursor(cur); - } else log.err("SDL_CreateCursor: {s}", .{c.SDL_GetError()}); - // fullscreen deck use: keep the pointer inside the window. Absolute - // coords stay (no relative mode — clicks need positions). - if (!test_mode) _ = c.SDL_SetWindowMouseGrab(window, true); - const device = c.SDL_CreateGPUDevice(c.SDL_GPU_SHADERFORMAT_SPIRV, true, null) orelse { - log.err("SDL_CreateGPUDevice: {s}", .{c.SDL_GetError()}); - return error.SdlInit; - }; - if (!c.SDL_ClaimWindowForGPUDevice(device, window)) { - log.err("ClaimWindowForGPUDevice: {s}", .{c.SDL_GetError()}); - return error.SdlInit; - } - // present mode: PARDES_SDL_PRESENT env override, else immediate → mailbox → vsync - const present_mode: c.SDL_GPUPresentMode = blk: { - if (env.get("PARDES_SDL_PRESENT")) |raw| { - const want: ?c.SDL_GPUPresentMode = if (std.ascii.eqlIgnoreCase(raw, "immediate")) - c.SDL_GPU_PRESENTMODE_IMMEDIATE - else if (std.ascii.eqlIgnoreCase(raw, "mailbox")) - c.SDL_GPU_PRESENTMODE_MAILBOX - else if (std.ascii.eqlIgnoreCase(raw, "vsync")) - c.SDL_GPU_PRESENTMODE_VSYNC - else - null; - if (want) |mode| if (c.SDL_WindowSupportsGPUPresentMode(device, window, mode)) break :blk mode; - } - for ([_]c.SDL_GPUPresentMode{ c.SDL_GPU_PRESENTMODE_IMMEDIATE, c.SDL_GPU_PRESENTMODE_MAILBOX, c.SDL_GPU_PRESENTMODE_VSYNC }) |mode| { - if (c.SDL_WindowSupportsGPUPresentMode(device, window, mode)) break :blk mode; - } - break :blk c.SDL_GPU_PRESENTMODE_VSYNC; - }; - if (!test_mode) _ = c.SDL_SetGPUAllowedFramesInFlight(device, 1); - _ = c.SDL_SetGPUSwapchainParameters(device, window, c.SDL_GPU_SWAPCHAINCOMPOSITION_SDR, present_mode); - const swapchain_format = c.SDL_GetGPUSwapchainTextureFormat(device, window); - - // ---- font + cell metrics ---- - const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)) orelse { - log.err("ui_font_new failed", .{}); - return error.FontInit; - }; - const px: f32 = 27.0; - const scale = c.ui_font_scale_for_height(font, px); - var cw: c_int = 10; - var chh: c_int = 20; - var asc: c_int = 16; - c.ui_font_cell_metrics(font, scale, &cw, &chh, &asc); - const cell_w: u32 = @intCast(@max(cw, 1)); - const cell_h: u32 = @intCast(@max(chh, 1)); - if (test_mode) { - const cols = envU16(env, "PARDES_TEST_COLS") orelse 80; - const rows = envU16(env, "PARDES_TEST_ROWS") orelse 24; - _ = c.SDL_SetWindowSize(window, @intCast(cols * cell_w), @intCast(rows * cell_h)); - _ = c.SDL_SyncWindow(window); - } - - // ---- glyph atlas (R8) + pipelines ---- - var tex_info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); - tex_info.type = c.SDL_GPU_TEXTURETYPE_2D; - tex_info.format = c.SDL_GPU_TEXTUREFORMAT_R8_UNORM; - tex_info.usage = c.SDL_GPU_TEXTUREUSAGE_SAMPLER; - tex_info.width = atlas_w; - tex_info.height = atlas_h; - tex_info.layer_count_or_depth = 1; - tex_info.num_levels = 1; - tex_info.sample_count = c.SDL_GPU_SAMPLECOUNT_1; - const atlas_tex = c.SDL_CreateGPUTexture(device, &tex_info) orelse return error.GpuCreate; - - var samp_info = std.mem.zeroes(c.SDL_GPUSamplerCreateInfo); - samp_info.min_filter = c.SDL_GPU_FILTER_LINEAR; - samp_info.mag_filter = c.SDL_GPU_FILTER_LINEAR; - samp_info.mipmap_mode = c.SDL_GPU_FILTER_LINEAR; - samp_info.address_mode_u = c.SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE; - samp_info.address_mode_v = c.SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE; - samp_info.address_mode_w = c.SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE; - const atlas_sampler = c.SDL_CreateGPUSampler(device, &samp_info) orelse return error.GpuCreate; - - var ax_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = atlas_w * atlas_h, .props = 0 }; - const atlas_xfer = c.SDL_CreateGPUTransferBuffer(device, &ax_info) orelse return error.GpuCreate; - - const pipeline = try makePipeline(device, swapchain_format); - const overlay_pipeline = try makeOverlayPipeline(device, swapchain_format); - const crt_pipeline = try makeCrtPipeline(device, swapchain_format); - - const overlay_buffer_size: u32 = @intCast(max_overlay_vertices * @sizeOf(OverlayVertex)); - var ovb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = overlay_buffer_size, .props = 0 }; - const overlay_vbuf = c.SDL_CreateGPUBuffer(device, &ovb_info) orelse return error.GpuCreate; - var ovx_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = overlay_buffer_size, .props = 0 }; - const overlay_vxfer = c.SDL_CreateGPUTransferBuffer(device, &ovx_info) orelse return error.GpuCreate; - - const atlas_stage = try gpa.alloc(u8, atlas_w * atlas_h); - defer gpa.free(atlas_stage); - @memset(atlas_stage, 0); - const overlay_vertices = try gpa.alloc(OverlayVertex, max_overlay_vertices); - defer gpa.free(overlay_vertices); - - var g: Gui = .{ - .window = window, - .device = device, - .swapchain_format = swapchain_format, - .pipeline = pipeline, - .overlay_pipeline = overlay_pipeline, - .crt_pipeline = crt_pipeline, - .atlas_tex = atlas_tex, - .atlas_sampler = atlas_sampler, - .atlas_xfer = atlas_xfer, - .overlay_vbuf = overlay_vbuf, - .overlay_vxfer = overlay_vxfer, - .overlay_vertices = overlay_vertices, - .font = font, - .scale = scale, - .cell_w = cell_w, - .cell_h = cell_h, - .ascent = asc, - .atlas_stage = atlas_stage, - .glyphs = std.AutoHashMap(u32, Slot).init(gpa), - .capture = test_mode, - .capture_dir = capture_dir orelse "", - }; - defer g.glyphs.deinit(); - // slot (0,0) is the space glyph (blank cells sample alpha=0 → bg only) - _ = c.ui_font_raster(font, scale, ' ', atlas_stage.ptr, @intCast(atlas_w), @intCast(cell_w), @intCast(cell_h), asc); - g.pen_x = cell_w; - - // ---- the core ---- - pardes.image.start(io, gpa); // stb_image allocator for image panes - defer pardes.image.stop(); - // Live sessions initialize at the default 80x24 grid; the real window size - // arrives as a resize EVENT on the first loop pass. The core defers the - // shell greeting until after the first resize (so `ls` wraps to the real - // pane width) — pre-sizing at init would mean no resize ever fires and the - // greeting never runs (panes sat blank until the first interaction). - // Dump loads pre-size instead: replayed panes never greet, and sizing at - // init avoids reflowing their replayed content twice. - var opts = opts_in; - var pw: c_int = 0; - var ph: c_int = 0; - if (opts.load_path != null) { - _ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph); - opts.cols = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), cell_w))); - opts.rows = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), cell_h))); - } - var core = if (opts.load_path) |lp| blk: { - const bytes = try @import("look.zig").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(); - // core.kitty_ok stays false: no kitty graphics in SDL, petscii covers images. - // ponytail: Surface.images pixel attachments could blit as textures later. - - // 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 ptys: [pardes.MAX_PANES]?Pty = @splat(null); - // per-slot spawn generation: drops a dead shell's late output/eof when its - // pane id has been respawned (see drainEffects .spawn) - var gens: [pardes.MAX_PANES]u32 = @splat(0); - defer for (&ptys) |*slot| if (slot.*) |pt| { - _ = libc.close(pt.fd); - slot.* = null; - }; - var queue: Queue = .{ .gpa = gpa, .sdl_wake = true }; - defer queue.close(); - - // initial spawns BEFORE any worker thread exists: forkpty from a - // multithreaded process can wedge the child before exec (see tty.zig). - drainEffects(core, &ptys, &gens, gpa, &queue, &g, false); - for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), gens[id], &queue); - - _ = c.SDL_StartTextInput(window); - - var feed: StdinFeed = .{}; - if (test_mode) setStdinRaw() catch {}; - - var frame_arena: std.heap.ArenaAllocator = .init(gpa); - defer frame_arena.deinit(); - - while (!core.quit) { - // 1. SDL input: block briefly for the first event, then drain the rest - var sev = std.mem.zeroes(c.SDL_Event); - if (c.SDL_WaitEventTimeout(&sev, 16)) { - dispatch(&g, core, &sev); - while (c.SDL_PollEvent(&sev)) dispatch(&g, core, &sev); - } - // test mode: input comes from stdin escape sequences instead - if (test_mode) { - const r = feed.pump(gpa, core, &g) catch break; - if (r.eof) break; - } - // 2. pty output from the reader threads - var msgs = queue.take(); - for (msgs.items) |m| switch (m) { - .output => |o| { - if (gens[o.pane] == o.gen) - core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } }); - gpa.free(o.bytes); - }, - .eof => |e| { - _ = libc.close(e.fd); // the dead reader's master — stale or current - if (gens[e.pane] == e.gen) { - ptys[e.pane] = null; - core.update(.{ .eof = .{ .pane = e.pane } }); - } - }, - .lsp => |l| { - core.update(.{ .lsp_resp = .{ .id = l.id, .rows = l.rows } }); - gpa.free(l.rows); - }, - }; - msgs.deinit(gpa); - // 3. steamdeck: poll gamepad axes into virtual cursor / wheel events - pollGamepad(&g, core); - // 4. effects - drainEffects(core, &ptys, &gens, gpa, &queue, &g, true); - if (core.quit) break; - // Restore builtin: swap in a core rebuilt from the dump; kill the live - // shells (their detached readers wake on child death; gens bumped so - // the stale eofs close the old fds without touching 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) |*slot| if (slot.*) |pt| { - _ = libc.kill(pt.pid, libc.SIG.KILL); - slot.* = null; - }; - for (&gens) |*g2| g2.* +%= 1; - core.deinit(); - core = nc; - } - // 5. live cwd for tags/look: cheap /proc readlink per pane, per frame - pollCwds(core, &ptys); - // 6. the grid follows the window (covers WINDOW_RESIZED and test resizes) - _ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph); - const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), cell_w))); - const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), cell_h))); - if (cols != core.screen_w or rows != core.screen_h) - core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); - // 7. render - _ = frame_arena.reset(.retain_capacity); - const surface = try core.render(frame_arena.allocator()); - renderFrame(&g, gpa, surface, core.crt_on, core.show_debug) catch |err| log.err("render: {t}", .{err}); - } -} - -// ===================================================================== -// web entry: set everything up, register a rAF frame callback, return. -// EXIT_RUNTIME=0 keeps the wasm runtime (and these statics) alive after. -// ===================================================================== - -// std.process.Init provides no gpa on emscripten — one 512 MiB fixed buffer -// is the whole heap, never freed (the shell lives for the page). -var web_heap: [if (is_emscripten) 512 * 1024 * 1024 else 0]u8 align(16) = undefined; -var web_fba: std.heap.FixedBufferAllocator = undefined; - -const WebState = struct { - g: Gui, - core: *pardes.Pardes, - gpa: std.mem.Allocator, - frame_arena: std.heap.ArenaAllocator, -}; -var live_web_state: ?*WebState = null; - -// Browser snapshot tests drive real DOM touch input, then read the exact same -// rendered Surface that WebGL consumed. These tiny exports keep the test -// contract textual (the existing .snap convention) without adding test state -// or browser concepts to the core. -comptime { - if (is_emscripten) { - @export(&webTestCols, .{ .name = "pardes_web_test_cols" }); - @export(&webTestRows, .{ .name = "pardes_web_test_rows" }); - @export(&webTestCursorX, .{ .name = "pardes_web_test_cursor_x" }); - @export(&webTestCursorY, .{ .name = "pardes_web_test_cursor_y" }); - @export(&webTestCell, .{ .name = "pardes_web_test_cell" }); - @export(&webTestCellFg, .{ .name = "pardes_web_test_cell_fg" }); - @export(&webTestCellBg, .{ .name = "pardes_web_test_cell_bg" }); - @export(&webTestCellAttrs, .{ .name = "pardes_web_test_cell_attrs" }); - @export(&webTestCellX, .{ .name = "pardes_web_test_cell_x" }); - @export(&webTestCellY, .{ .name = "pardes_web_test_cell_y" }); - } -} - -fn webTestCols() callconv(.c) c_uint { - const st = live_web_state orelse return 0; - return st.core.surface.cols; -} - -fn webTestRows() callconv(.c) c_uint { - const st = live_web_state orelse return 0; - return st.core.surface.rows; -} - -fn webTestCursorX() callconv(.c) c_int { - const st = live_web_state orelse return -1; - return if (st.core.surface.cursor) |cursor| cursor.x else -1; -} - -fn webTestCursorY() callconv(.c) c_int { - const st = live_web_state orelse return -1; - return if (st.core.surface.cursor) |cursor| cursor.y else -1; -} - -fn webTestCell(col: c_uint, row: c_uint) callconv(.c) c_uint { - const cell = webTestCellAt(col, row) orelse return ' '; - const glyph = cell.grapheme(); - if (glyph.len == 0) return ' '; - return std.unicode.utf8Decode(glyph) catch 0xfffd; -} - -// RGB occupies 0x000000-0xffffff; the high byte distinguishes the two other -// Color tags. The browser .snap harness renders these into d/pN/#rrggbb runs. -fn webTestCellFg(col: c_uint, row: c_uint) callconv(.c) c_uint { - const cell = webTestCellAt(col, row) orelse return 0x01000000; - return webTestColor(cell.style.fg); -} - -fn webTestCellBg(col: c_uint, row: c_uint) callconv(.c) c_uint { - const cell = webTestCellAt(col, row) orelse return 0x01000000; - return webTestColor(cell.style.bg); -} - -fn webTestColor(color: pardes.Color) c_uint { - return switch (color) { - .default => 0x01000000, - .index => |idx| 0x02000000 | @as(c_uint, idx), - .rgb => |rgb| (@as(c_uint, rgb[0]) << 16) | (@as(c_uint, rgb[1]) << 8) | rgb[2], - }; -} - -fn webTestCellAttrs(col: c_uint, row: c_uint) callconv(.c) c_uint { - const cell = webTestCellAt(col, row) orelse return 0; - const style = cell.style; - var attrs: c_uint = 0; - if (style.bold) attrs |= 1 << 0; - if (style.dim) attrs |= 1 << 1; - if (style.italic) attrs |= 1 << 2; - if (style.blink) attrs |= 1 << 3; - if (style.reverse) attrs |= 1 << 4; - if (style.invisible) attrs |= 1 << 5; - if (style.strikethrough) attrs |= 1 << 6; - attrs |= @as(c_uint, @intFromEnum(style.ul)) << 8; - return attrs; -} - -fn webTestCellAt(col: c_uint, row: c_uint) ?*const pardes.Cell { - const st = live_web_state orelse return null; - const surface = &st.core.surface; - if (col >= surface.cols or row >= surface.rows) return null; - return surface.at(@intCast(col), @intCast(row)); -} - -fn webTestCellX(col: f32) callconv(.c) f32 { - return webTestCellCenter(col, true); -} - -fn webTestCellY(row: f32) callconv(.c) f32 { - return webTestCellCenter(row, false); -} - -fn webTestCellCenter(cell: f32, horizontal: bool) f32 { - const st = live_web_state orelse return 0; - var pixel_w: c_int = 0; - var pixel_h: c_int = 0; - _ = c.SDL_GetWindowSizeInPixels(st.g.window, &pixel_w, &pixel_h); - const pw: u32 = @intCast(@max(pixel_w, 1)); - const ph: u32 = @intCast(@max(pixel_h, 1)); - var css_w: f64 = 0; - var css_h: f64 = 0; - if (emscripten_get_element_css_size("#canvas", &css_w, &css_h) != EMSCRIPTEN_RESULT_SUCCESS) return 0; - const layout = cellLayout(&st.g, st.core.surface.cols, st.core.surface.rows, pw, ph); - if (horizontal) { - const backing = layout.x_off + (cell + 0.5) * layout.w; - return backing * @as(f32, @floatCast(css_w)) / @as(f32, @floatFromInt(pw)); - } - const backing = layout.y_off + (cell + 0.5) * layout.h; - return backing * @as(f32, @floatCast(css_h)) / @as(f32, @floatFromInt(ph)); -} - -fn runWeb(opts_in: pardes.Options) !void { - web_fba = .init(&web_heap); - const gpa = web_fba.allocator(); - - // Our finger machine deliberately maps touch to scroll/LOOK. Suppress - // SDL's default synthetic left mouse event for the same browser touch. - _ = c.SDL_SetHint(c.SDL_HINT_TOUCH_MOUSE_EVENTS, "0"); - if (!c.SDL_Init(c.SDL_INIT_VIDEO)) { // no gamepad in the browser - log.err("SDL_Init: {s}", .{c.SDL_GetError()}); - return error.SdlInit; - } - const window = c.SDL_CreateWindow("pardes", 1120, 720, c.SDL_WINDOW_RESIZABLE | c.SDL_WINDOW_OPENGL) orelse { - log.err("SDL_CreateWindow: {s}", .{c.SDL_GetError()}); - return error.SdlInit; - }; - _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_PROFILE_MASK, c.SDL_GL_CONTEXT_PROFILE_ES); - _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MAJOR_VERSION, 3); - _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MINOR_VERSION, 0); - _ = c.SDL_GL_SetAttribute(c.SDL_GL_DOUBLEBUFFER, 1); - const gl_context = c.SDL_GL_CreateContext(window) orelse { - log.err("SDL_GL_CreateContext: {s}", .{c.SDL_GetError()}); - return error.SdlInit; - }; - if (!c.SDL_GL_MakeCurrent(window, gl_context)) { - log.err("SDL_GL_MakeCurrent: {s}", .{c.SDL_GetError()}); - return error.SdlInit; - } - _ = c.SDL_GL_SetSwapInterval(1); - - // ---- font + cell metrics, rastered at web_render_scale ---- - const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)) orelse { - log.err("ui_font_new failed", .{}); - return error.FontInit; - }; - const px: f32 = 18.0; - const base_scale = c.ui_font_scale_for_height(font, px); - var cw: c_int = 10; - var chh: c_int = 20; - var asc: c_int = 16; - c.ui_font_cell_metrics(font, base_scale, &cw, &chh, &asc); - const scale = c.ui_font_scale_for_height(font, px * web_render_scale); - const cell_w: u32 = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(cw, 1))) * web_render_scale)); - const cell_h: u32 = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(chh, 1))) * web_render_scale)); - const ascent: i32 = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(asc, 1))) * web_render_scale)); - - // ---- WebGL2: runtime-compiled ES shaders + atlas texture + vbos ---- - const program = try makeGlProgramFrom(vert_glsl_es, frag_glsl_es); - const overlay_program = try makeGlProgramFrom(overlay_vert_glsl_es, overlay_frag_glsl_es); - var atlas_tex: c_uint = 0; - c.glGenTextures(1, &atlas_tex); - c.glBindTexture(c.GL_TEXTURE_2D, atlas_tex); - c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_MIN_FILTER, c.GL_LINEAR); - c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_MAG_FILTER, c.GL_LINEAR); - c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_WRAP_S, c.GL_CLAMP_TO_EDGE); - c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_WRAP_T, c.GL_CLAMP_TO_EDGE); - c.glPixelStorei(c.GL_UNPACK_ALIGNMENT, 1); - c.glTexImage2D(c.GL_TEXTURE_2D, 0, c.GL_R8, atlas_w, atlas_h, 0, c.GL_RED, c.GL_UNSIGNED_BYTE, null); - var vbo: c_uint = 0; - c.glGenBuffers(1, &vbo); - var overlay_vbo: c_uint = 0; - c.glGenBuffers(1, &overlay_vbo); - - const atlas_stage = try gpa.alloc(u8, atlas_w * atlas_h); - @memset(atlas_stage, 0); - const overlay_vertices = try gpa.alloc(OverlayVertex, max_overlay_vertices); - - const st = try gpa.create(WebState); - st.* = .{ - .g = .{ - .window = window, - .device = undefined, // web renders through GL, never the GPU device - .swapchain_format = undefined, - .pipeline = undefined, - .overlay_pipeline = undefined, - .crt_pipeline = undefined, - .atlas_tex = undefined, - .atlas_sampler = undefined, - .atlas_xfer = undefined, - .overlay_vbuf = undefined, - .overlay_vxfer = undefined, - .overlay_vertices = overlay_vertices, - .gl_program = program, - .gl_overlay_program = overlay_program, - .gl_atlas_tex = atlas_tex, - .gl_vbo = vbo, - .gl_overlay_vbo = overlay_vbo, - .gl_u_atlas = c.glGetUniformLocation(program, "u_atlas"), - .font = font, - .scale = scale, - .cell_w = cell_w, - .cell_h = cell_h, - .ascent = ascent, - .atlas_stage = atlas_stage, - .glyphs = std.AutoHashMap(u32, Slot).init(gpa), - }, - .core = undefined, - .gpa = gpa, - .frame_arena = .init(gpa), - }; - const g = &st.g; - // slot (0,0) is the space glyph (blank cells sample alpha=0 → bg only) - _ = c.ui_font_raster(font, scale, ' ', atlas_stage.ptr, @intCast(atlas_w), @intCast(cell_w), @intCast(cell_h), ascent); - g.pen_x = cell_w; - - // ---- the core: state replays from the embedded dump, no ptys ---- - pardes.image.start(std.Io.failing, gpa); // decode is synchronous; no io on wasm - syncCanvasSize(g); // size the backing canvas before deriving the grid - var opts = opts_in; - var pw: c_int = 0; - var ph: c_int = 0; - _ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph); - opts.cols = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), cell_w))); - opts.rows = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), cell_h))); - st.core = try pardes.Pardes.initFromDump(gpa, opts, embedded_dump); - live_web_state = st; - - _ = c.SDL_StartTextInput(window); - std.os.emscripten.emscripten_set_main_loop_arg(webFrame, st, 0, 0); -} - -fn webFrame(arg: ?*anyopaque) callconv(.c) void { - const st: *WebState = @ptrCast(@alignCast(arg.?)); - const g = &st.g; - const core = st.core; - // 1. input - var sev = std.mem.zeroes(c.SDL_Event); - while (c.SDL_PollEvent(&sev)) dispatch(g, core, &sev); - // 2. effects - drainEffectsWeb(core, st.gpa, g); - if (core.quit) return std.os.emscripten.emscripten_cancel_main_loop(); - // 3. the backing canvas follows the page, the grid follows the canvas - syncCanvasSize(g); - var pw: c_int = 0; - var ph: c_int = 0; - _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); - const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w))); - const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h))); - if (cols != core.screen_w or rows != core.screen_h) - core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); - // 4. render - _ = st.frame_arena.reset(.retain_capacity); - const surface = core.render(st.frame_arena.allocator()) catch return; - renderFrameGl(g, st.gpa, surface, core.show_debug) catch return; -} - -/// the canvas follows the page (CSS); the SDL window follows the canvas, -/// backed at web_render_scale (postWinsize in the prototype). -fn syncCanvasSize(g: *Gui) void { - var css_w: f64 = 0; - var css_h: f64 = 0; - if (emscripten_get_element_css_size("#canvas", &css_w, &css_h) != EMSCRIPTEN_RESULT_SUCCESS or css_w <= 0 or css_h <= 0) return; - const want_w: c_int = @intFromFloat(@round(css_w * web_render_scale)); - const want_h: c_int = @intFromFloat(@round(css_h * web_render_scale)); - var cur_w: c_int = 0; - var cur_h: c_int = 0; - _ = c.SDL_GetWindowSizeInPixels(g.window, &cur_w, &cur_h); - if (cur_w != want_w or cur_h != want_h) { - _ = c.SDL_SetWindowSize(g.window, want_w, want_h); - _ = c.SDL_SyncWindow(g.window); - } -} - -/// PARDES_TEST_GRID=1: headless. No SDL at all — stdin escape sequences in, -/// the rendered Surface out as text frames (same framing as the prototype). -fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { - const io = init.io; - const gpa = init.gpa; - const env = init.environ_map; - - pardes.image.start(io, gpa); - defer pardes.image.stop(); - - const grid_cols = envU16(env, "PARDES_TEST_COLS") orelse 80; - const grid_rows = envU16(env, "PARDES_TEST_ROWS") orelse 24; - var opts = opts_in; - if (opts.load_path != null) { - opts.cols = grid_cols; - opts.rows = grid_rows; - } - const core = if (opts.load_path) |lp| blk: { - const bytes = try @import("look.zig").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(); - // the requested grid arrives as a resize EVENT (not init opts) so the core - // counts a resize and releases the shell greeting on first output - if (opts.load_path == null) - core.update(.{ .resize = .{ .cols = grid_cols, .rows = grid_rows } }); - - 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 ptys: [pardes.MAX_PANES]?Pty = @splat(null); - // per-slot spawn generation: drops a dead shell's late output/eof when its - // pane id has been respawned (see drainEffects .spawn) - var gens: [pardes.MAX_PANES]u32 = @splat(0); - defer for (&ptys) |*slot| if (slot.*) |pt| { - _ = libc.close(pt.fd); - slot.* = null; - }; - var queue: Queue = .{ .gpa = gpa, .sdl_wake = false }; - defer queue.close(); - drainEffects(core, &ptys, &gens, gpa, &queue, null, false); - for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), gens[id], &queue); - - setStdinRaw() catch {}; // stdin may be a pipe, not a pty — best effort - - var frame_arena: std.heap.ArenaAllocator = .init(gpa); - defer frame_arena.deinit(); - var feed: StdinFeed = .{}; - - // first frame before touching stdin, so `printf '' | pardes` still shows one - { - const surface = try core.render(frame_arena.allocator()); - try dumpGrid(gpa, surface); - } - while (!core.quit) { - const r = try feed.pump(gpa, core, null); - if (r.eof) break; - var n_events: usize = r.n_events; - var msgs = queue.take(); - for (msgs.items) |m| switch (m) { - .output => |o| { - if (gens[o.pane] == o.gen) - core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } }); - gpa.free(o.bytes); - n_events += 1; - }, - .eof => |e| { - _ = libc.close(e.fd); // the dead reader's master — stale or current - if (gens[e.pane] == e.gen) { - ptys[e.pane] = null; - core.update(.{ .eof = .{ .pane = e.pane } }); - } - n_events += 1; - }, - .lsp => |l| { - core.update(.{ .lsp_resp = .{ .id = l.id, .rows = l.rows } }); - gpa.free(l.rows); - n_events += 1; - }, - }; - msgs.deinit(gpa); - drainEffects(core, &ptys, &gens, gpa, &queue, null, true); - pollCwds(core, &ptys); - if (n_events == 0) continue; // idle tick: nothing changed, no frame - _ = frame_arena.reset(.retain_capacity); - const surface = try core.render(frame_arena.allocator()); - try dumpGrid(gpa, surface); - } -} - -// ===================================================================== -// test-mode stdin: terminal escape sequences (vaxis.Parser) → core events, -// plus the private synthetic finger OSC: -// ESC ] 777;finger;;;;; BEL (normalized 0..1) -// ===================================================================== - -const StdinFeed = struct { - parser: vaxis.Parser = .{}, - cache: vaxis.GraphemeCache = .{}, - buf: [1024]u8 = undefined, - fill: usize = 0, - last_cols: u16 = 0, - last_rows: u16 = 0, - - const Result = struct { eof: bool = false, n_events: usize = 0 }; - - /// Poll stdin briefly and translate what arrived. `g` is null in grid mode. - fn pump(f: *StdinFeed, gpa: std.mem.Allocator, core: *pardes.Pardes, g: ?*Gui) !Result { - var out: Result = .{}; - // a pty stdin also carries the winsize; poll it in place of SIGWINCH - var ws: posix.winsize = std.mem.zeroes(posix.winsize); - if (posix.system.ioctl(0, posix.T.IOCGWINSZ, @intFromPtr(&ws)) == 0 and - ws.col > 0 and ws.row > 0 and ws.col <= 1000 and ws.row <= 1000 and - (ws.col != f.last_cols or ws.row != f.last_rows)) - { - f.last_cols = ws.col; - f.last_rows = ws.row; - f.applyResize(core, g, ws.col, ws.row); - out.n_events += 1; - } - - var fds = [_]posix.pollfd{.{ .fd = 0, .events = posix.POLL.IN, .revents = 0 }}; - _ = posix.poll(&fds, 16) catch return out; - if ((fds[0].revents & posix.POLL.IN) == 0) { - if ((fds[0].revents & (posix.POLL.HUP | posix.POLL.ERR)) != 0) out.eof = true; - return out; - } - const rn = libc.read(0, f.buf[f.fill..].ptr, f.buf.len - f.fill); - if (rn < 0) return out; - if (rn == 0) { - out.eof = true; - return out; - } - const len = f.fill + @as(usize, @intCast(rn)); - f.fill = 0; - - var seq_start: usize = 0; - while (seq_start < len) { - // private finger OSC first (vaxis would swallow it as unknown OSC) - const prefix = "\x1b]777;finger;"; - if (std.mem.startsWith(u8, f.buf[seq_start..len], prefix)) { - const body = f.buf[seq_start + prefix.len .. len]; - const end = std.mem.indexOfScalar(u8, body, 0x07) orelse { - std.mem.copyForwards(u8, f.buf[0 .. len - seq_start], f.buf[seq_start..len]); - f.fill = len - seq_start; - break; - }; - if (parseFinger(body[0..end])) |finger_in| { - var finger = finger_in; - if (g) |gp| { - var wpw: c_int = 0; - var wph: c_int = 0; - _ = c.SDL_GetWindowSizeInPixels(gp.window, &wpw, &wph); - const win_w: f32 = @floatFromInt(@max(wpw, 1)); - const win_h: f32 = @floatFromInt(@max(wph, 1)); - if (core.crt_on) { - // touch points are physical: same CRT mapping as the mouse - const m = crt.map(finger.x * win_w, finger.y * win_h, win_w, win_h); - finger.x = m.x / win_w; - finger.y = m.y / win_h; - } - handleFinger(&gp.touch, core, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h)); - } else { - handleFinger(&grid_touch, core, finger, @floatFromInt(core.screen_w), @floatFromInt(core.screen_h), 1, 1); - } - out.n_events += 1; - } - seq_start += prefix.len + end + 1; - continue; - } - if (len - seq_start < prefix.len and std.mem.startsWith(u8, prefix, f.buf[seq_start..len])) { - std.mem.copyForwards(u8, f.buf[0 .. len - seq_start], f.buf[seq_start..len]); - f.fill = len - seq_start; - break; - } - - const result = f.parser.parse(f.buf[seq_start..len], gpa) catch break; - if (result.n == 0) { // partial sequence: shift to front, read more - std.mem.copyForwards(u8, f.buf[0 .. len - seq_start], f.buf[seq_start..len]); - f.fill = len - seq_start; - break; - } - seq_start += result.n; - const event = result.event orelse continue; - switch (event) { - .key_press => |key| { - var text: []const u8 = ""; - if (key.text) |t| text = f.cache.put(t); - core.update(.{ .key = .{ - .cp = mapKey(effCp(key)), - .text = text, - .ctrl = key.mods.ctrl, - .alt = key.mods.alt, - .shift = key.mods.shift, - } }); - out.n_events += 1; - }, - .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, - else => null, - }; - if (button) |b| { - core.update(.{ .mouse = .{ - .button = b, - .kind = switch (m.type) { - .press => .press, - .release => .release, - .motion => .motion, - .drag => .drag, - }, - .col = @intCast(@max(m.col, 0)), - .row = @intCast(@max(m.row, 0)), - .ctrl = m.mods.ctrl, - } }); - out.n_events += 1; - } - }, - .winsize => |vw| { - f.applyResize(core, g, @intCast(vw.cols), @intCast(vw.rows)); - out.n_events += 1; - }, - .paste => |text| { - core.update(.{ .paste = text }); - gpa.free(@constCast(text)); - out.n_events += 1; - }, - else => {}, - } - } - return out; - } - - fn applyResize(f: *StdinFeed, core: *pardes.Pardes, g: ?*Gui, cols: u16, rows: u16) void { - _ = f; - if (g) |gp| { - // capture mode: resize the window; the frame loop resizes the core - _ = c.SDL_SetWindowSize(gp.window, @intCast(cols * gp.cell_w), @intCast(rows * gp.cell_h)); - _ = c.SDL_SyncWindow(gp.window); - } else { - core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); - } - } -}; - -var grid_touch: Touch = .{}; // grid test mode has no Gui to hang it off - -fn parseFinger(payload: []const u8) ?Finger { - var parts = std.mem.splitScalar(u8, payload, ';'); - const type_name = parts.next() orelse return null; - const id_s = parts.next() orelse return null; - const x_s = parts.next() orelse return null; - const y_s = parts.next() orelse return null; - const pressure_s = parts.next() orelse return null; - if (parts.next() != null) return null; - const kind: @FieldType(Finger, "kind") = if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_DOWN") or std.mem.eql(u8, type_name, "down")) - .down - else if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_MOTION") or std.mem.eql(u8, type_name, "motion")) - .motion - else if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_UP") or std.mem.eql(u8, type_name, "up")) - .up - else if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_CANCELED") or std.mem.eql(u8, type_name, "cancel")) - .cancel - else - return null; - return .{ - .kind = kind, - .id = std.fmt.parseInt(u64, id_s, 10) catch return null, - .x = std.fmt.parseFloat(f32, x_s) catch return null, - .y = std.fmt.parseFloat(f32, y_s) catch return null, - .pressure = std.fmt.parseFloat(f32, pressure_s) catch return null, - }; -} - -fn setStdinRaw() !void { - var term = try posix.tcgetattr(0); - term.iflag.BRKINT = false; - term.iflag.ICRNL = false; - term.iflag.INPCK = false; - term.iflag.ISTRIP = false; - term.iflag.IXON = false; - term.oflag.OPOST = false; - term.lflag.ECHO = false; - term.lflag.ICANON = false; - term.lflag.IEXTEN = false; - term.lflag.ISIG = false; - term.cc[@intFromEnum(posix.V.MIN)] = 1; - term.cc[@intFromEnum(posix.V.TIME)] = 0; - try posix.tcsetattr(0, .NOW, term); -} - -// PARDES_TEST_GRID: one text frame per render, prototype-compatible framing. -fn dumpGrid(gpa: std.mem.Allocator, surface: *pardes.Surface) !void { - if (surface.cols == 0 or surface.rows == 0) return; - // build the whole frame in one buffer → one write → atomic snapshots - var buf: std.ArrayList(u8) = .empty; - defer buf.deinit(gpa); - const cur_x: u16 = if (surface.cursor) |cu| cu.x else 0; - const cur_y: u16 = if (surface.cursor) |cu| cu.y else 0; - var hdr: [128]u8 = undefined; - const hdr_str = std.fmt.bufPrint(&hdr, "\n---FRAME:{d}:{d}:{d}:{d}:{}---\n", .{ - surface.rows, surface.cols, cur_x, cur_y, surface.cursor != null, - }) catch return; - try buf.appendSlice(gpa, hdr_str); - var y: u16 = 0; - while (y < surface.rows) : (y += 1) { - var x: u16 = 0; - while (x < surface.cols) : (x += 1) { - const g = surface.at(x, y).grapheme(); - if (g.len > 0) try buf.appendSlice(gpa, g) else try buf.append(gpa, ' '); - } - try buf.append(gpa, '\n'); - } - try buf.appendSlice(gpa, "---ENDFRAME---\n"); - writeFd(1, buf.items); -} - -// ===================================================================== -// SDL event dispatch -// ===================================================================== - -fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void { - switch (sev.type) { - c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => core.quit = true, - // window resizes are picked up by the per-frame grid check - c.SDL_EVENT_KEY_DOWN => { - g.live_ctrl = (sev.key.mod & c.SDL_KMOD_CTRL) != 0; - g.live_alt = (sev.key.mod & c.SDL_KMOD_ALT) != 0; - keyDown(core, sev.key.key, sev.key.mod); - }, - c.SDL_EVENT_KEY_UP => { - g.live_ctrl = (sev.key.mod & c.SDL_KMOD_CTRL) != 0; - g.live_alt = (sev.key.mod & c.SDL_KMOD_ALT) != 0; - }, - c.SDL_EVENT_TEXT_INPUT => { - const tptr = sev.text.text orelse return; - var tlen: usize = 0; - while (tptr[tlen] != 0) : (tlen += 1) {} - if (tlen == 0) return; - const text: []const u8 = tptr[0..tlen]; - const cplen = std.unicode.utf8ByteSequenceLength(text[0]) catch return; - if (cplen > text.len) return; - const cp = std.unicode.utf8Decode(text[0..cplen]) catch return; - core.update(.{ .key = .{ .cp = cp, .text = text[0..cplen], .ctrl = g.live_ctrl, .alt = g.live_alt } }); - }, - c.SDL_EVENT_MOUSE_BUTTON_DOWN, c.SDL_EVENT_MOUSE_BUTTON_UP => { - const b = sev.button; - g.mouse_x = b.x; - g.mouse_y = b.y; - const button: pardes.Mouse.Button = switch (b.button) { - 1 => .left, - 2 => .middle, - 3 => .right, - else => return, - }; - const mc = mouseCell(g, core, b.x, b.y); - core.update(.{ - .mouse = .{ - .button = button, - .kind = if (b.down) .press else .release, - .col = mc.col, - .row = mc.row, - // asked of SDL directly rather than read off g.live_ctrl: - // that one is bookkeeping from KEY events, and a ctrl-click - // with no key pressed since startup would miss it - .ctrl = (c.SDL_GetModState() & c.SDL_KMOD_CTRL) != 0, - }, - }); - }, - c.SDL_EVENT_MOUSE_MOTION => { - const m = sev.motion; - g.mouse_x = m.x; - g.mouse_y = m.y; - // pad cursor continues from wherever the pointer last was - g.pad_x = m.x; - g.pad_y = m.y; - // drag = motion with a button held (selection extension keys off it) - const held: ?pardes.Mouse.Button = if ((m.state & c.SDL_BUTTON_LMASK) != 0) - .left - else if ((m.state & c.SDL_BUTTON_MMASK) != 0) - .middle - else if ((m.state & c.SDL_BUTTON_RMASK) != 0) - .right - else - null; - const mc = mouseCell(g, core, m.x, m.y); - core.update(.{ .mouse = .{ - .button = held orelse .none, - .kind = if (held != null) .drag else .motion, - .col = mc.col, - .row = mc.row, - } }); - }, - c.SDL_EVENT_MOUSE_WHEEL => { - const w = sev.wheel; - if (w.y == 0 and w.x == 0) return; - g.mouse_x = w.mouse_x; - g.mouse_y = w.mouse_y; - const mc = mouseCell(g, core, w.mouse_x, w.mouse_y); - if (w.y != 0) core.update(.{ .mouse = .{ - .button = if (w.y > 0) .wheel_up else .wheel_down, - .kind = .press, - .col = mc.col, - .row = mc.row, - } }); - if (w.x != 0) core.update(.{ .mouse = .{ - .button = if (w.x > 0) .wheel_right else .wheel_left, - .kind = .press, - .col = mc.col, - .row = mc.row, - } }); - }, - c.SDL_EVENT_FINGER_DOWN, c.SDL_EVENT_FINGER_MOTION, c.SDL_EVENT_FINGER_UP, c.SDL_EVENT_FINGER_CANCELED => { - const f = sev.tfinger; - var finger: Finger = .{ - .kind = switch (sev.type) { - c.SDL_EVENT_FINGER_DOWN => .down, - c.SDL_EVENT_FINGER_MOTION => .motion, - c.SDL_EVENT_FINGER_UP => .up, - else => .cancel, - }, - .id = @intCast(f.fingerID), - .x = f.x, - .y = f.y, - .pressure = f.pressure, - }; - var pw: c_int = 0; - var ph: c_int = 0; - _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); - var win_w: f32 = @floatFromInt(@max(pw, 1)); - var win_h: f32 = @floatFromInt(@max(ph, 1)); - var fcw: f32 = @floatFromInt(g.cell_w); - var fch: f32 = @floatFromInt(g.cell_h); - if (!is_emscripten and core.crt_on) { - // touch points are physical too: same CRT mapping as the mouse - const m = crt.map(finger.x * win_w, finger.y * win_h, win_w, win_h); - finger.x = m.x / win_w; - finger.y = m.y / win_h; - } - if (is_emscripten) { - // remap normalized coords into the letterboxed grid so the - // shared touch machine's cell math stays exact - const layout = cellLayout(g, core.screen_w, core.screen_h, @intCast(@max(pw, 1)), @intCast(@max(ph, 1))); - const grid_w = @max(1.0, @as(f32, @floatFromInt(core.screen_w)) * layout.w); - const grid_h = @max(1.0, @as(f32, @floatFromInt(core.screen_h)) * layout.h); - finger.x = std.math.clamp((finger.x * win_w - layout.x_off) / grid_w, 0.0, 1.0); - finger.y = std.math.clamp((finger.y * win_h - layout.y_off) / grid_h, 0.0, 1.0); - win_w = grid_w; - win_h = grid_h; - fcw = layout.w; - fch = layout.h; - } - handleFinger(&g.touch, core, finger, win_w, win_h, fcw, fch); - }, - c.SDL_EVENT_PINCH_BEGIN, c.SDL_EVENT_PINCH_UPDATE => core.update(.{ .pinch = sev.pinch.scale }), - // ---- steamdeck: first gamepad drives a virtual mouse (buttons here, - // axes polled per frame in pollGamepad) ---- - c.SDL_EVENT_GAMEPAD_ADDED => { - if (g.gamepad == null) g.gamepad = c.SDL_OpenGamepad(sev.gdevice.which); - }, - c.SDL_EVENT_GAMEPAD_REMOVED => { - if (g.gamepad) |pad| { - c.SDL_CloseGamepad(pad); - g.gamepad = null; - } - }, - // deck.zig maps buttons/triggers/trackpads to pointer, clicks, - // wheel, keys and rumble; this arm just applies its actions - c.SDL_EVENT_GAMEPAD_BUTTON_DOWN, - c.SDL_EVENT_GAMEPAD_BUTTON_UP, - c.SDL_EVENT_GAMEPAD_AXIS_MOTION, - c.SDL_EVENT_GAMEPAD_TOUCHPAD_DOWN, - c.SDL_EVENT_GAMEPAD_TOUCHPAD_MOTION, - c.SDL_EVENT_GAMEPAD_TOUCHPAD_UP, - => { - const in: deck.Input = switch (sev.type) { - c.SDL_EVENT_GAMEPAD_BUTTON_DOWN, c.SDL_EVENT_GAMEPAD_BUTTON_UP => .{ .button = .{ - .idx = sev.gbutton.button, - .down = sev.gbutton.down, - } }, - c.SDL_EVENT_GAMEPAD_AXIS_MOTION => .{ .axis = .{ - .idx = sev.gaxis.axis, - .value = sev.gaxis.value, - } }, - else => .{ .touch = .{ - .pad = @intCast(std.math.clamp(sev.gtouchpad.touchpad, 0, 255)), - .phase = switch (sev.type) { - c.SDL_EVENT_GAMEPAD_TOUCHPAD_DOWN => .down, - c.SDL_EVENT_GAMEPAD_TOUCHPAD_UP => .up, - else => .motion, - }, - .x = sev.gtouchpad.x, - .y = sev.gtouchpad.y, - .pressure = sev.gtouchpad.pressure, - } }, - }; - var acts: [3]deck.Action = undefined; - for (acts[0..g.deck.feed(in, &acts)]) |act| switch (act) { - .move => |mv| { - var pw: c_int = 0; - var ph: c_int = 0; - _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); - // pad_x/pad_y stay PHYSICAL; mouseCell applies the CRT - // mapping once at cell conversion, never into the pad - const old = mouseCell(g, core, g.pad_x, g.pad_y); - g.pad_x = std.math.clamp(g.pad_x + mv.dx, 0, @as(f32, @floatFromInt(@max(pw, 1))) - 1); - g.pad_y = std.math.clamp(g.pad_y + mv.dy, 0, @as(f32, @floatFromInt(@max(ph, 1))) - 1); - // the SDL cursor (plan9 arrow) rides along, so the pad - // cursor and a hardware mouse are one visible pointer - c.SDL_WarpMouseInWindow(g.window, g.pad_x, g.pad_y); - const mc = mouseCell(g, core, g.pad_x, g.pad_y); - if (mc.col == old.col and mc.row == old.row) continue; - // moving with a click held drags, so selections stretch - // (a firm right-pad press drags-selects like a laptop pad) - const held: ?pardes.Mouse.Button = if (g.deck.r2_held or g.deck.rpad_click) - .left - else if (g.deck.l2_held) - .middle - else if (g.deck.look_held) - .right - else - null; - core.update(.{ .mouse = .{ - .button = held orelse .none, - .kind = if (held != null) .drag else .motion, - .col = mc.col, - .row = mc.row, - } }); - }, - .click => |ck| { - const mc = mouseCell(g, core, g.pad_x, g.pad_y); - core.update(.{ .mouse = .{ - .button = switch (ck.which) { - .select => .left, - .execute => .middle, - .look => .right, - }, - .kind = if (ck.down) .press else .release, - .col = mc.col, - .row = mc.row, - } }); - }, - .wheel => |ticks| { - const mc = mouseCell(g, core, g.pad_x, g.pad_y); - var left = ticks; - while (left != 0) { - left += if (ticks > 0) -1 else 1; - core.update(.{ .mouse = .{ - .button = if (ticks > 0) .wheel_down else .wheel_up, - .kind = .press, - .col = mc.col, - .row = mc.row, - } }); - } - }, - .key => |k| core.update(.{ - .key = switch (k) { - .n => .{ .cp = 'n', .text = "n" }, - .cap_n => .{ .cp = 'N', .text = "N" }, - .enter => .{ .cp = pardes.Key.enter }, - .tab => .{ .cp = pardes.Key.tab }, - // back paddle: Ctrl- flips tty mode - .tty_toggle => .{ .cp = core.opts.tty_toggle, .ctrl = true }, - }, - }), - // a brief gentle ack for execute/look, not a buzz - .rumble => if (g.gamepad) |pad| { - _ = c.SDL_RumbleGamepad(pad, 0x4000, 0x4000, 80); - }, - }; - }, - else => {}, - } -} - -/// Special keys + ctrl/alt shortcuts. Plain printable keys arrive as -/// SDL_EVENT_TEXT_INPUT instead (so shift/layout map correctly). -fn keyDown(core: *pardes.Pardes, sym: c.SDL_Keycode, mod: c.SDL_Keymod) void { - const ctrl = (mod & c.SDL_KMOD_CTRL) != 0; - const alt = (mod & c.SDL_KMOD_ALT) != 0; - const shift = (mod & c.SDL_KMOD_SHIFT) != 0; - const gui_mod = (mod & c.SDL_KMOD_GUI) != 0; - const cp: u21 = switch (sym) { - c.SDLK_RETURN, c.SDLK_KP_ENTER => pardes.Key.enter, - c.SDLK_BACKSPACE => pardes.Key.backspace, - c.SDLK_TAB => pardes.Key.tab, - c.SDLK_ESCAPE => pardes.Key.escape, - c.SDLK_LEFT => pardes.Key.left, - c.SDLK_RIGHT => pardes.Key.right, - c.SDLK_UP => pardes.Key.up, - c.SDLK_DOWN => pardes.Key.down, - c.SDLK_HOME => pardes.Key.home, - c.SDLK_END => pardes.Key.end, - c.SDLK_PAGEUP => pardes.Key.page_up, - c.SDLK_PAGEDOWN => pardes.Key.page_down, - c.SDLK_DELETE => pardes.Key.delete, - else => blk: { - // letters / digits / punctuation only as a modifier shortcut; - // plain printable (incl. space) goes via TEXT_INPUT - if (!(ctrl or alt or gui_mod)) break :blk 0; - if (sym < 128 and sym >= ' ') break :blk @intCast(sym); - break :blk 0; - }, - }; - if (cp == 0) return; - core.update(.{ .key = .{ .cp = cp, .ctrl = ctrl, .alt = alt, .shift = shift } }); -} - -fn pixelCell(px: f32, cell: u32) u16 { - return pixelCellF(px, @floatFromInt(cell)); -} - -fn pixelCellF(px: f32, cell: f32) u16 { - const idx = @floor(@max(px, 0) / @max(cell, 1)); - return @intFromFloat(@min(idx, 10_000)); -} - -/// mouse coords → cell. Native windows are pixel-exact; web SDL reports CSS -/// points into a letterboxed canvas backed at web_render_scale. With the Crt -/// builtin on, the physical point first maps to the scene point the shader -/// displays there (crt.map) — this is the one physical→logical spot shared -/// by the real mouse and the deck's virtual cursor. -fn mouseCell(g: *const Gui, core: *const pardes.Pardes, x: f32, y: f32) struct { col: u16, row: u16 } { - if (!is_emscripten) { - var mx = x; - var my = y; - if (core.crt_on) { - var pw: c_int = 0; - var ph: c_int = 0; - _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); - const m = crt.map(mx, my, @floatFromInt(@max(pw, 1)), @floatFromInt(@max(ph, 1))); - mx = m.x; - my = m.y; - } - return .{ .col = pixelCell(mx, g.cell_w), .row = pixelCell(my, g.cell_h) }; - } - var pw: c_int = 0; - var ph: c_int = 0; - _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); - var ww: c_int = 0; - var wh: c_int = 0; - _ = c.SDL_GetWindowSize(g.window, &ww, &wh); - const px = x * @as(f32, @floatFromInt(@max(pw, 1))) / @as(f32, @floatFromInt(@max(ww, 1))); - const py = y * @as(f32, @floatFromInt(@max(ph, 1))) / @as(f32, @floatFromInt(@max(wh, 1))); - const layout = cellLayout(g, core.screen_w, core.screen_h, @intCast(@max(pw, 1)), @intCast(@max(ph, 1))); - return .{ - .col = pixelCellF(px - layout.x_off, layout.w), - .row = pixelCellF(py - layout.y_off, layout.h), - }; -} - -/// steamdeck support: poll the sticks each frame, mirroring the trackpads — -/// RIGHT stick moves the virtual cursor at ~cell granularity (emits -/// button-less motion so hover works), LEFT stick accumulates into wheel -/// ticks (both axes: vertical + horizontal) at the cursor position. -fn pollGamepad(g: *Gui, core: *pardes.Pardes) void { - const pad = g.gamepad orelse return; - var pw: c_int = 0; - var ph: c_int = 0; - _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); - const win_w: f32 = @floatFromInt(@max(pw, 1)); - const win_h: f32 = @floatFromInt(@max(ph, 1)); - const deadzone: f32 = 8000; - // right stick = pointer - const ax: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_RIGHTX)); - const ay: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_RIGHTY)); - const old = mouseCell(g, core, g.pad_x, g.pad_y); - // full tilt ≈ 0.4 cell-heights per frame: a gentle, aimable glide (the - // mouse-move sensitivity knob — raise for a faster pointer) - const speed: f32 = @as(f32, @floatFromInt(g.cell_h)) * 0.4; - if (@abs(ax) > deadzone) g.pad_x = std.math.clamp(g.pad_x + ax / 32767.0 * speed, 0, win_w - 1); - if (@abs(ay) > deadzone) g.pad_y = std.math.clamp(g.pad_y + ay / 32767.0 * speed, 0, win_h - 1); - // only on actual stick movement — an unconditional per-frame warp would - // pin the pointer and fight any hardware mouse - if (@abs(ax) > deadzone or @abs(ay) > deadzone) c.SDL_WarpMouseInWindow(g.window, g.pad_x, g.pad_y); - const mc = mouseCell(g, core, g.pad_x, g.pad_y); - if (mc.col != old.col or mc.row != old.row) - core.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = mc.col, .row = mc.row } }); - // left stick = scroll (both axes) - const lx: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_LEFTX)); - const ly: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_LEFTY)); - if (@abs(ly) > deadzone) g.pad_scroll += ly / 32767.0 * 0.15; - if (@abs(lx) > deadzone) g.pad_scroll_h += lx / 32767.0 * 0.15; - while (@abs(g.pad_scroll) >= 1.0) { - const button: pardes.Mouse.Button = if (g.pad_scroll < 0) .wheel_up else .wheel_down; - g.pad_scroll += if (g.pad_scroll < 0) 1.0 else -1.0; - core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = mc.col, .row = mc.row } }); - } - while (@abs(g.pad_scroll_h) >= 1.0) { - const button: pardes.Mouse.Button = if (g.pad_scroll_h < 0) .wheel_left else .wheel_right; - g.pad_scroll_h += if (g.pad_scroll_h < 0) 1.0 else -1.0; - core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = mc.col, .row = mc.row } }); - } -} - -// ===================================================================== -// effects — identical duties to tty.zig's drainEffects, plus SDL clipboard -// ===================================================================== - -fn drainEffects( - core: *pardes.Pardes, - ptys: *[pardes.MAX_PANES]?Pty, - gens: *[pardes.MAX_PANES]u32, - gpa: std.mem.Allocator, - queue: *Queue, - g: ?*Gui, // null in grid test mode (no SDL: clipboard effects are no-ops) - 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. Kill - // it; its detached reader wakes on child death and the gen-guarded - // eof closes the old fd (not here — the reader still reads it). - if (ptys[sp.pane]) |old| { - _ = libc.kill(old.pid, libc.SIG.KILL); - 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 pt = forkShell(cwd_z, core.screen_h, core.screen_w); - ptys[sp.pane] = pt; - // report the pane's starting directory back to the core (tags) - var lbuf: [1024]u8 = undefined; - if (look.shellCwd(pt.pid, &lbuf)) |wd| core.setCwd(sp.pane, wd); - if (threads_ok) spawnReader(gpa, pt, sp.pane, gens[sp.pane], queue); - }, - .write => |w| { - if (ptys[w.pane]) |pt| writeFd(pt.fd, 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.fd, 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); - core.setLastDump(path); - _ = libc.close(fd); - }, - .set_clipboard => { - if (g == null) continue; - const y = core.yank orelse continue; - const z = gpa.dupeZ(u8, y) catch continue; - defer gpa.free(z); - _ = c.SDL_SetClipboardText(z.ptr); - }, - .lsp => |e| if (threads_ok) spawnLsp(core, gpa, queue, e), - .quit => {}, - }; -} - -fn forkShell(cwd: ?[*:0]const u8, rows: u16, cols: u16) Pty { - 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) { - if (cwd) |cd| _ = chdir(cd); - const argv: [4:null]?[*:0]const u8 = .{ bash_path, "--rcfile", "/tmp/pardes-osc133.bash", null }; - _ = execv(bash_path, &argv); - _exit(127); - } - return .{ .fd = master, .pid = pid }; -} - -fn pollCwds(core: *pardes.Pardes, ptys: *[pardes.MAX_PANES]?Pty) void { - for (ptys, 0..) |slot, id| if (slot) |pt| { - var lbuf: [1024]u8 = undefined; - if (look.shellCwd(pt.pid, &lbuf)) |cwd| core.setCwd(id, cwd); - }; -} - -/// web: no ptys, no fs — panes replay from the embedded dump. Only the -/// clipboard and open_link effects have a browser meaning. -fn drainEffectsWeb(core: *pardes.Pardes, gpa: std.mem.Allocator, g: *Gui) void { - _ = g; - while (core.nextEffect()) |effect| switch (effect) { - .set_clipboard => { - const y = core.yank orelse continue; - const z = gpa.dupeZ(u8, y) catch continue; - defer gpa.free(z); - _ = c.SDL_SetClipboardText(z.ptr); - }, - // look on a URL → a new tab - .open_link => |url| openLinkWeb(gpa, url.slice()), - // nothing to spawn/write/resize/save/dump into — all no-ops - .spawn, .write, .resize_pty, .save_file, .write_dump, .lsp, .quit => {}, - }; -} - -/// window.open in a tiny generated script; single quotes and backslashes are -/// %-escaped so the url can't break out of the JS string literal. -fn openLinkWeb(gpa: std.mem.Allocator, url: []const u8) void { - var safe: std.ArrayList(u8) = .empty; - defer safe.deinit(gpa); - for (url) |ch| switch (ch) { - '\'' => safe.appendSlice(gpa, "%27") catch return, - '\\' => safe.appendSlice(gpa, "%5C") catch return, - else => safe.append(gpa, ch) catch return, - }; - const script = std.fmt.allocPrintSentinel(gpa, "window.open('{s}','_blank')", .{safe.items}, 0) catch return; - defer gpa.free(script); - std.os.emscripten.emscripten_run_script(script.ptr); -} - -// ===================================================================== -// render: Surface → instanced quads → SDL GPU -// ===================================================================== - -fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, crt_on: bool, debug_on: bool) !void { - const cmd = c.SDL_AcquireGPUCommandBuffer(g.device) orelse return; - var sw: u32 = 0; - var sh: u32 = 0; - var target: *c.SDL_GPUTexture = undefined; - if (g.capture) { - // capture: render offscreen (the window may never present), dump PPM - sw = @as(u32, surface.cols) * g.cell_w; - sh = @as(u32, surface.rows) * g.cell_h; - if (sw == 0 or sh == 0) { - _ = c.SDL_SubmitGPUCommandBuffer(cmd); - return; - } - try ensureCaptureTexture(g, sw, sh); - target = g.capture_tex.?; - } else { - var swap_tex: ?*c.SDL_GPUTexture = null; - if (!c.SDL_AcquireGPUSwapchainTexture(cmd, g.window, &swap_tex, &sw, &sh)) { - _ = c.SDL_SubmitGPUCommandBuffer(cmd); - return; - } - target = swap_tex orelse { // swapchain busy: skip the frame - _ = c.SDL_SubmitGPUCommandBuffer(cmd); - return; - }; - } - const win_w: f32 = @floatFromInt(sw); - const win_h: f32 = @floatFromInt(sh); - - // Crt on: the scene pass lands in an offscreen texture, then a second - // pass draws it through the CRT shader onto the real target - if (crt_on) try ensureSceneTexture(g, sw, sh); - const scene: *c.SDL_GPUTexture = if (crt_on) g.scene_tex.? else target; - - const overlay_count = buildTouchOverlay(g, sw, sh, debug_on); - if (overlay_count != 0) uploadOverlayGpu(g, cmd, overlay_count); - - const cells: u32 = @as(u32, surface.cols) * surface.rows; - if (cells != 0) try ensureVbuf(g, cells); - - var color_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo); - color_target.texture = scene; - color_target.clear_color = .{ - .r = @as(f32, @floatFromInt(bg_default[0])) / 255.0, - .g = @as(f32, @floatFromInt(bg_default[1])) / 255.0, - .b = @as(f32, @floatFromInt(bg_default[2])) / 255.0, - .a = 1.0, - }; - color_target.load_op = c.SDL_GPU_LOADOP_CLEAR; - color_target.store_op = c.SDL_GPU_STOREOP_STORE; - - if (cells != 0 and g.vbuf != null) { - // ponytail: full re-upload every frame; the prototype's dirty-range - // diffing (cell_keys + coalesced ranges) is skipped for now. - const vptr: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, g.vxfer.?, false) orelse { - _ = c.SDL_SubmitGPUCommandBuffer(cmd); - return; - }); - const instances: [*]CellInstance = @ptrCast(@alignCast(vptr)); - const layout = fixedCellLayout(g); - const cursor_idx: u32 = if (surface.cursor) |cu| @as(u32, cu.y) * surface.cols + cu.x else cells; - const cursor_bar = if (surface.cursor) |cu| cu.bar else false; - var row: u16 = 0; - while (row < surface.rows) : (row += 1) { - var col: u16 = 0; - while (col < surface.cols) : (col += 1) { - const idx: u32 = @as(u32, row) * surface.cols + col; - emitInstance(g, instances, idx, col, row, layout, win_w, win_h, surface.at(col, row), idx == cursor_idx, cursor_bar); - } - } - c.SDL_UnmapGPUTransferBuffer(g.device, g.vxfer.?); - - // emitInstance may have rasterized new glyphs into the staging atlas; - // upload after vertex generation so this frame has what it references - if (g.atlas_dirty) uploadAtlas(g, cmd); - - const copy = c.SDL_BeginGPUCopyPass(cmd); - const src = c.SDL_GPUTransferBufferLocation{ .transfer_buffer = g.vxfer.?, .offset = 0 }; - const dst = c.SDL_GPUBufferRegion{ .buffer = g.vbuf.?, .offset = 0, .size = cells * @sizeOf(CellInstance) }; - c.SDL_UploadToGPUBuffer(copy, &src, &dst, false); - c.SDL_EndGPUCopyPass(copy); - - const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null); - c.SDL_BindGPUGraphicsPipeline(rp, g.pipeline); - const samp_binding = c.SDL_GPUTextureSamplerBinding{ .texture = g.atlas_tex, .sampler = g.atlas_sampler }; - c.SDL_BindGPUFragmentSamplers(rp, 0, &samp_binding, 1); - const vbinding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = 0 }; - c.SDL_BindGPUVertexBuffers(rp, 0, &vbinding, 1); - c.SDL_DrawGPUPrimitives(rp, 6, cells, 0, 0); - drawOverlayGpu(g, rp, overlay_count); - c.SDL_EndGPURenderPass(rp); - } else { - // no grid yet: just clear to the page bg - const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null); - drawOverlayGpu(g, rp, overlay_count); - c.SDL_EndGPURenderPass(rp); - } - - if (crt_on) { - var crt_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo); - crt_target.texture = target; - crt_target.load_op = c.SDL_GPU_LOADOP_DONT_CARE; - crt_target.store_op = c.SDL_GPU_STOREOP_STORE; - const rp = c.SDL_BeginGPURenderPass(cmd, &crt_target, 1, null); - c.SDL_BindGPUGraphicsPipeline(rp, g.crt_pipeline); - const samp_binding = c.SDL_GPUTextureSamplerBinding{ .texture = g.scene_tex.?, .sampler = g.atlas_sampler }; - c.SDL_BindGPUFragmentSamplers(rp, 0, &samp_binding, 1); - c.SDL_DrawGPUPrimitives(rp, 3, 1, 0, 0); - c.SDL_EndGPURenderPass(rp); - } - - if (g.capture) return captureFrame(g, gpa, cmd, target, sw, sh); - _ = c.SDL_SubmitGPUCommandBuffer(cmd); -} - -fn emitInstance( - g: *Gui, - instances: [*]CellInstance, - idx: u32, - col: u16, - row: u16, - layout: CellLayout, - win_w: f32, - win_h: f32, - cell: *const pardes.Cell, - is_cursor: bool, - cursor_bar: bool, -) void { - // resolve the cell style to concrete fg/bg, matching the prototype's - // cellKey: reverse swaps, invisible hides, dim darkens. The cursor cell - // renders as reverse (cancelling an already-reversed cell); an insert - // cursor keeps the cell colors and draws a bar glyph instead. - var fg = fg_default; - var bg = bg_default; - var reverse = is_cursor and !cursor_bar; - if (!cell.default) { - const st = cell.style; - fg = switch (st.fg) { - .default => fg_default, - .index => |i| palColor(i), - .rgb => |rgb| rgb, - }; - bg = switch (st.bg) { - .default => bg_default, - .index => |i| palColor(i), - .rgb => |rgb| rgb, - }; - if (st.reverse) reverse = !reverse; - if (st.invisible) fg = bg; - if (st.dim) for (&fg) |*ch| { - ch.* = @intCast(@as(u16, ch.*) * 6 / 10); - }; - } - if (reverse) std.mem.swap([3]u8, &fg, &bg); - - // cell pixel rect (top-left origin) → NDC (y up); the layout letterboxes on web - const px0 = layout.x_off + @as(f32, @floatFromInt(col)) * layout.w; - const py0 = layout.y_off + @as(f32, @floatFromInt(row)) * layout.h; - const x0 = (px0 / win_w) * 2.0 - 1.0; - const x1 = ((px0 + layout.w) / win_w) * 2.0 - 1.0; - const y0 = 1.0 - (py0 / win_h) * 2.0; - const y1 = 1.0 - ((py0 + layout.h) / win_h) * 2.0; - - const grapheme = cell.grapheme(); - var cp: u32 = if (cell.default or grapheme.len == 0) ' ' else firstCp(grapheme); - // ponytail: the insert bar is the left-eighth block glyph over the cell - // (hides the char under it); a dedicated quad if that ever matters - if (is_cursor and cursor_bar) cp = 0x258F; - const slot = if (cp == ' ') g.space_slot else ensureGlyph(g, cp); - const aw_f: f32 = @floatFromInt(atlas_w); - const ah_f: f32 = @floatFromInt(atlas_h); - - instances[idx] = .{ - .x0 = x0, - .y0 = y0, - .x1 = x1, - .y1 = y1, - .u0 = @as(f32, @floatFromInt(slot.u)) / aw_f, - .v0 = @as(f32, @floatFromInt(slot.v)) / ah_f, - .u1 = @as(f32, @floatFromInt(slot.u + g.cell_w)) / aw_f, - .v1 = @as(f32, @floatFromInt(slot.v + g.cell_h)) / ah_f, - .fr = @as(f32, @floatFromInt(fg[0])) / 255.0, - .fg = @as(f32, @floatFromInt(fg[1])) / 255.0, - .fb = @as(f32, @floatFromInt(fg[2])) / 255.0, - .br = @as(f32, @floatFromInt(bg[0])) / 255.0, - .bg = @as(f32, @floatFromInt(bg[1])) / 255.0, - .bb = @as(f32, @floatFromInt(bg[2])) / 255.0, - }; -} - -fn palColor(idx: u8) [3]u8 { - const p = ghostty_vt.color.default[idx]; - return .{ p.r, p.g, p.b }; -} - -// first codepoint of a UTF-8 grapheme; space on failure/empty -fn firstCp(s: []const u8) u32 { - if (s.len == 0) return ' '; - const n = std.unicode.utf8ByteSequenceLength(s[0]) catch return ' '; - if (n > s.len) return ' '; - return std.unicode.utf8Decode(s[0..n]) catch ' '; -} - -fn ensureGlyph(g: *Gui, cp: u32) Slot { - if (g.glyphs.get(cp)) |s| return s; - if (g.pen_x + g.cell_w > atlas_w) { - g.pen_x = 0; - g.pen_y += g.cell_h; - } - if (g.pen_y + g.cell_h > atlas_h) return g.space_slot; // atlas full: fall back - const s = Slot{ .u = g.pen_x, .v = g.pen_y }; - const out = g.atlas_stage.ptr + @as(usize, s.v) * atlas_w + s.u; - _ = c.ui_font_raster(g.font, g.scale, @intCast(cp), out, @intCast(atlas_w), @intCast(g.cell_w), @intCast(g.cell_h), g.ascent); - g.glyphs.put(cp, s) catch return g.space_slot; - g.pen_x += g.cell_w; - g.atlas_dirty = true; - return s; -} - -fn uploadAtlas(g: *Gui, cmd: *c.SDL_GPUCommandBuffer) void { - const ptr = c.SDL_MapGPUTransferBuffer(g.device, g.atlas_xfer, false) orelse return; - const dst: [*]u8 = @ptrCast(ptr); - @memcpy(dst[0 .. atlas_w * atlas_h], g.atlas_stage); - c.SDL_UnmapGPUTransferBuffer(g.device, g.atlas_xfer); - - const copy = c.SDL_BeginGPUCopyPass(cmd); - const src = c.SDL_GPUTextureTransferInfo{ .transfer_buffer = g.atlas_xfer, .offset = 0, .pixels_per_row = atlas_w, .rows_per_layer = atlas_h }; - const dstregion = c.SDL_GPUTextureRegion{ .texture = g.atlas_tex, .mip_level = 0, .layer = 0, .x = 0, .y = 0, .z = 0, .w = atlas_w, .h = atlas_h, .d = 1 }; - c.SDL_UploadToGPUTexture(copy, &src, &dstregion, false); - c.SDL_EndGPUCopyPass(copy); - g.atlas_dirty = false; -} - -fn ensureVbuf(g: *Gui, cells: u32) !void { - if (g.vbuf_cells == cells and g.vbuf != null) return; - if (g.vbuf) |b| c.SDL_ReleaseGPUBuffer(g.device, b); - if (g.vxfer) |x| c.SDL_ReleaseGPUTransferBuffer(g.device, x); - g.vbuf = null; - g.vxfer = null; - const size = cells * @sizeOf(CellInstance); - var vb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = size, .props = 0 }; - g.vbuf = c.SDL_CreateGPUBuffer(g.device, &vb_info) orelse return error.GpuCreate; - var xf_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = size, .props = 0 }; - g.vxfer = c.SDL_CreateGPUTransferBuffer(g.device, &xf_info) orelse return error.GpuCreate; - g.vbuf_cells = cells; -} - -fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline { - const vs = try makeShader(device, vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0); - defer c.SDL_ReleaseGPUShader(device, vs); - const fs = try makeShader(device, frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1); - defer c.SDL_ReleaseGPUShader(device, fs); - - var vbuf_desc = c.SDL_GPUVertexBufferDescription{ - .slot = 0, - .pitch = @sizeOf(CellInstance), - .input_rate = c.SDL_GPU_VERTEXINPUTRATE_INSTANCE, - .instance_step_rate = 0, - }; - var attrs = [_]c.SDL_GPUVertexAttribute{ - .{ .location = 0, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "x0") }, - .{ .location = 1, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "u0") }, - .{ .location = 2, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT3, .offset = @offsetOf(CellInstance, "fr") }, - .{ .location = 3, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT3, .offset = @offsetOf(CellInstance, "br") }, - }; - var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = std.mem.zeroes(c.SDL_GPUColorTargetBlendState) }; - - var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo); - info.vertex_shader = vs; - info.fragment_shader = fs; - info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST; - info.vertex_input_state = .{ - .vertex_buffer_descriptions = &vbuf_desc, - .num_vertex_buffers = 1, - .vertex_attributes = &attrs, - .num_vertex_attributes = attrs.len, - }; - info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL; - info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE; - info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1; - info.target_info = .{ .color_target_descriptions = &col_desc, .num_color_targets = 1 }; - return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate; -} - -fn makeOverlayPipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline { - const vs = try makeShader(device, overlay_vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0); - defer c.SDL_ReleaseGPUShader(device, vs); - const fs = try makeShader(device, overlay_frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 0); - defer c.SDL_ReleaseGPUShader(device, fs); - - var vbuf_desc = c.SDL_GPUVertexBufferDescription{ - .slot = 0, - .pitch = @sizeOf(OverlayVertex), - .input_rate = c.SDL_GPU_VERTEXINPUTRATE_VERTEX, - .instance_step_rate = 0, - }; - var attrs = [_]c.SDL_GPUVertexAttribute{ - .{ .location = 0, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT2, .offset = @offsetOf(OverlayVertex, "x") }, - .{ .location = 1, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(OverlayVertex, "r") }, - }; - var blend = std.mem.zeroes(c.SDL_GPUColorTargetBlendState); - blend.src_color_blendfactor = c.SDL_GPU_BLENDFACTOR_SRC_ALPHA; - blend.dst_color_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA; - blend.color_blend_op = c.SDL_GPU_BLENDOP_ADD; - blend.src_alpha_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE; - blend.dst_alpha_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA; - blend.alpha_blend_op = c.SDL_GPU_BLENDOP_ADD; - blend.enable_blend = true; - var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = blend }; - - var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo); - info.vertex_shader = vs; - info.fragment_shader = fs; - info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST; - info.vertex_input_state = .{ - .vertex_buffer_descriptions = &vbuf_desc, - .num_vertex_buffers = 1, - .vertex_attributes = &attrs, - .num_vertex_attributes = attrs.len, - }; - info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL; - info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE; - info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1; - info.target_info = .{ .color_target_descriptions = &col_desc, .num_color_targets = 1 }; - return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate; -} - -// the CRT pass: a fullscreen triangle sampling the scene texture, no vertex -// buffers at all (positions from gl_VertexIndex) -fn makeCrtPipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline { - const vs = try makeShader(device, crt_vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0); - defer c.SDL_ReleaseGPUShader(device, vs); - const fs = try makeShader(device, crt_frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1); - defer c.SDL_ReleaseGPUShader(device, fs); - - var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = std.mem.zeroes(c.SDL_GPUColorTargetBlendState) }; - var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo); - info.vertex_shader = vs; - info.fragment_shader = fs; - info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST; - info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL; - info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE; - info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1; - info.target_info = .{ .color_target_descriptions = &col_desc, .num_color_targets = 1 }; - return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate; -} - -fn makeShader(device: *c.SDL_GPUDevice, code: []const u8, stage: c.SDL_GPUShaderStage, n_samplers: u32) !*c.SDL_GPUShader { - var info = std.mem.zeroes(c.SDL_GPUShaderCreateInfo); - info.code = code.ptr; - info.code_size = code.len; - info.entrypoint = "main"; - info.format = c.SDL_GPU_SHADERFORMAT_SPIRV; - info.stage = stage; - info.num_samplers = n_samplers; - return c.SDL_CreateGPUShader(device, &info) orelse error.GpuCreate; -} - -// ===================================================================== -// web render: Surface → the same instanced quads → WebGL2. Full instance -// re-upload each frame; attribs re-specified per draw (no VAO to manage). -// ===================================================================== - -fn renderFrameGl(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, debug_on: bool) !void { - var pw: c_int = 0; - var ph: c_int = 0; - _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); - const sw: u32 = @intCast(@max(pw, 1)); - const sh: u32 = @intCast(@max(ph, 1)); - c.glViewport(0, 0, @intCast(sw), @intCast(sh)); - c.glClearColor( - @as(f32, @floatFromInt(bg_default[0])) / 255.0, - @as(f32, @floatFromInt(bg_default[1])) / 255.0, - @as(f32, @floatFromInt(bg_default[2])) / 255.0, - 1.0, - ); - c.glClear(c.GL_COLOR_BUFFER_BIT); - - const overlay_count = buildTouchOverlay(g, sw, sh, debug_on); - const cells: u32 = @as(u32, surface.cols) * surface.rows; - if (cells == 0) { - drawOverlayGl(g, overlay_count); - _ = c.SDL_GL_SwapWindow(g.window); - return; - } - if (g.gl_instances.len < cells) { - gpa.free(g.gl_instances); - g.gl_instances = try gpa.alloc(CellInstance, cells); - } - - const win_w: f32 = @floatFromInt(sw); - const win_h: f32 = @floatFromInt(sh); - const layout = cellLayout(g, surface.cols, surface.rows, sw, sh); - const cursor_idx: u32 = if (surface.cursor) |cu| @as(u32, cu.y) * surface.cols + cu.x else cells; - const cursor_bar = if (surface.cursor) |cu| cu.bar else false; - var row: u16 = 0; - while (row < surface.rows) : (row += 1) { - var col: u16 = 0; - while (col < surface.cols) : (col += 1) { - const idx: u32 = @as(u32, row) * surface.cols + col; - emitInstance(g, g.gl_instances.ptr, idx, col, row, layout, win_w, win_h, surface.at(col, row), idx == cursor_idx, cursor_bar); - } - } - if (g.atlas_dirty) uploadAtlasGl(g); - - c.glUseProgram(g.gl_program); - c.glActiveTexture(c.GL_TEXTURE0); - c.glBindTexture(c.GL_TEXTURE_2D, g.gl_atlas_tex); - c.glUniform1i(g.gl_u_atlas, 0); - c.glBindBuffer(c.GL_ARRAY_BUFFER, g.gl_vbo); - c.glBufferData(c.GL_ARRAY_BUFFER, @intCast(cells * @sizeOf(CellInstance)), g.gl_instances.ptr, c.GL_STREAM_DRAW); - const stride: c_int = @intCast(@sizeOf(CellInstance)); - c.glEnableVertexAttribArray(0); - c.glVertexAttribPointer(0, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "x0"))); - c.glVertexAttribDivisor(0, 1); - c.glEnableVertexAttribArray(1); - c.glVertexAttribPointer(1, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "u0"))); - c.glVertexAttribDivisor(1, 1); - c.glEnableVertexAttribArray(2); - c.glVertexAttribPointer(2, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "fr"))); - c.glVertexAttribDivisor(2, 1); - c.glEnableVertexAttribArray(3); - c.glVertexAttribPointer(3, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "br"))); - c.glVertexAttribDivisor(3, 1); - c.glDrawArraysInstanced(c.GL_TRIANGLES, 0, 6, @intCast(cells)); - drawOverlayGl(g, overlay_count); - _ = c.SDL_GL_SwapWindow(g.window); -} - -fn uploadAtlasGl(g: *Gui) void { - c.glBindTexture(c.GL_TEXTURE_2D, g.gl_atlas_tex); - c.glPixelStorei(c.GL_UNPACK_ALIGNMENT, 1); - c.glTexSubImage2D(c.GL_TEXTURE_2D, 0, 0, 0, atlas_w, atlas_h, c.GL_RED, c.GL_UNSIGNED_BYTE, g.atlas_stage.ptr); - g.atlas_dirty = false; -} - -fn drawOverlayGl(g: *Gui, vertex_count: u32) void { - if (vertex_count == 0) return; - c.glUseProgram(g.gl_overlay_program); - c.glBindBuffer(c.GL_ARRAY_BUFFER, g.gl_overlay_vbo); - c.glBufferData(c.GL_ARRAY_BUFFER, @intCast(vertex_count * @sizeOf(OverlayVertex)), g.overlay_vertices.ptr, c.GL_STREAM_DRAW); - const stride: c_int = @intCast(@sizeOf(OverlayVertex)); - c.glEnableVertexAttribArray(0); - c.glVertexAttribPointer(0, 2, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(OverlayVertex, "x"))); - c.glVertexAttribDivisor(0, 0); - c.glEnableVertexAttribArray(1); - c.glVertexAttribPointer(1, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(OverlayVertex, "r"))); - c.glVertexAttribDivisor(1, 0); - c.glDisableVertexAttribArray(2); - c.glDisableVertexAttribArray(3); - c.glEnable(c.GL_BLEND); - c.glBlendFuncSeparate(c.GL_SRC_ALPHA, c.GL_ONE_MINUS_SRC_ALPHA, c.GL_ONE, c.GL_ONE_MINUS_SRC_ALPHA); - c.glDrawArrays(c.GL_TRIANGLES, 0, @intCast(vertex_count)); - c.glDisable(c.GL_BLEND); -} - -fn makeGlProgramFrom(vertex_source: []const u8, fragment_source: []const u8) !c_uint { - const vs = try makeGlShader(c.GL_VERTEX_SHADER, vertex_source); - defer c.glDeleteShader(vs); - const fs = try makeGlShader(c.GL_FRAGMENT_SHADER, fragment_source); - defer c.glDeleteShader(fs); - const program = c.glCreateProgram(); - if (program == 0) return error.GpuCreate; - c.glAttachShader(program, vs); - c.glAttachShader(program, fs); - c.glLinkProgram(program); - var ok: c_int = 0; - c.glGetProgramiv(program, c.GL_LINK_STATUS, &ok); - if (ok == 0) { - log.err("WebGL program link failed", .{}); - c.glDeleteProgram(program); - return error.GpuCreate; - } - return program; -} - -fn makeGlShader(kind: c_uint, source: []const u8) !c_uint { - const shader = c.glCreateShader(kind); - if (shader == 0) return error.GpuCreate; - var src_ptr = [_][*c]const u8{@ptrCast(source.ptr)}; - var src_len = [_]c_int{@intCast(source.len)}; - c.glShaderSource(shader, 1, &src_ptr, &src_len); - c.glCompileShader(shader); - var ok: c_int = 0; - c.glGetShaderiv(shader, c.GL_COMPILE_STATUS, &ok); - if (ok == 0) { - log.err("WebGL shader compile failed", .{}); - c.glDeleteShader(shader); - return error.GpuCreate; - } - return shader; -} - -// ===================================================================== -// PARDES_TEST frame capture: download the render target, write latest.ppm -// ===================================================================== - -fn ensureSceneTexture(g: *Gui, width: u32, height: u32) !void { - if (g.scene_tex != null and g.scene_tex_w == width and g.scene_tex_h == height) return; - if (g.scene_tex) |t| c.SDL_ReleaseGPUTexture(g.device, t); - var info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); - info.type = c.SDL_GPU_TEXTURETYPE_2D; - info.format = g.swapchain_format; - info.usage = c.SDL_GPU_TEXTUREUSAGE_COLOR_TARGET | c.SDL_GPU_TEXTUREUSAGE_SAMPLER; - info.width = width; - info.height = height; - info.layer_count_or_depth = 1; - info.num_levels = 1; - info.sample_count = c.SDL_GPU_SAMPLECOUNT_1; - g.scene_tex = c.SDL_CreateGPUTexture(g.device, &info) orelse return error.GpuCreate; - g.scene_tex_w = width; - g.scene_tex_h = height; -} - -fn ensureCaptureTexture(g: *Gui, width: u32, height: u32) !void { - if (g.capture_tex != null and g.capture_tex_w == width and g.capture_tex_h == height) return; - if (g.capture_tex) |t| c.SDL_ReleaseGPUTexture(g.device, t); - var info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); - info.type = c.SDL_GPU_TEXTURETYPE_2D; - info.format = g.swapchain_format; - info.usage = c.SDL_GPU_TEXTUREUSAGE_COLOR_TARGET; - info.width = width; - info.height = height; - info.layer_count_or_depth = 1; - info.num_levels = 1; - info.sample_count = c.SDL_GPU_SAMPLECOUNT_1; - g.capture_tex = c.SDL_CreateGPUTexture(g.device, &info) orelse return error.GpuCreate; - g.capture_tex_w = width; - g.capture_tex_h = height; -} - -fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, target: *c.SDL_GPUTexture, sw: u32, sh: u32) !void { - const size = c.SDL_CalculateGPUTextureFormatSize(g.swapchain_format, sw, sh, 1); - if (size == 0) { - _ = c.SDL_SubmitGPUCommandBuffer(cmd); - return error.UnsupportedCaptureFormat; - } - if (g.capture_xfer == null or g.capture_xfer_size < size) { - if (g.capture_xfer) |x| c.SDL_ReleaseGPUTransferBuffer(g.device, x); - var info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_DOWNLOAD, .size = size, .props = 0 }; - g.capture_xfer = c.SDL_CreateGPUTransferBuffer(g.device, &info) orelse { - _ = c.SDL_SubmitGPUCommandBuffer(cmd); - return error.GpuCreate; - }; - g.capture_xfer_size = size; - } - const xfer = g.capture_xfer.?; - const copy = c.SDL_BeginGPUCopyPass(cmd); - const src = c.SDL_GPUTextureRegion{ .texture = target, .mip_level = 0, .layer = 0, .x = 0, .y = 0, .z = 0, .w = sw, .h = sh, .d = 1 }; - const dst = c.SDL_GPUTextureTransferInfo{ .transfer_buffer = xfer, .offset = 0, .pixels_per_row = sw, .rows_per_layer = sh }; - c.SDL_DownloadFromGPUTexture(copy, &src, &dst); - c.SDL_EndGPUCopyPass(copy); - - const fence = c.SDL_SubmitGPUCommandBufferAndAcquireFence(cmd) orelse return error.GpuSubmit; - defer c.SDL_ReleaseGPUFence(g.device, fence); - var fences = [_]*c.SDL_GPUFence{fence}; - if (!c.SDL_WaitForGPUFences(g.device, true, &fences, 1)) return error.GpuSubmit; - - const mapped = c.SDL_MapGPUTransferBuffer(g.device, xfer, false) orelse return error.GpuMap; - defer c.SDL_UnmapGPUTransferBuffer(g.device, xfer); - const pixels: [*]const u8 = @ptrCast(mapped); - try writeCapturePpm(g, gpa, pixels[0..size], sw, sh); -} - -fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u32, height: u32) !void { - if (g.capture_dir.len == 0) return; - const bpp = c.SDL_GPUTextureFormatTexelBlockSize(g.swapchain_format); - if (bpp != 4) return error.UnsupportedCaptureFormat; - const bgr = switch (g.swapchain_format) { - c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM, c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM_SRGB => false, - c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM, c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM_SRGB => true, - else => return error.UnsupportedCaptureFormat, - }; - - var tmp_buf: [4096]u8 = undefined; - var final_buf: [4096]u8 = undefined; - const tmp_path = std.fmt.bufPrintSentinel(&tmp_buf, "{s}/latest.ppm.tmp", .{g.capture_dir}, 0) catch return error.CapturePathTooLong; - const final_path = std.fmt.bufPrintSentinel(&final_buf, "{s}/latest.ppm", .{g.capture_dir}, 0) catch return error.CapturePathTooLong; - - const fd = libc.open(tmp_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); - if (fd < 0) return error.CaptureWriteFailed; - defer _ = libc.close(fd); - - var header: [64]u8 = undefined; - const hdr = std.fmt.bufPrint(&header, "P6\n{d} {d}\n255\n", .{ width, height }) catch return error.CaptureWriteFailed; - writeFd(fd, hdr); - - const row_rgb = try gpa.alloc(u8, @as(usize, width) * 3); - defer gpa.free(row_rgb); - for (0..height) |row| { - const src = pixels[row * width * 4 ..][0 .. @as(usize, width) * 4]; - for (0..width) |x| { - const si = x * 4; - const di = x * 3; - row_rgb[di + 0] = src[si + if (bgr) @as(usize, 2) else 0]; - row_rgb[di + 1] = src[si + 1]; - row_rgb[di + 2] = src[si + if (bgr) @as(usize, 0) else 2]; - } - writeFd(fd, row_rgb); - } - if (libc.rename(tmp_path, final_path) != 0) return error.CaptureWriteFailed; -} - -// ===================================================================== -// touch debug overlay: per-finger colored circles + trails + a click-action -// flash HUD, alpha-blended over the grid only while the Debug builtin is on. -// ===================================================================== - -fn buildTouchOverlay(g: *Gui, sw: u32, sh: u32, debug_on: bool) u32 { - if (!debug_on) { - g.touch.click_flash_frames = 0; - return 0; - } - if (g.overlay_vertices.len == 0 or sw == 0 or sh == 0) return 0; - const win_w: f32 = @floatFromInt(sw); - const win_h: f32 = @floatFromInt(sh); - var builder = OverlayBuilder{ .vertices = g.overlay_vertices, .win_w = win_w, .win_h = win_h }; - const t = &g.touch; - - if (t.click_flash_frames != 0) { - const flash = @as(f32, @floatFromInt(t.click_flash_frames)) / @as(f32, @floatFromInt(touch_click_flash_max_frames)); - const label = touchClickLabel(t.click_count, t.click_button); - const glyph_scale = 3.0; - const char_step = 4.0 * glyph_scale; - const pad = 5.0; - const stripe_w = 4.0; - const label_w = pad * 2.0 + stripe_w + 3.0 + @as(f32, @floatFromInt(label.len)) * char_step - glyph_scale; - const label_h = pad * 2.0 + 5.0 * glyph_scale; - builder.addRect(0.0, 0.0, win_w, win_h, .{ .r = 1.0, .g = 0.78, .b = 0.18, .a = 0.055 * flash }); - builder.addRect(8.0, 8.0, 8.0 + label_w, 8.0 + label_h, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.54 + 0.22 * flash }); - builder.addRect(8.0 + pad, 8.0 + pad, 8.0 + pad + stripe_w, 8.0 + label_h - pad, .{ .r = 1.0, .g = 0.78, .b = 0.18, .a = 0.96 }); - builder.addMiniText(label[0..], 8.0 + pad + stripe_w + 3.0, 8.0 + pad, glyph_scale, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.96 }); - t.click_flash_frames -= 1; - } - - for (&t.points) |*tp| { - if (!tp.active) continue; - const cx = std.math.clamp(tp.x, 0.0, 1.0) * win_w; - const cy = std.math.clamp(tp.y, 0.0, 1.0) * win_h; - const radius = (12.0 + std.math.clamp(tp.pressure, 0.0, 1.0) * 4.0) * 4.0; - const color = touchColor(tp.id); - - if (tp.trail_len > 1) { - const first = if (tp.trail_len == max_touch_trail_points) tp.trail_next else 0; - for (0..tp.trail_len - 1) |trail_i| { - const sample = tp.trail[(first + trail_i) % max_touch_trail_points]; - const fade = @as(f32, @floatFromInt(trail_i + 1)) / @as(f32, @floatFromInt(tp.trail_len)); - var trail_color = color; - trail_color.a *= 0.42 * fade; - const tx = std.math.clamp(sample.x, 0.0, 1.0) * win_w; - const ty = std.math.clamp(sample.y, 0.0, 1.0) * win_h; - const trail_radius = (12.0 + std.math.clamp(sample.pressure, 0.0, 1.0) * 4.0) * 2.8; - builder.addCircle(tx, ty, trail_radius, trail_color); - } - } - - builder.addCircle(cx, cy, radius + 4.0, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.62 }); - builder.addCircle(cx, cy, radius, color); - builder.addCircle(cx, cy, 2.4, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.92 }); - builder.addIdTag(tp.id, cx, cy, color); - } - return @intCast(builder.len); -} - -fn uploadOverlayGpu(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, vertex_count: u32) void { - const byte_len: u32 = vertex_count * @sizeOf(OverlayVertex); - const ptr = c.SDL_MapGPUTransferBuffer(g.device, g.overlay_vxfer, false) orelse return; - const dst: [*]u8 = @ptrCast(ptr); - const src: [*]const u8 = @ptrCast(g.overlay_vertices.ptr); - @memcpy(dst[0..byte_len], src[0..byte_len]); - c.SDL_UnmapGPUTransferBuffer(g.device, g.overlay_vxfer); - const copy = c.SDL_BeginGPUCopyPass(cmd); - const src_loc = c.SDL_GPUTransferBufferLocation{ .transfer_buffer = g.overlay_vxfer, .offset = 0 }; - const dst_region = c.SDL_GPUBufferRegion{ .buffer = g.overlay_vbuf, .offset = 0, .size = byte_len }; - c.SDL_UploadToGPUBuffer(copy, &src_loc, &dst_region, false); - c.SDL_EndGPUCopyPass(copy); -} - -fn drawOverlayGpu(g: *Gui, rp: ?*c.SDL_GPURenderPass, vertex_count: u32) void { - if (vertex_count == 0) return; - const pass = rp orelse return; - c.SDL_BindGPUGraphicsPipeline(pass, g.overlay_pipeline); - const binding = c.SDL_GPUBufferBinding{ .buffer = g.overlay_vbuf, .offset = 0 }; - c.SDL_BindGPUVertexBuffers(pass, 0, &binding, 1); - c.SDL_DrawGPUPrimitives(pass, vertex_count, 1, 0, 0); -} - -fn touchColor(id: u64) OverlayColor { - const palette = [_]OverlayColor{ - .{ .r = 0.10, .g = 0.72, .b = 1.00, .a = 0.88 }, - .{ .r = 1.00, .g = 0.32, .b = 0.50, .a = 0.88 }, - .{ .r = 0.35, .g = 0.90, .b = 0.38, .a = 0.88 }, - .{ .r = 1.00, .g = 0.78, .b = 0.18, .a = 0.88 }, - .{ .r = 0.78, .g = 0.52, .b = 1.00, .a = 0.88 }, - .{ .r = 0.10, .g = 0.88, .b = 0.75, .a = 0.88 }, - .{ .r = 1.00, .g = 0.48, .b = 0.18, .a = 0.88 }, - .{ .r = 0.78, .g = 0.90, .b = 1.00, .a = 0.88 }, - }; - return palette[@intCast(id % palette.len)]; -} - -const OverlayBuilder = struct { - vertices: []OverlayVertex, - len: usize = 0, - win_w: f32, - win_h: f32, - - fn addVertex(b: *OverlayBuilder, px: f32, py: f32, color: OverlayColor) void { - if (b.len >= b.vertices.len) return; - b.vertices[b.len] = .{ - .x = (px / b.win_w) * 2.0 - 1.0, - .y = 1.0 - (py / b.win_h) * 2.0, - .r = color.r, - .g = color.g, - .b = color.b, - .a = color.a, - }; - b.len += 1; - } - - fn addTri(b: *OverlayBuilder, x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32, color: OverlayColor) void { - if (b.len + 3 > b.vertices.len) return; - b.addVertex(x0, y0, color); - b.addVertex(x1, y1, color); - b.addVertex(x2, y2, color); - } - - fn addRect(b: *OverlayBuilder, x0_in: f32, y0_in: f32, x1_in: f32, y1_in: f32, color: OverlayColor) void { - if (b.len + 6 > b.vertices.len) return; - const x0 = std.math.clamp(@min(x0_in, x1_in), 0.0, b.win_w); - const x1 = std.math.clamp(@max(x0_in, x1_in), 0.0, b.win_w); - const y0 = std.math.clamp(@min(y0_in, y1_in), 0.0, b.win_h); - const y1 = std.math.clamp(@max(y0_in, y1_in), 0.0, b.win_h); - if (x1 <= x0 or y1 <= y0) return; - b.addTri(x0, y0, x1, y0, x1, y1, color); - b.addTri(x0, y0, x1, y1, x0, y1, color); - } - - fn addCircle(b: *OverlayBuilder, cx: f32, cy: f32, radius: f32, color: OverlayColor) void { - var i: usize = 0; - while (i + 1 < circle_points.len) : (i += 1) { - const p0 = circle_points[i]; - const p1 = circle_points[i + 1]; - b.addTri(cx, cy, cx + p0[0] * radius, cy + p0[1] * radius, cx + p1[0] * radius, cy + p1[1] * radius, color); - } - } - - fn addIdTag(b: *OverlayBuilder, id: u64, cx: f32, cy: f32, color: OverlayColor) void { - const label = touchIdLabel(id); - const glyph_scale = 2.0; - const char_step = 4.0 * glyph_scale; - const pad = 4.0; - const stripe_w = 3.0; - const label_w = pad * 2.0 + stripe_w + 3.0 + @as(f32, @floatFromInt(label.len)) * char_step - glyph_scale; - const label_h = pad * 2.0 + 5.0 * glyph_scale; - - var x = cx + 20.0; - if (x + label_w > b.win_w - 2.0) x = cx - label_w - 20.0; - x = std.math.clamp(x, 2.0, @max(2.0, b.win_w - label_w - 2.0)); - var y = cy - label_h - 10.0; - if (y < 2.0) y = cy + 20.0; - y = std.math.clamp(y, 2.0, @max(2.0, b.win_h - label_h - 2.0)); - - b.addRect(x, y, x + label_w, y + label_h, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.72 }); - b.addRect(x + pad, y + pad, x + pad + stripe_w, y + label_h - pad, color); - b.addMiniText(label[0..], x + pad + stripe_w + 3.0, y + pad, glyph_scale, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.95 }); - } - - fn addMiniText(b: *OverlayBuilder, text: []const u8, x: f32, y: f32, scale: f32, color: OverlayColor) void { - var cx = x; - for (text) |ch| { - const rows = miniGlyph(ch); - const masks = [_]u8{ 0b100, 0b010, 0b001 }; - for (rows, 0..) |bits, row| { - for (masks, 0..) |mask, colidx| { - if ((bits & mask) == 0) continue; - const x0 = cx + @as(f32, @floatFromInt(colidx)) * scale; - const y0 = y + @as(f32, @floatFromInt(row)) * scale; - b.addRect(x0, y0, x0 + scale, y0 + scale, color); - } - } - cx += 4.0 * scale; - } - } -}; - -const circle_points = [_][2]f32{ - .{ 1.0000000, 0.0000000 }, .{ 0.9238795, 0.3826834 }, .{ 0.7071068, 0.7071068 }, .{ 0.3826834, 0.9238795 }, - .{ 0.0000000, 1.0000000 }, .{ -0.3826834, 0.9238795 }, .{ -0.7071068, 0.7071068 }, .{ -0.9238795, 0.3826834 }, - .{ -1.0000000, 0.0000000 }, .{ -0.9238795, -0.3826834 }, .{ -0.7071068, -0.7071068 }, .{ -0.3826834, -0.9238795 }, - .{ 0.0000000, -1.0000000 }, .{ 0.3826834, -0.9238795 }, .{ 0.7071068, -0.7071068 }, .{ 0.9238795, -0.3826834 }, - .{ 1.0000000, 0.0000000 }, -}; - -fn touchIdLabel(id: u64) [9]u8 { - var out: [9]u8 = undefined; - out[0] = '#'; - const low: u32 = @truncate(id); - for (0..8) |i| { - const shift: u5 = @intCast((7 - i) * 4); - out[i + 1] = hexDigit((low >> shift) & 0xf); - } - return out; -} - -fn touchClickLabel(count: u32, button: pardes.Mouse.Button) [14]u8 { - var out: [14]u8 = undefined; - @memcpy(out[0..6], if (button == .right) "LOOK #" else "MID #"); - for (0..8) |i| { - const shift: u5 = @intCast((7 - i) * 4); - out[i + 6] = hexDigit((count >> shift) & 0xf); - } - return out; -} - -fn hexDigit(n: u32) u8 { - return if (n < 10) @intCast('0' + n) else @intCast('A' + (n - 10)); -} - -fn miniGlyph(ch: u8) [5]u8 { - return switch (ch) { - '#' => .{ 0b101, 0b111, 0b101, 0b111, 0b101 }, - '0' => .{ 0b111, 0b101, 0b101, 0b101, 0b111 }, - '1' => .{ 0b010, 0b110, 0b010, 0b010, 0b111 }, - '2' => .{ 0b111, 0b001, 0b111, 0b100, 0b111 }, - '3' => .{ 0b111, 0b001, 0b111, 0b001, 0b111 }, - '4' => .{ 0b101, 0b101, 0b111, 0b001, 0b001 }, - '5' => .{ 0b111, 0b100, 0b111, 0b001, 0b111 }, - '6' => .{ 0b111, 0b100, 0b111, 0b101, 0b111 }, - '7' => .{ 0b111, 0b001, 0b010, 0b010, 0b010 }, - '8' => .{ 0b111, 0b101, 0b111, 0b101, 0b111 }, - '9' => .{ 0b111, 0b101, 0b111, 0b001, 0b111 }, - 'A' => .{ 0b111, 0b101, 0b111, 0b101, 0b101 }, - 'B' => .{ 0b110, 0b101, 0b110, 0b101, 0b110 }, - 'C' => .{ 0b111, 0b100, 0b100, 0b100, 0b111 }, - 'D' => .{ 0b110, 0b101, 0b101, 0b101, 0b110 }, - 'E' => .{ 0b111, 0b100, 0b111, 0b100, 0b111 }, - 'F' => .{ 0b111, 0b100, 0b111, 0b100, 0b100 }, - 'I' => .{ 0b111, 0b010, 0b010, 0b010, 0b111 }, - 'K' => .{ 0b101, 0b101, 0b110, 0b101, 0b101 }, - 'L' => .{ 0b100, 0b100, 0b100, 0b100, 0b111 }, - 'M' => .{ 0b101, 0b111, 0b111, 0b101, 0b101 }, - 'O' => .{ 0b111, 0b101, 0b101, 0b101, 0b111 }, - else => .{ 0, 0, 0, 0, 0 }, - }; -} - -// ===================================================================== -// shared plumbing (same shapes as tty.zig) -// ===================================================================== - -/// 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()) |cp| { - if (it.nextCodepoint() == null) return cp; - } - } - 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 envU16(env: *std.process.Environ.Map, name: []const u8) ?u16 { - const raw = env.get(name) orelse return null; - return std.fmt.parseInt(u16, raw, 10) catch null; -} - -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); - } -} diff --git a/src/gui/crt.zig b/src/gui/crt.zig new file mode 100644 index 00000000..9cf81593 --- /dev/null +++ b/src/gui/crt.zig @@ -0,0 +1,43 @@ +//! The Crt builtin's screen geometry, shared between the gui shell's input +//! mapping and its shader: crt.frag.glsl shows the scene point +//! uv' = cc*(1 + barrel*r2) at screen point cc, so a physical mouse position +//! must pass through the SAME forward transform before it becomes a cell. +//! Pure std, so the unit-test step can pin it against the shader formula. + +const std = @import("std"); + +/// MUST match BARREL in shaders/crt.frag.glsl +pub const barrel: f32 = 0.012; + +pub const Point = struct { x: f32, y: f32 }; + +/// physical window pixels -> the scene pixels the shader displays there, +/// clamped to the window. The shader's phosphor quantization and chromatic +/// offset displace at most a few pixels — well under a cell — and are +/// ignored here. +pub fn map(x: f32, y: f32, w: f32, h: f32) Point { + const ccx = x / w * 2.0 - 1.0; + const ccy = y / h * 2.0 - 1.0; + const s = 1.0 + barrel * (ccx * ccx + ccy * ccy); + return .{ + .x = std.math.clamp((ccx * s + 1.0) * 0.5 * w, 0, w - 1), + .y = std.math.clamp((ccy * s + 1.0) * 0.5 * h, 0, h - 1), + }; +} + +test "map matches the shader's barrel formula" { + // center is a fixed point + try std.testing.expectApproxEqAbs(@as(f32, 500), map(500, 250, 1000, 500).x, 0.001); + try std.testing.expectApproxEqAbs(@as(f32, 250), map(500, 250, 1000, 500).y, 0.001); + // corners: cc=(±1,±1), r2=2, scale 1.024 pushes past the tube -> clamped + try std.testing.expectEqual(@as(f32, 0), map(0, 0, 1000, 500).x); + try std.testing.expectEqual(@as(f32, 0), map(0, 0, 1000, 500).y); + try std.testing.expectEqual(@as(f32, 999), map(1000, 500, 1000, 500).x); + try std.testing.expectEqual(@as(f32, 499), map(1000, 500, 1000, 500).y); + // (100,100) of 1000x500: cc=(-0.8,-0.6), r2=1.0, s=1.012 -> + // cc'=(-0.8096,-0.6072) -> (95.2, 98.2) + try std.testing.expectApproxEqAbs(@as(f32, 95.2), map(100, 100, 1000, 500).x, 0.01); + try std.testing.expectApproxEqAbs(@as(f32, 98.2), map(100, 100, 1000, 500).y, 0.01); + // (900,250): cc=(0.8,0), r2=0.64, s=1.00768 -> x'=903.072 + try std.testing.expectApproxEqAbs(@as(f32, 903.072), map(900, 250, 1000, 500).x, 0.01); +} diff --git a/src/gui/deck.zig b/src/gui/deck.zig new file mode 100644 index 00000000..1859bae8 --- /dev/null +++ b/src/gui/deck.zig @@ -0,0 +1,204 @@ +//! Steam Deck controls -> editor input, as a pure state machine: gui.zig +//! feeds SDL gamepad events in and applies the returned actions (cursor +//! moves, synthetic clicks, wheel ticks, keys, rumble), so the whole +//! mapping replays off-device from deckcap recordings (test/deck/). +//! +//! Layout: right pad moves the pointer (relative, laptop-touchpad-style) and +//! a firm press on it = select (button 1); left pad scrolls and a firm press +//! = look (button 3); R2 = select, L2 = execute (button 2), L1 = look, dpad +//! down/up = n/N, A = Enter, Y = Tab, the back paddles (L4/R4/L5/R5) toggle +//! the terminal (Ctrl-b); execute, look and pad-clicks request a brief rumble +//! ack. (The GUI mirrors this on the sticks: right stick = pointer, left +//! stick = scroll.) + +const std = @import("std"); + +// SDL_GamepadButton / SDL_GamepadAxis / touchpad indices (SDL3), verified +// against real deck recordings (test/deck/*.zon) and the pinned SDL's +// hidapi deck driver (touchpad 0 = left pad, 1 = right pad). +pub const BTN_A = 0; // SOUTH +pub const BTN_Y = 3; // NORTH +pub const BTN_L1 = 9; // LEFT_SHOULDER +pub const BTN_DPAD_UP = 11; +pub const BTN_DPAD_DOWN = 12; +// rear grip paddles (from a deckcap deckmap capture): the two-digit indices +pub const BTN_R4 = 16; +pub const BTN_L4 = 17; +pub const BTN_R5 = 18; +pub const BTN_L5 = 19; +pub const AXIS_L2 = 4; // LEFT_TRIGGER, analog 0..32767 +pub const AXIS_R2 = 5; // RIGHT_TRIGGER +pub const PAD_LEFT = 0; +pub const PAD_RIGHT = 1; + +/// feel knobs: a full-pad finger sweep covers this many screen pixels +/// (the deck's screen is 1280x800) / this many wheel ticks +pub const POINTER_SCALE: f32 = 600.0; +pub const SCROLL_SCALE: f32 = 24.0; +/// the pinned SDL 3.4.4 deck driver skips upstream's pad-Y negation, so +/// touch y arrives 0 = pad bottom; flip this when the sdl dep is bumped +pub const PAD_Y_SIGN: f32 = -1.0; +// analog triggers click at 30% and release at 20%: the gap keeps a value +// hovering near one threshold from chattering press/release pairs +pub const TRIG_PRESS: i16 = 9830; +pub const TRIG_RELEASE: i16 = 6553; +// a firm pad press (pressure 0..1) counts as a click, with the same release +// hysteresis. Real light-touch drags read ~0.03 in captures, so this is well +// clear of them; tune on-device against a real click capture. +pub const PAD_CLICK_PRESS: f32 = 0.10; +pub const PAD_CLICK_RELEASE: f32 = 0.06; + +pub const Input = union(enum) { + button: struct { idx: u8, down: bool }, + axis: struct { idx: u8, value: i16 }, + touch: struct { pad: u8, phase: enum { down, motion, up }, x: f32, y: f32, pressure: f32 }, +}; + +pub const Click = enum { select, execute, look }; // mouse buttons 1/2/3 +pub const Keytap = enum { n, cap_n, enter, tab, tty_toggle }; + +pub const Action = union(enum) { + move: struct { dx: f32, dy: f32 }, // pointer delta in screen pixels + click: struct { which: Click, down: bool }, + wheel: i32, // whole ticks, positive = wheel_down + key: Keytap, + rumble, +}; + +pub const Deck = struct { + rpad_on: bool = false, + rpad_x: f32 = 0, + rpad_y: f32 = 0, + lpad_on: bool = false, + lpad_y: f32 = 0, + scroll: f32 = 0, // fractional wheel ticks carried between motions + l2_held: bool = false, + r2_held: bool = false, + look_held: bool = false, + rpad_click: bool = false, // firm-press click state, per pad (hysteresis) + lpad_click: bool = false, + + pub fn feed(d: *Deck, in: Input, out: *[3]Action) usize { + var n: usize = 0; + switch (in) { + .button => |b| switch (b.idx) { + BTN_A => if (b.down) { + out[0] = .{ .key = .enter }; + n = 1; + }, + BTN_Y => if (b.down) { + out[0] = .{ .key = .tab }; + n = 1; + }, + BTN_DPAD_UP => if (b.down) { + out[0] = .{ .key = .cap_n }; + n = 1; + }, + BTN_DPAD_DOWN => if (b.down) { + out[0] = .{ .key = .n }; + n = 1; + }, + // any back paddle toggles the terminal (Ctrl-b), applied in gui.zig + BTN_L4, BTN_R4, BTN_L5, BTN_R5 => if (b.down) { + out[0] = .{ .key = .tty_toggle }; + n = 1; + }, + BTN_L1 => { + d.look_held = b.down; + out[0] = .{ .click = .{ .which = .look, .down = b.down } }; + n = 1; + if (b.down) { + out[1] = .rumble; + n = 2; + } + }, + else => {}, + }, + .axis => |a| { + if (a.idx != AXIS_L2 and a.idx != AXIS_R2) return 0; + const held = if (a.idx == AXIS_L2) &d.l2_held else &d.r2_held; + const which: Click = if (a.idx == AXIS_L2) .execute else .select; + if (!held.* and a.value >= TRIG_PRESS) { + held.* = true; + out[0] = .{ .click = .{ .which = which, .down = true } }; + n = 1; + if (which == .execute) { + out[1] = .rumble; + n = 2; + } + } else if (held.* and a.value <= TRIG_RELEASE) { + held.* = false; + out[0] = .{ .click = .{ .which = which, .down = false } }; + n = 1; + } + }, + .touch => |t| { + switch (t.pad) { + PAD_RIGHT => switch (t.phase) { + // a fresh touch only re-anchors — deltas start from the + // landing point, so the pointer never jumps to it + .down => { + d.rpad_on = true; + d.rpad_x = t.x; + d.rpad_y = t.y; + }, + .motion => if (d.rpad_on) { + out[n] = .{ .move = .{ + .dx = (t.x - d.rpad_x) * POINTER_SCALE, + .dy = (t.y - d.rpad_y) * PAD_Y_SIGN * POINTER_SCALE, + } }; + n += 1; + d.rpad_x = t.x; + d.rpad_y = t.y; + }, + .up => d.rpad_on = false, + }, + PAD_LEFT => switch (t.phase) { + .down => { + d.lpad_on = true; + d.lpad_y = t.y; + }, + .motion => if (d.lpad_on) { + d.scroll += (t.y - d.lpad_y) * PAD_Y_SIGN * SCROLL_SCALE; + d.lpad_y = t.y; + const ticks: i32 = @intFromFloat(d.scroll); + if (ticks != 0) { + d.scroll -= @floatFromInt(ticks); + out[n] = .{ .wheel = ticks }; + n += 1; + } + }, + .up => d.lpad_on = false, + }, + else => {}, + } + // a firm press on either pad is a click: right pad -> select + // (left button), left pad -> look (right button). The press + // rumbles as click feedback, mirroring the stock driver (SDL + // exposes no per-pad haptic, so it's a short whole-pad buzz). + if (t.pad == PAD_RIGHT or t.pad == PAD_LEFT) { + const which: Click = if (t.pad == PAD_RIGHT) .select else .look; + const held = if (t.pad == PAD_RIGHT) &d.rpad_click else &d.lpad_click; + if (t.phase == .up) { + if (held.*) { + held.* = false; + out[n] = .{ .click = .{ .which = which, .down = false } }; + n += 1; + } + } else if (!held.* and t.pressure >= PAD_CLICK_PRESS) { + held.* = true; + out[n] = .{ .click = .{ .which = which, .down = true } }; + n += 1; + out[n] = .rumble; + n += 1; + } else if (held.* and t.pressure <= PAD_CLICK_RELEASE) { + held.* = false; + out[n] = .{ .click = .{ .which = which, .down = false } }; + n += 1; + } + } + }, + } + return n; + } +}; diff --git a/src/gui/gui.zig b/src/gui/gui.zig new file mode 100644 index 00000000..bca0ebb9 --- /dev/null +++ b/src/gui/gui.zig @@ -0,0 +1,3103 @@ +//! The SDL3 GPU shell: owns an SDL window + event loop, translates SDL input +//! into core events, performs the core's effects (fork ptys, write them, +//! resize them — same duties as tty.zig, this is also native), and +//! rasterizes the core's Surface: one instanced quad per cell, glyphs from an +//! stb_truetype R8 atlas. Test modes: PARDES_TEST_GRID=1 is headless (no SDL, +//! stdin escape sequences in, text grid frames out); PARDES_TEST=1 keeps the +//! real renderer, drives input from stdin, and captures frames to PPM. +//! +//! The same file is the BROWSER shell (-Dplatform=web, wasm32-emscripten): +//! WebGL2 instead of the GPU device, an embedded dump instead of ptys, and a +//! requestAnimationFrame callback instead of a blocking loop. Comptime +//! branches on is_emscripten, the way the stdlib branches on os.tag. +const std = @import("std"); +const builtin = @import("builtin"); +const posix = std.posix; +const libc = std.c; +const vaxis = @import("vaxis"); // test modes only: the stdin escape-seq parser +const ghostty_vt = @import("ghostty-vt"); // 256-color palette for .index cells +const pardes = @import("../pardes.zig"); +const look = @import("../look.zig"); +const deck = @import("deck.zig"); +const crt = @import("crt.zig"); + +const is_emscripten = builtin.os.tag == .emscripten; + +pub const c = @cImport({ + @cDefine("SDL_DISABLE_OLD_NAMES", "1"); + @cInclude("SDL3/SDL.h"); + if (is_emscripten) @cInclude("GLES3/gl3.h"); + @cInclude("font.h"); +}); + +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; +extern "c" fn emscripten_get_element_css_size(target: [*:0]const u8, width: *f64, height: *f64) c_int; +const EMSCRIPTEN_RESULT_SUCCESS: c_int = 0; + +// 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)); + +const log = std.log.scoped(.gui); + +const font_ttf = @embedFile("AdwaitaMono-Regular.ttf"); +const vert_spv = if (is_emscripten) "" else @embedFile("ui.vert.spv"); +const frag_spv = if (is_emscripten) "" else @embedFile("ui.frag.spv"); +const overlay_vert_spv = if (is_emscripten) "" else @embedFile("overlay.vert.spv"); +const overlay_frag_spv = if (is_emscripten) "" else @embedFile("overlay.frag.spv"); +const crt_vert_spv = if (is_emscripten) "" else @embedFile("crt.vert.spv"); +const crt_frag_spv = if (is_emscripten) "" else @embedFile("crt.frag.spv"); +// web: GL ES shader SOURCES, compiled by WebGL at init (no SPIR-V there) +const vert_glsl_es = if (is_emscripten) @embedFile("ui.vert.es.glsl") else ""; +const frag_glsl_es = if (is_emscripten) @embedFile("ui.frag.es.glsl") else ""; +const overlay_vert_glsl_es = if (is_emscripten) @embedFile("overlay.vert.es.glsl") else ""; +const overlay_frag_glsl_es = if (is_emscripten) @embedFile("overlay.frag.es.glsl") else ""; +// web: no ptys — the whole state replays from a dump embedded at build time +const embedded_dump = if (is_emscripten) @embedFile("embedded.dump.zon") else ""; + +// default page colors — the same "terminal native dark" the prototype used +const bg_default = [3]u8{ 18, 18, 18 }; +const fg_default = [3]u8{ 204, 204, 204 }; + +// the plan9 arrow cursor, bytes verbatim from 9front /sys/src/9/port/ +// devmouse.c (Cursor arrow, 16x16 MSB-first): clr is the white outline, set +// the black ink, offset {-1,-1} puts the hot point at (1,1) in the bitmap. +// SDL_CreateCursor's scheme: data=1,mask=1 black; data=0,mask=1 white; +// mask=0 transparent — so data = set and mask = set|clr. +const p9_arrow_clr = [32]u8{ + 0xFF, 0xFF, 0x80, 0x01, 0x80, 0x02, 0x80, 0x0C, + 0x80, 0x10, 0x80, 0x10, 0x80, 0x08, 0x80, 0x04, + 0x80, 0x02, 0x80, 0x01, 0x80, 0x02, 0x8C, 0x04, + 0x92, 0x08, 0x91, 0x10, 0xA0, 0xA0, 0xC0, 0x40, +}; +const p9_arrow_set = [32]u8{ + 0x00, 0x00, 0x7F, 0xFE, 0x7F, 0xFC, 0x7F, 0xF0, + 0x7F, 0xE0, 0x7F, 0xE0, 0x7F, 0xF0, 0x7F, 0xF8, + 0x7F, 0xFC, 0x7F, 0xFE, 0x7F, 0xFC, 0x73, 0xF8, + 0x61, 0xF0, 0x60, 0xE0, 0x40, 0x40, 0x00, 0x00, +}; +const p9_arrow_mask = blk: { + var m: [32]u8 = undefined; + for (&m, p9_arrow_set, p9_arrow_clr) |*b, s, cl| b.* = s | cl; + break :blk m; +}; + +// web: glyphs raster at 1.5x and the canvas backs at CSS×1.5 so text stays +// sharp on scaled canvases (the prototype's -Dweb-render-scale, which never +// shipped non-default — hardcoded here instead of a build option). +const web_render_scale: f32 = 1.5; + +// 2048 fits ~2500 glyphs at the 2x native cell (~20x40); web needs it too +const atlas_w: u32 = 2048; +const atlas_h: u32 = 2048; +const Slot = struct { u: u32, v: u32 }; + +const max_touch_points: usize = 16; +const max_touch_trail_points: usize = 8; +const overlay_circle_vertices: usize = 16 * 3; +const touch_click_flash_max_frames: u8 = 14; +const touch_click_flash_vertices: usize = 1400; +const touch_scroll_tick: f32 = 0.02; +/// Web tap-vs-scroll heuristic, measured after normalized coordinates have +/// been mapped into backing pixels (12px = 8 CSS px at web_render_scale). +const touch_tap_slop_px: f32 = 12.0; +const max_overlay_vertices: usize = touch_click_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices); + +// One instance per cell; the vertex shader expands it to a 2-triangle quad +// with gl_VertexIndex. Coords are NDC (y up), uv into the atlas, colors 0..1. +const CellInstance = extern struct { + x0: f32, + y0: f32, + x1: f32, + y1: f32, + u0: f32, + v0: f32, + u1: f32, + v1: f32, + fr: f32, + fg: f32, + fb: f32, + br: f32, + bg: f32, + bb: f32, +}; + +const OverlayVertex = extern struct { x: f32, y: f32, r: f32, g: f32, b: f32, a: f32 }; +const OverlayColor = struct { r: f32, g: f32, b: f32, a: f32 }; + +// ---- touch: per-finger tracking + shared scroll/tap machines ---- + +const TouchSample = struct { x: f32 = 0, y: f32 = 0, pressure: f32 = 1 }; + +const TouchPoint = struct { + active: bool = false, + id: u64 = 0, + x: f32 = 0, + y: f32 = 0, + pressure: f32 = 1, + trail: [max_touch_trail_points]TouchSample = @splat(.{}), + trail_len: usize = 0, + trail_next: usize = 0, +}; + +const TouchNormPoint = struct { x: f32 = 0, y: f32 = 0 }; + +/// A finger event with SDL's normalized 0..1 coordinates — the one shape both +/// real SDL_EVENT_FINGER_* and the synthetic test OSC feed into the machine. +const Finger = struct { kind: enum { down, motion, up, cancel }, id: u64, x: f32, y: f32, pressure: f32 }; + +const Touch = struct { + points: [max_touch_points]TouchPoint = @splat(.{}), + scroll: struct { + active: bool = false, + ids: [2]u64 = .{ 0, 0 }, + accum_y: f32 = 0, + scrolled: bool = false, + last_center: TouchNormPoint = .{}, + } = .{}, + // Web policy: one primary finger is either a body tap/scroll or a latched + // left-mouse gesture when it begins on layout chrome. A second simultaneous + // finger cancels a body tap rather than producing a click. + single: struct { + active: bool = false, + id: u64 = 0, + start: TouchNormPoint = .{}, + last: TouchNormPoint = .{}, + accum_y: f32 = 0, + scrolling: bool = false, + mouse_drag: bool = false, + multitouch: bool = false, + } = .{}, + click_count: u32 = 0, + click_button: pardes.Mouse.Button = .middle, + click_flash_frames: u8 = 0, + + fn findSlot(t: *const Touch, id: u64) ?usize { + for (&t.points, 0..) |*tp, i| if (tp.active and tp.id == id) return i; + return null; + } + + fn updatePoint(t: *Touch, f: Finger) void { + switch (f.kind) { + .up, .cancel => if (t.findSlot(f.id)) |i| { + t.points[i].active = false; + }, + .down, .motion => { + const idx = t.findSlot(f.id) orelse blk: { + for (&t.points, 0..) |*tp, i| if (!tp.active) break :blk i; + break :blk @as(usize, @intCast(f.id % max_touch_points)); + }; + const tp = &t.points[idx]; + if (!tp.active or tp.id != f.id) tp.* = .{ .active = true, .id = f.id }; + tp.x = f.x; + tp.y = f.y; + tp.pressure = f.pressure; + tp.trail[tp.trail_next] = .{ .x = f.x, .y = f.y, .pressure = f.pressure }; + tp.trail_next = (tp.trail_next + 1) % max_touch_trail_points; + if (tp.trail_len < max_touch_trail_points) tp.trail_len += 1; + }, + } + } + + fn activeCount(t: *const Touch) usize { + var count: usize = 0; + for (&t.points) |*tp| count += @intFromBool(tp.active); + return count; + } + + fn pairCenter(t: *const Touch, ids: [2]u64) ?TouchNormPoint { + var sum: TouchNormPoint = .{}; + for (ids) |id| { + const i = t.findSlot(id) orelse return null; + sum.x += t.points[i].x; + sum.y += t.points[i].y; + } + return .{ .x = sum.x * 0.5, .y = sum.y * 0.5 }; + } + + /// keep scroll state in sync with the set of active fingers: exactly two + /// active fingers begin (or re-key) a pair; anything else resets it. + fn syncPair(t: *Touch) void { + var ids: [2]u64 = .{ 0, 0 }; + var count: usize = 0; + for (&t.points) |*tp| { + if (!tp.active) continue; + if (count >= 2) { + t.scroll = .{}; + return; + } + ids[count] = tp.id; + count += 1; + } + if (count != 2) { + t.scroll = .{}; + return; + } + if (ids[0] > ids[1]) std.mem.swap(u64, &ids[0], &ids[1]); + if (t.scroll.active and t.scroll.ids[0] == ids[0] and t.scroll.ids[1] == ids[1]) return; + t.scroll = .{ .active = true, .ids = ids, .last_center = t.pairCenter(ids) orelse .{} }; + } + + /// finger lift: a pair that never scrolled is a two-finger TAP — returns + /// its center (computed with the lifting finger's final position). + fn finishPair(t: *Touch, f: Finger) ?TouchNormPoint { + if (!t.scroll.active or (t.scroll.ids[0] != f.id and t.scroll.ids[1] != f.id)) return null; + const tap = f.kind == .up and !t.scroll.scrolled; + var center: ?TouchNormPoint = null; + if (tap) { + var sum: TouchNormPoint = .{}; + var ok = true; + for (t.scroll.ids) |id| { + if (id == f.id) { + sum.x += f.x; + sum.y += f.y; + } else if (t.findSlot(id)) |i| { + sum.x += t.points[i].x; + sum.y += t.points[i].y; + } else ok = false; + } + center = if (ok) .{ .x = sum.x * 0.5, .y = sum.y * 0.5 } else t.scroll.last_center; + } + t.scroll = .{}; + return center; + } + + fn noteMotion(t: *Touch, f: Finger) ?struct { center: TouchNormPoint, ticks: i32, delta_y: f32 } { + if (!t.scroll.active or (t.scroll.ids[0] != f.id and t.scroll.ids[1] != f.id)) return null; + const center = t.pairCenter(t.scroll.ids) orelse t.scroll.last_center; + const delta_y = center.y - t.scroll.last_center.y; + t.scroll.last_center = center; + t.scroll.accum_y += delta_y; + const ticks = takeScrollTicks(&t.scroll.accum_y); + if (ticks == 0) return null; + t.scroll.scrolled = true; + return .{ .center = center, .ticks = ticks, .delta_y = @as(f32, @floatFromInt(ticks)) * touch_scroll_tick }; + } + + fn beginSingle(t: *Touch, f: Finger, mouse_drag: bool) void { + if (t.single.active) { + if (t.single.id != f.id) t.single.multitouch = true; + return; + } + if (t.activeCount() != 1) return; + const point = TouchNormPoint{ .x = f.x, .y = f.y }; + t.single = .{ .active = true, .id = f.id, .start = point, .last = point, .mouse_drag = mouse_drag }; + } + + fn noteSingleMotion(t: *Touch, f: Finger, win_w: f32, win_h: f32) ?struct { center: TouchNormPoint, ticks: i32, delta_y: f32 } { + if (!t.single.active or t.single.id != f.id or t.single.multitouch) return null; + const center = TouchNormPoint{ .x = f.x, .y = f.y }; + var delta_y = center.y - t.single.last.y; + t.single.last = center; + if (!t.single.scrolling) { + const dx = (center.x - t.single.start.x) * win_w; + const dy = (center.y - t.single.start.y) * win_h; + if (dx * dx + dy * dy < touch_tap_slop_px * touch_tap_slop_px) return null; + // Once the pointer leaves the tap slop it can never click. Include + // all travel since finger-down so the first scroll tick is prompt. + t.single.scrolling = true; + delta_y = center.y - t.single.start.y; + } + t.single.accum_y += delta_y; + const ticks = takeScrollTicks(&t.single.accum_y); + if (ticks == 0) return null; + return .{ .center = center, .ticks = ticks, .delta_y = @as(f32, @floatFromInt(ticks)) * touch_scroll_tick }; + } + + fn finishSingle(t: *Touch, f: Finger, win_w: f32, win_h: f32) ?TouchNormPoint { + if (!t.single.active or t.single.id != f.id) return null; + const point = TouchNormPoint{ .x = f.x, .y = f.y }; + const dx = (point.x - t.single.start.x) * win_w; + const dy = (point.y - t.single.start.y) * win_h; + const left_slop = dx * dx + dy * dy >= touch_tap_slop_px * touch_tap_slop_px; + const tap = f.kind == .up and !left_slop and !t.single.scrolling and !t.single.mouse_drag and !t.single.multitouch; + t.single = .{}; + return if (tap) point else null; + } +}; + +fn takeScrollTicks(accum: *f32) i32 { + var ticks: i32 = 0; + while (accum.* >= touch_scroll_tick) : (ticks += 1) accum.* -= touch_scroll_tick; + while (accum.* <= -touch_scroll_tick) : (ticks -= 1) accum.* += touch_scroll_tick; + return ticks; +} + +/// The SDL boundary owns touch policy: a web body uses one-finger natural +/// scrolling and tap-as-LOOK, while layout chrome latches a left-mouse drag; +/// native keeps two-finger scroll/tap-as-execute. Both feed the same core mouse +/// events and coordinate mapping. +fn handleFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void { + std.debug.assert(cell_w > 0 and cell_h > 0); + if (is_emscripten) + return handleSingleFinger(t, core, f, win_w, win_h, cell_w, cell_h); + return handlePairFinger(t, core, f, win_w, win_h, cell_w, cell_h); +} + +fn handleSingleFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void { + switch (f.kind) { + .down => { + t.updatePoint(f); + const point = TouchNormPoint{ .x = f.x, .y = f.y }; + const col = normCell(point.x, win_w, cell_w); + const row = normCell(point.y, win_h, cell_h); + const chrome = webChromeTarget(core, col, row); + t.beginSingle(f, chrome != null); + if (chrome) |target| + core.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = target.col, .row = target.row } }); + }, + .motion => { + t.updatePoint(f); + if (t.single.active and t.single.id == f.id and t.single.mouse_drag) { + core.update(.{ .mouse = .{ + .button = .left, + .kind = .drag, + .col = normCell(f.x, win_w, cell_w), + .row = normCell(f.y, win_h, cell_h), + } }); + return; + } + const res = t.noteSingleMotion(f, win_w, win_h) orelse return; + emitTouchScroll(core, res.center, res.ticks, res.delta_y, win_w, win_h, cell_w, cell_h); + }, + .up, .cancel => { + const mouse_drag = t.single.active and t.single.id == f.id and t.single.mouse_drag; + const tap = t.finishSingle(f, win_w, win_h); + t.updatePoint(f); + if (mouse_drag) { + core.update(.{ .mouse = .{ + .button = .left, + .kind = .release, + .col = normCell(f.x, win_w, cell_w), + .row = normCell(f.y, win_h, cell_h), + } }); + } else if (tap) |point| emitTouchClick(t, core, point, .right, win_w, win_h, cell_w, cell_h); + }, + } +} + +const WebChromeTarget = struct { col: u16, row: u16 }; + +/// Fat-finger hit test for web layout chrome. A gesture is classified once at +/// finger-down, then never changes into scrolling. Handle coordinates are +/// snapped onto the core's one-cell resize edge; body rows remain untouched. +fn webChromeTarget(core: *const pardes.Pardes, col: u16, row: u16) ?WebChromeTarget { + if (row < pardes.TOPBAR_H) return .{ .col = col, .row = row }; + + // The move box wins over a horizontal handle when a pane is tag-only, + // matching the core's own mouse hit-test priority. + for (core.panes, 0..) |slot, id| { + if (slot == null) continue; + const rect = core.rects[id]; + if (row == rect.y and col >= rect.x and col < rect.x + rect.w and col < rect.x + pardes.GUTTER) + return .{ .col = col, .row = rect.y }; + } + + for (0..core.ncol -| 1) |column| { + const handle = core.col_x[column] + core.col_w[column] -| 1; + if (col == handle) return .{ .col = handle, .row = row }; + } + for (0..core.ncol) |column| { + if (col < core.col_x[column] or col >= core.col_x[column] + core.col_w[column]) continue; + for (0..core.col_n[column] -| 1) |index| { + const rect = core.rects[core.col_terms[column][index]]; + const handle = rect.y + rect.h -| 1; + if (row == handle) return .{ .col = col, .row = handle }; + } + } + + // The remainder of each tag row is tag editing, not a scrollable body. + for (core.panes, 0..) |slot, id| { + if (slot == null) continue; + const rect = core.rects[id]; + if (row == rect.y and col >= rect.x and col < rect.x + rect.w) + return .{ .col = col, .row = rect.y }; + } + + // One-cell tolerance around thin separators, after exact tag rows have had + // first refusal so a fat-finger tag edit never turns into a resize. + for (0..core.ncol -| 1) |column| { + const handle = core.col_x[column] + core.col_w[column] -| 1; + if (cellDistance(col, handle) == 1) return .{ .col = handle, .row = row }; + } + for (0..core.ncol) |column| { + if (col < core.col_x[column] or col >= core.col_x[column] + core.col_w[column]) continue; + for (0..core.col_n[column] -| 1) |index| { + const rect = core.rects[core.col_terms[column][index]]; + const handle = rect.y + rect.h -| 1; + if (cellDistance(row, handle) == 1) return .{ .col = col, .row = handle }; + } + } + return null; +} + +fn cellDistance(a: u16, b: u16) u16 { + return if (a > b) a - b else b - a; +} + +fn handlePairFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void { + switch (f.kind) { + .down => { + t.updatePoint(f); + t.syncPair(); + }, + .motion => { + const had_pair = t.scroll.active; + t.updatePoint(f); + t.syncPair(); + if (!had_pair) return; + const res = t.noteMotion(f) orelse return; + emitTouchScroll(core, res.center, res.ticks, res.delta_y, win_w, win_h, cell_w, cell_h); + }, + .up, .cancel => { + const tap_center = t.finishPair(f); + t.updatePoint(f); + t.syncPair(); + if (tap_center) |center| emitTouchClick(t, core, center, .middle, win_w, win_h, cell_w, cell_h); + }, + } +} + +fn emitTouchScroll(core: *pardes.Pardes, center: TouchNormPoint, ticks: i32, delta_y: f32, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void { + const col = normCell(center.x, win_w, cell_w); + const row = normCell(center.y, win_h, cell_h); + const button: pardes.Mouse.Button = if (ticks > 0) .wheel_up else .wheel_down; + var remaining: u32 = @abs(ticks); + while (remaining > 0) : (remaining -= 1) + core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = col, .row = row } }); + core.update(.{ .touch_scroll = delta_y }); +} + +fn emitTouchClick(t: *Touch, core: *pardes.Pardes, point: TouchNormPoint, button: pardes.Mouse.Button, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void { + t.click_count +%= 1; + t.click_button = button; + t.click_flash_frames = touch_click_flash_max_frames; + const col = normCell(point.x, win_w, cell_w); + const row = normCell(point.y, win_h, cell_h); + core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = col, .row = row } }); + core.update(.{ .mouse = .{ .button = button, .kind = .release, .col = col, .row = row } }); +} + +fn normCell(norm: f32, win: f32, cell: f32) u16 { + const px = std.math.clamp(norm, 0.0, 1.0) * win; + return @intFromFloat(@max(0, @floor(px / cell))); +} + +// ---- pty plumbing: reader threads feed a mutex-protected queue ---- + +const Pty = struct { fd: c_int, pid: libc.pid_t }; + +const Msg = union(enum) { + output: struct { pane: u8, gen: u32, bytes: []u8 }, + eof: struct { pane: u8, gen: u32, fd: c_int }, + /// a language query finished on its own thread (see lspThread) + lsp: struct { id: u32, rows: []u8 }, +}; + +/// One language query, owned by the thread running it — the gui twin of +/// tty.zig's LspJob, and copied for the same reason: the core edits on. +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 Queue = struct { + gpa: std.mem.Allocator, + sdl_wake: bool, // wake a blocking SDL_WaitEventTimeout on cross-thread push + // 0.16 has no std.Thread.Mutex; critical sections here are a few + // instructions, so spinning on the lock-free std.atomic.Mutex is enough + mutex: std.atomic.Mutex = .unlocked, + items: std.ArrayList(Msg) = .empty, + closed: bool = false, + + fn lock(q: *Queue) void { + while (!q.mutex.tryLock()) std.atomic.spinLoopHint(); + } + + fn push(q: *Queue, m: Msg) void { + q.lock(); + if (q.closed) { + q.mutex.unlock(); + switch (m) { + .output => |o| q.gpa.free(o.bytes), + .lsp => |l| q.gpa.free(l.rows), + .eof => {}, + } + return; + } + q.items.append(q.gpa, m) catch { + q.mutex.unlock(); + switch (m) { + .output => |o| q.gpa.free(o.bytes), + .lsp => |l| q.gpa.free(l.rows), + .eof => {}, + } + return; + }; + q.mutex.unlock(); + if (q.sdl_wake) { + var sev = std.mem.zeroes(c.SDL_Event); + sev.type = c.SDL_EVENT_USER; + _ = c.SDL_PushEvent(&sev); + } + } + + /// take the pending messages (caller iterates + deinits outside the lock) + fn take(q: *Queue) std.ArrayList(Msg) { + q.lock(); + defer q.mutex.unlock(); + const local = q.items; + q.items = .empty; + return local; + } + + fn close(q: *Queue) void { + q.lock(); + defer q.mutex.unlock(); + q.closed = true; + for (q.items.items) |m| switch (m) { + .output => |o| q.gpa.free(o.bytes), + .lsp => |l| q.gpa.free(l.rows), + .eof => {}, + }; + q.items.deinit(q.gpa); + } +}; + +fn readPtyThread(gpa: std.mem.Allocator, fd: c_int, pane: u8, gen: u32, q: *Queue) void { + var buf: [0x10000]u8 = undefined; + while (true) { + const n = libc.read(fd, &buf, buf.len); + if (n < 0) { + if (libc.errno(n) == .INTR) continue; + break; // EIO when the child exits: treat as EOF + } + if (n == 0) break; + const bytes = gpa.dupe(u8, buf[0..@intCast(n)]) catch break; + q.push(.{ .output = .{ .pane = pane, .gen = gen, .bytes = bytes } }); + } + q.push(.{ .eof = .{ .pane = pane, .gen = gen, .fd = fd } }); +} + +fn spawnReader(gpa: std.mem.Allocator, pt: Pty, pane: u8, gen: u32, q: *Queue) void { + const th = std.Thread.spawn(.{}, readPtyThread, .{ gpa, pt.fd, pane, gen, q }) catch return; + th.detach(); +} + +/// Answer a language query off the render loop and push the rows to the queue +/// — the async execution model, spelled in the plumbing this shell already has +/// (a detached thread and the mutex queue the pty readers use). +fn lspThread(gpa: std.mem.Allocator, job: *LspJob, q: *Queue) 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; + q.push(.{ .lsp = .{ .id = job.id, .rows = rows } }); +} + +/// Copy the query out of the core and hand it to a thread. A detached thread +/// per query is fine at this rate: one keystroke, one query, and the queue +/// already tolerates a late push after close. +fn spawnLsp(core: *pardes.Pardes, gpa: std.mem.Allocator, q: *Queue, e: anytype) void { + const pane = core.panes[e.pane] orelse return; + // 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 return; + job.* = .{ + .id = e.id, + .kind = e.kind, + .offset = e.offset, + .path = gpa.dupe(u8, if (f) |ff| ff.path else "") catch { + gpa.destroy(job); + return; + }, + .source = gpa.dupeZ(u8, if (f) |ff| ff.content else "") catch { + gpa.free(job.path); + gpa.destroy(job); + return; + }, + .arg = gpa.dupe(u8, e.arg.slice()) catch { + gpa.free(job.path); + gpa.free(job.source); + gpa.destroy(job); + return; + }, + .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); + return; + }, + }; + const th = std.Thread.spawn(.{}, lspThread, .{ gpa, job, q }) catch { + job.free(gpa); + return; + }; + th.detach(); +} + +// ---- the renderer state ---- + +const Gui = struct { + window: *c.SDL_Window, + device: *c.SDL_GPUDevice, + swapchain_format: c.SDL_GPUTextureFormat, + pipeline: *c.SDL_GPUGraphicsPipeline, + overlay_pipeline: *c.SDL_GPUGraphicsPipeline, + crt_pipeline: *c.SDL_GPUGraphicsPipeline, + atlas_tex: *c.SDL_GPUTexture, + atlas_sampler: *c.SDL_GPUSampler, + atlas_xfer: *c.SDL_GPUTransferBuffer, + vbuf: ?*c.SDL_GPUBuffer = null, + vxfer: ?*c.SDL_GPUTransferBuffer = null, + vbuf_cells: u32 = 0, + overlay_vbuf: *c.SDL_GPUBuffer, + overlay_vxfer: *c.SDL_GPUTransferBuffer, + overlay_vertices: []OverlayVertex, + + font: *c.UIFont, + scale: f32, + cell_w: u32, + cell_h: u32, + ascent: i32, + + // glyph atlas: CPU staging bitmap + codepoint → texel slot, pen-walk alloc + atlas_stage: []u8, + glyphs: std.AutoHashMap(u32, Slot), + pen_x: u32 = 0, + pen_y: u32 = 0, + atlas_dirty: bool = true, + space_slot: Slot = .{ .u = 0, .v = 0 }, + + touch: Touch = .{}, + mouse_x: f32 = 0, + mouse_y: f32 = 0, + live_ctrl: bool = false, + live_alt: bool = false, + + // Crt builtin: the scene renders into this texture, then a fullscreen + // CRT pass warps it onto the real target + scene_tex: ?*c.SDL_GPUTexture = null, + scene_tex_w: u32 = 0, + scene_tex_h: u32 = 0, + + // PARDES_TEST frame capture (render into an offscreen target, dump PPM) + capture: bool = false, + capture_dir: []const u8 = "", + capture_tex: ?*c.SDL_GPUTexture = null, + capture_tex_w: u32 = 0, + capture_tex_h: u32 = 0, + capture_xfer: ?*c.SDL_GPUTransferBuffer = null, + capture_xfer_size: u32 = 0, + + // steamdeck: gamepad-driven virtual mouse cursor (pixel position) + gamepad: ?*c.SDL_Gamepad = null, + pad_x: f32 = 0, + pad_y: f32 = 0, + pad_scroll: f32 = 0, // fractional vertical wheel ticks (left stick) + pad_scroll_h: f32 = 0, // fractional horizontal wheel ticks (left stick) + deck: deck.Deck = .{}, + + // web: WebGL2 handles (plain GL ints) + the CPU-side instance staging + gl_program: c_uint = 0, + gl_overlay_program: c_uint = 0, + gl_atlas_tex: c_uint = 0, + gl_vbo: c_uint = 0, + gl_overlay_vbo: c_uint = 0, + gl_u_atlas: c_int = -1, + gl_instances: []CellInstance = &.{}, +}; + +/// A cell's on-screen rect. Native: exactly cell_w×cell_h at (0,0). Web: the +/// canvas resize and the grid resize land on different frames, so the grid is +/// scaled to fit and centered (letterboxed) instead of clipping. +const CellLayout = struct { w: f32, h: f32, x_off: f32, y_off: f32 }; + +fn fixedCellLayout(g: *const Gui) CellLayout { + return .{ .w = @floatFromInt(g.cell_w), .h = @floatFromInt(g.cell_h), .x_off = 0, .y_off = 0 }; +} + +fn cellLayout(g: *const Gui, cols: u16, rows: u16, sw: u32, sh: u32) CellLayout { + const fixed = fixedCellLayout(g); + if (!is_emscripten) return fixed; + if (cols == 0 or rows == 0) return fixed; + const grid_w = @as(f32, @floatFromInt(cols)) * fixed.w; + const grid_h = @as(f32, @floatFromInt(rows)) * fixed.h; + const win_w: f32 = @floatFromInt(sw); + const win_h: f32 = @floatFromInt(sh); + const scale = @min(win_w / grid_w, win_h / grid_h); + return .{ + .w = fixed.w * scale, + .h = fixed.h * scale, + .x_off = @max(0, (win_w - grid_w * scale) * 0.5), + .y_off = @max(0, (win_h - grid_h * scale) * 0.5), + }; +} + +// ===================================================================== +// entry +// ===================================================================== + +pub const run = if (is_emscripten) runWeb else runNative; + +fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { + const io = init.io; + const gpa = init.gpa; + const env = init.environ_map; + if (env.get("PARDES_TEST_GRID") != null) return runGrid(init, opts_in); + + const test_mode = env.get("PARDES_TEST") != null; + const capture_dir = env.get("PARDES_TEST_CAPTURE_DIR"); + if (test_mode and capture_dir == null) { + log.err("PARDES_TEST requires PARDES_TEST_CAPTURE_DIR", .{}); + return error.SdlInit; + } + + // The trackpads only exist through SDL's built-in HIDAPI Steam Deck + // driver (it registers the two touchpads and disables "lizard mode"). + // It's default-on on Linux; pin it so intent is explicit. NOTE: this is + // NOT enough in Game Mode — Steam Input there hands the app a + // touchpad-less virtual gamepad instead of the real Neptune controller, + // so the pads go dead no matter what the app does. The only fix is to + // set "Disable Steam Input" on pardes (Steam -> Properties -> Controller), + // after which the real controller enumerates and the touchpad events flow. + _ = c.SDL_SetHint("SDL_JOYSTICK_HIDAPI", "1"); // SDL_HINT_JOYSTICK_HIDAPI + _ = c.SDL_SetHint("SDL_JOYSTICK_HIDAPI_STEAMDECK", "1"); // ..._STEAMDECK + if (!c.SDL_Init(c.SDL_INIT_VIDEO | c.SDL_INIT_GAMEPAD)) { + log.err("SDL_Init: {s}", .{c.SDL_GetError()}); + return error.SdlInit; + } + var win_flags: c.SDL_WindowFlags = c.SDL_WINDOW_RESIZABLE; + if (!test_mode) win_flags |= c.SDL_WINDOW_HIGH_PIXEL_DENSITY; + const window = c.SDL_CreateWindow("pardes", 1120, 720, win_flags) orelse { + log.err("SDL_CreateWindow: {s}", .{c.SDL_GetError()}); + return error.SdlInit; + }; + if (c.SDL_CreateCursor(&p9_arrow_set, &p9_arrow_mask, 16, 16, 1, 1)) |cur| { + _ = c.SDL_SetCursor(cur); + } else log.err("SDL_CreateCursor: {s}", .{c.SDL_GetError()}); + // fullscreen deck use: keep the pointer inside the window. Absolute + // coords stay (no relative mode — clicks need positions). + if (!test_mode) _ = c.SDL_SetWindowMouseGrab(window, true); + const device = c.SDL_CreateGPUDevice(c.SDL_GPU_SHADERFORMAT_SPIRV, true, null) orelse { + log.err("SDL_CreateGPUDevice: {s}", .{c.SDL_GetError()}); + return error.SdlInit; + }; + if (!c.SDL_ClaimWindowForGPUDevice(device, window)) { + log.err("ClaimWindowForGPUDevice: {s}", .{c.SDL_GetError()}); + return error.SdlInit; + } + // present mode: PARDES_SDL_PRESENT env override, else immediate → mailbox → vsync + const present_mode: c.SDL_GPUPresentMode = blk: { + if (env.get("PARDES_SDL_PRESENT")) |raw| { + const want: ?c.SDL_GPUPresentMode = if (std.ascii.eqlIgnoreCase(raw, "immediate")) + c.SDL_GPU_PRESENTMODE_IMMEDIATE + else if (std.ascii.eqlIgnoreCase(raw, "mailbox")) + c.SDL_GPU_PRESENTMODE_MAILBOX + else if (std.ascii.eqlIgnoreCase(raw, "vsync")) + c.SDL_GPU_PRESENTMODE_VSYNC + else + null; + if (want) |mode| if (c.SDL_WindowSupportsGPUPresentMode(device, window, mode)) break :blk mode; + } + for ([_]c.SDL_GPUPresentMode{ c.SDL_GPU_PRESENTMODE_IMMEDIATE, c.SDL_GPU_PRESENTMODE_MAILBOX, c.SDL_GPU_PRESENTMODE_VSYNC }) |mode| { + if (c.SDL_WindowSupportsGPUPresentMode(device, window, mode)) break :blk mode; + } + break :blk c.SDL_GPU_PRESENTMODE_VSYNC; + }; + if (!test_mode) _ = c.SDL_SetGPUAllowedFramesInFlight(device, 1); + _ = c.SDL_SetGPUSwapchainParameters(device, window, c.SDL_GPU_SWAPCHAINCOMPOSITION_SDR, present_mode); + const swapchain_format = c.SDL_GetGPUSwapchainTextureFormat(device, window); + + // ---- font + cell metrics ---- + const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)) orelse { + log.err("ui_font_new failed", .{}); + return error.FontInit; + }; + const px: f32 = 27.0; + const scale = c.ui_font_scale_for_height(font, px); + var cw: c_int = 10; + var chh: c_int = 20; + var asc: c_int = 16; + c.ui_font_cell_metrics(font, scale, &cw, &chh, &asc); + const cell_w: u32 = @intCast(@max(cw, 1)); + const cell_h: u32 = @intCast(@max(chh, 1)); + if (test_mode) { + const cols = envU16(env, "PARDES_TEST_COLS") orelse 80; + const rows = envU16(env, "PARDES_TEST_ROWS") orelse 24; + _ = c.SDL_SetWindowSize(window, @intCast(cols * cell_w), @intCast(rows * cell_h)); + _ = c.SDL_SyncWindow(window); + } + + // ---- glyph atlas (R8) + pipelines ---- + var tex_info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); + tex_info.type = c.SDL_GPU_TEXTURETYPE_2D; + tex_info.format = c.SDL_GPU_TEXTUREFORMAT_R8_UNORM; + tex_info.usage = c.SDL_GPU_TEXTUREUSAGE_SAMPLER; + tex_info.width = atlas_w; + tex_info.height = atlas_h; + tex_info.layer_count_or_depth = 1; + tex_info.num_levels = 1; + tex_info.sample_count = c.SDL_GPU_SAMPLECOUNT_1; + const atlas_tex = c.SDL_CreateGPUTexture(device, &tex_info) orelse return error.GpuCreate; + + var samp_info = std.mem.zeroes(c.SDL_GPUSamplerCreateInfo); + samp_info.min_filter = c.SDL_GPU_FILTER_LINEAR; + samp_info.mag_filter = c.SDL_GPU_FILTER_LINEAR; + samp_info.mipmap_mode = c.SDL_GPU_FILTER_LINEAR; + samp_info.address_mode_u = c.SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE; + samp_info.address_mode_v = c.SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE; + samp_info.address_mode_w = c.SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE; + const atlas_sampler = c.SDL_CreateGPUSampler(device, &samp_info) orelse return error.GpuCreate; + + var ax_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = atlas_w * atlas_h, .props = 0 }; + const atlas_xfer = c.SDL_CreateGPUTransferBuffer(device, &ax_info) orelse return error.GpuCreate; + + const pipeline = try makePipeline(device, swapchain_format); + const overlay_pipeline = try makeOverlayPipeline(device, swapchain_format); + const crt_pipeline = try makeCrtPipeline(device, swapchain_format); + + const overlay_buffer_size: u32 = @intCast(max_overlay_vertices * @sizeOf(OverlayVertex)); + var ovb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = overlay_buffer_size, .props = 0 }; + const overlay_vbuf = c.SDL_CreateGPUBuffer(device, &ovb_info) orelse return error.GpuCreate; + var ovx_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = overlay_buffer_size, .props = 0 }; + const overlay_vxfer = c.SDL_CreateGPUTransferBuffer(device, &ovx_info) orelse return error.GpuCreate; + + const atlas_stage = try gpa.alloc(u8, atlas_w * atlas_h); + defer gpa.free(atlas_stage); + @memset(atlas_stage, 0); + const overlay_vertices = try gpa.alloc(OverlayVertex, max_overlay_vertices); + defer gpa.free(overlay_vertices); + + var g: Gui = .{ + .window = window, + .device = device, + .swapchain_format = swapchain_format, + .pipeline = pipeline, + .overlay_pipeline = overlay_pipeline, + .crt_pipeline = crt_pipeline, + .atlas_tex = atlas_tex, + .atlas_sampler = atlas_sampler, + .atlas_xfer = atlas_xfer, + .overlay_vbuf = overlay_vbuf, + .overlay_vxfer = overlay_vxfer, + .overlay_vertices = overlay_vertices, + .font = font, + .scale = scale, + .cell_w = cell_w, + .cell_h = cell_h, + .ascent = asc, + .atlas_stage = atlas_stage, + .glyphs = std.AutoHashMap(u32, Slot).init(gpa), + .capture = test_mode, + .capture_dir = capture_dir orelse "", + }; + defer g.glyphs.deinit(); + // slot (0,0) is the space glyph (blank cells sample alpha=0 → bg only) + _ = c.ui_font_raster(font, scale, ' ', atlas_stage.ptr, @intCast(atlas_w), @intCast(cell_w), @intCast(cell_h), asc); + g.pen_x = cell_w; + + // ---- the core ---- + pardes.image.start(io, gpa); // stb_image allocator for image panes + defer pardes.image.stop(); + // Live sessions initialize at the default 80x24 grid; the real window size + // arrives as a resize EVENT on the first loop pass. The core defers the + // shell greeting until after the first resize (so `ls` wraps to the real + // pane width) — pre-sizing at init would mean no resize ever fires and the + // greeting never runs (panes sat blank until the first interaction). + // Dump loads pre-size instead: replayed panes never greet, and sizing at + // init avoids reflowing their replayed content twice. + var opts = opts_in; + var pw: c_int = 0; + var ph: c_int = 0; + if (opts.load_path != null) { + _ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph); + opts.cols = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), cell_w))); + opts.rows = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), cell_h))); + } + var core = if (opts.load_path) |lp| blk: { + const bytes = try @import("../look.zig").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(); + // core.kitty_ok stays false: no kitty graphics in SDL, petscii covers images. + // ponytail: Surface.images pixel attachments could blit as textures later. + + // 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 ptys: [pardes.MAX_PANES]?Pty = @splat(null); + // per-slot spawn generation: drops a dead shell's late output/eof when its + // pane id has been respawned (see drainEffects .spawn) + var gens: [pardes.MAX_PANES]u32 = @splat(0); + defer for (&ptys) |*slot| if (slot.*) |pt| { + _ = libc.close(pt.fd); + slot.* = null; + }; + var queue: Queue = .{ .gpa = gpa, .sdl_wake = true }; + defer queue.close(); + + // initial spawns BEFORE any worker thread exists: forkpty from a + // multithreaded process can wedge the child before exec (see tty.zig). + drainEffects(core, &ptys, &gens, gpa, &queue, &g, false); + for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), gens[id], &queue); + + _ = c.SDL_StartTextInput(window); + + var feed: StdinFeed = .{}; + if (test_mode) setStdinRaw() catch {}; + + var frame_arena: std.heap.ArenaAllocator = .init(gpa); + defer frame_arena.deinit(); + + while (!core.quit) { + // 1. SDL input: block briefly for the first event, then drain the rest + var sev = std.mem.zeroes(c.SDL_Event); + if (c.SDL_WaitEventTimeout(&sev, 16)) { + dispatch(&g, core, &sev); + while (c.SDL_PollEvent(&sev)) dispatch(&g, core, &sev); + } + // test mode: input comes from stdin escape sequences instead + if (test_mode) { + const r = feed.pump(gpa, core, &g) catch break; + if (r.eof) break; + } + // 2. pty output from the reader threads + var msgs = queue.take(); + for (msgs.items) |m| switch (m) { + .output => |o| { + if (gens[o.pane] == o.gen) + core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } }); + gpa.free(o.bytes); + }, + .eof => |e| { + _ = libc.close(e.fd); // the dead reader's master — stale or current + if (gens[e.pane] == e.gen) { + ptys[e.pane] = null; + core.update(.{ .eof = .{ .pane = e.pane } }); + } + }, + .lsp => |l| { + core.update(.{ .lsp_resp = .{ .id = l.id, .rows = l.rows } }); + gpa.free(l.rows); + }, + }; + msgs.deinit(gpa); + // 3. steamdeck: poll gamepad axes into virtual cursor / wheel events + pollGamepad(&g, core); + // 4. effects + drainEffects(core, &ptys, &gens, gpa, &queue, &g, true); + if (core.quit) break; + // Restore builtin: swap in a core rebuilt from the dump; kill the live + // shells (their detached readers wake on child death; gens bumped so + // the stale eofs close the old fds without touching 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) |*slot| if (slot.*) |pt| { + _ = libc.kill(pt.pid, libc.SIG.KILL); + slot.* = null; + }; + for (&gens) |*g2| g2.* +%= 1; + core.deinit(); + core = nc; + } + // 5. live cwd for tags/look: cheap /proc readlink per pane, per frame + pollCwds(core, &ptys); + // 6. the grid follows the window (covers WINDOW_RESIZED and test resizes) + _ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph); + const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), cell_w))); + const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), cell_h))); + if (cols != core.screen_w or rows != core.screen_h) + core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); + // 7. render + _ = frame_arena.reset(.retain_capacity); + const surface = try core.render(frame_arena.allocator()); + renderFrame(&g, gpa, surface, core.crt_on, core.show_debug) catch |err| log.err("render: {t}", .{err}); + } +} + +// ===================================================================== +// web entry: set everything up, register a rAF frame callback, return. +// EXIT_RUNTIME=0 keeps the wasm runtime (and these statics) alive after. +// ===================================================================== + +// std.process.Init provides no gpa on emscripten — one 512 MiB fixed buffer +// is the whole heap, never freed (the shell lives for the page). +var web_heap: [if (is_emscripten) 512 * 1024 * 1024 else 0]u8 align(16) = undefined; +var web_fba: std.heap.FixedBufferAllocator = undefined; + +const WebState = struct { + g: Gui, + core: *pardes.Pardes, + gpa: std.mem.Allocator, + frame_arena: std.heap.ArenaAllocator, +}; +var live_web_state: ?*WebState = null; + +// Browser snapshot tests drive real DOM touch input, then read the exact same +// rendered Surface that WebGL consumed. These tiny exports keep the test +// contract textual (the existing .snap convention) without adding test state +// or browser concepts to the core. +comptime { + if (is_emscripten) { + @export(&webTestCols, .{ .name = "pardes_web_test_cols" }); + @export(&webTestRows, .{ .name = "pardes_web_test_rows" }); + @export(&webTestCursorX, .{ .name = "pardes_web_test_cursor_x" }); + @export(&webTestCursorY, .{ .name = "pardes_web_test_cursor_y" }); + @export(&webTestCell, .{ .name = "pardes_web_test_cell" }); + @export(&webTestCellFg, .{ .name = "pardes_web_test_cell_fg" }); + @export(&webTestCellBg, .{ .name = "pardes_web_test_cell_bg" }); + @export(&webTestCellAttrs, .{ .name = "pardes_web_test_cell_attrs" }); + @export(&webTestCellX, .{ .name = "pardes_web_test_cell_x" }); + @export(&webTestCellY, .{ .name = "pardes_web_test_cell_y" }); + } +} + +fn webTestCols() callconv(.c) c_uint { + const st = live_web_state orelse return 0; + return st.core.surface.cols; +} + +fn webTestRows() callconv(.c) c_uint { + const st = live_web_state orelse return 0; + return st.core.surface.rows; +} + +fn webTestCursorX() callconv(.c) c_int { + const st = live_web_state orelse return -1; + return if (st.core.surface.cursor) |cursor| cursor.x else -1; +} + +fn webTestCursorY() callconv(.c) c_int { + const st = live_web_state orelse return -1; + return if (st.core.surface.cursor) |cursor| cursor.y else -1; +} + +fn webTestCell(col: c_uint, row: c_uint) callconv(.c) c_uint { + const cell = webTestCellAt(col, row) orelse return ' '; + const glyph = cell.grapheme(); + if (glyph.len == 0) return ' '; + return std.unicode.utf8Decode(glyph) catch 0xfffd; +} + +// RGB occupies 0x000000-0xffffff; the high byte distinguishes the two other +// Color tags. The browser .snap harness renders these into d/pN/#rrggbb runs. +fn webTestCellFg(col: c_uint, row: c_uint) callconv(.c) c_uint { + const cell = webTestCellAt(col, row) orelse return 0x01000000; + return webTestColor(cell.style.fg); +} + +fn webTestCellBg(col: c_uint, row: c_uint) callconv(.c) c_uint { + const cell = webTestCellAt(col, row) orelse return 0x01000000; + return webTestColor(cell.style.bg); +} + +fn webTestColor(color: pardes.Color) c_uint { + return switch (color) { + .default => 0x01000000, + .index => |idx| 0x02000000 | @as(c_uint, idx), + .rgb => |rgb| (@as(c_uint, rgb[0]) << 16) | (@as(c_uint, rgb[1]) << 8) | rgb[2], + }; +} + +fn webTestCellAttrs(col: c_uint, row: c_uint) callconv(.c) c_uint { + const cell = webTestCellAt(col, row) orelse return 0; + const style = cell.style; + var attrs: c_uint = 0; + if (style.bold) attrs |= 1 << 0; + if (style.dim) attrs |= 1 << 1; + if (style.italic) attrs |= 1 << 2; + if (style.blink) attrs |= 1 << 3; + if (style.reverse) attrs |= 1 << 4; + if (style.invisible) attrs |= 1 << 5; + if (style.strikethrough) attrs |= 1 << 6; + attrs |= @as(c_uint, @intFromEnum(style.ul)) << 8; + return attrs; +} + +fn webTestCellAt(col: c_uint, row: c_uint) ?*const pardes.Cell { + const st = live_web_state orelse return null; + const surface = &st.core.surface; + if (col >= surface.cols or row >= surface.rows) return null; + return surface.at(@intCast(col), @intCast(row)); +} + +fn webTestCellX(col: f32) callconv(.c) f32 { + return webTestCellCenter(col, true); +} + +fn webTestCellY(row: f32) callconv(.c) f32 { + return webTestCellCenter(row, false); +} + +fn webTestCellCenter(cell: f32, horizontal: bool) f32 { + const st = live_web_state orelse return 0; + var pixel_w: c_int = 0; + var pixel_h: c_int = 0; + _ = c.SDL_GetWindowSizeInPixels(st.g.window, &pixel_w, &pixel_h); + const pw: u32 = @intCast(@max(pixel_w, 1)); + const ph: u32 = @intCast(@max(pixel_h, 1)); + var css_w: f64 = 0; + var css_h: f64 = 0; + if (emscripten_get_element_css_size("#canvas", &css_w, &css_h) != EMSCRIPTEN_RESULT_SUCCESS) return 0; + const layout = cellLayout(&st.g, st.core.surface.cols, st.core.surface.rows, pw, ph); + if (horizontal) { + const backing = layout.x_off + (cell + 0.5) * layout.w; + return backing * @as(f32, @floatCast(css_w)) / @as(f32, @floatFromInt(pw)); + } + const backing = layout.y_off + (cell + 0.5) * layout.h; + return backing * @as(f32, @floatCast(css_h)) / @as(f32, @floatFromInt(ph)); +} + +fn runWeb(opts_in: pardes.Options) !void { + web_fba = .init(&web_heap); + const gpa = web_fba.allocator(); + + // Our finger machine deliberately maps touch to scroll/LOOK. Suppress + // SDL's default synthetic left mouse event for the same browser touch. + _ = c.SDL_SetHint(c.SDL_HINT_TOUCH_MOUSE_EVENTS, "0"); + if (!c.SDL_Init(c.SDL_INIT_VIDEO)) { // no gamepad in the browser + log.err("SDL_Init: {s}", .{c.SDL_GetError()}); + return error.SdlInit; + } + const window = c.SDL_CreateWindow("pardes", 1120, 720, c.SDL_WINDOW_RESIZABLE | c.SDL_WINDOW_OPENGL) orelse { + log.err("SDL_CreateWindow: {s}", .{c.SDL_GetError()}); + return error.SdlInit; + }; + _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_PROFILE_MASK, c.SDL_GL_CONTEXT_PROFILE_ES); + _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MAJOR_VERSION, 3); + _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MINOR_VERSION, 0); + _ = c.SDL_GL_SetAttribute(c.SDL_GL_DOUBLEBUFFER, 1); + const gl_context = c.SDL_GL_CreateContext(window) orelse { + log.err("SDL_GL_CreateContext: {s}", .{c.SDL_GetError()}); + return error.SdlInit; + }; + if (!c.SDL_GL_MakeCurrent(window, gl_context)) { + log.err("SDL_GL_MakeCurrent: {s}", .{c.SDL_GetError()}); + return error.SdlInit; + } + _ = c.SDL_GL_SetSwapInterval(1); + + // ---- font + cell metrics, rastered at web_render_scale ---- + const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)) orelse { + log.err("ui_font_new failed", .{}); + return error.FontInit; + }; + const px: f32 = 18.0; + const base_scale = c.ui_font_scale_for_height(font, px); + var cw: c_int = 10; + var chh: c_int = 20; + var asc: c_int = 16; + c.ui_font_cell_metrics(font, base_scale, &cw, &chh, &asc); + const scale = c.ui_font_scale_for_height(font, px * web_render_scale); + const cell_w: u32 = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(cw, 1))) * web_render_scale)); + const cell_h: u32 = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(chh, 1))) * web_render_scale)); + const ascent: i32 = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(asc, 1))) * web_render_scale)); + + // ---- WebGL2: runtime-compiled ES shaders + atlas texture + vbos ---- + const program = try makeGlProgramFrom(vert_glsl_es, frag_glsl_es); + const overlay_program = try makeGlProgramFrom(overlay_vert_glsl_es, overlay_frag_glsl_es); + var atlas_tex: c_uint = 0; + c.glGenTextures(1, &atlas_tex); + c.glBindTexture(c.GL_TEXTURE_2D, atlas_tex); + c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_MIN_FILTER, c.GL_LINEAR); + c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_MAG_FILTER, c.GL_LINEAR); + c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_WRAP_S, c.GL_CLAMP_TO_EDGE); + c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_WRAP_T, c.GL_CLAMP_TO_EDGE); + c.glPixelStorei(c.GL_UNPACK_ALIGNMENT, 1); + c.glTexImage2D(c.GL_TEXTURE_2D, 0, c.GL_R8, atlas_w, atlas_h, 0, c.GL_RED, c.GL_UNSIGNED_BYTE, null); + var vbo: c_uint = 0; + c.glGenBuffers(1, &vbo); + var overlay_vbo: c_uint = 0; + c.glGenBuffers(1, &overlay_vbo); + + const atlas_stage = try gpa.alloc(u8, atlas_w * atlas_h); + @memset(atlas_stage, 0); + const overlay_vertices = try gpa.alloc(OverlayVertex, max_overlay_vertices); + + const st = try gpa.create(WebState); + st.* = .{ + .g = .{ + .window = window, + .device = undefined, // web renders through GL, never the GPU device + .swapchain_format = undefined, + .pipeline = undefined, + .overlay_pipeline = undefined, + .crt_pipeline = undefined, + .atlas_tex = undefined, + .atlas_sampler = undefined, + .atlas_xfer = undefined, + .overlay_vbuf = undefined, + .overlay_vxfer = undefined, + .overlay_vertices = overlay_vertices, + .gl_program = program, + .gl_overlay_program = overlay_program, + .gl_atlas_tex = atlas_tex, + .gl_vbo = vbo, + .gl_overlay_vbo = overlay_vbo, + .gl_u_atlas = c.glGetUniformLocation(program, "u_atlas"), + .font = font, + .scale = scale, + .cell_w = cell_w, + .cell_h = cell_h, + .ascent = ascent, + .atlas_stage = atlas_stage, + .glyphs = std.AutoHashMap(u32, Slot).init(gpa), + }, + .core = undefined, + .gpa = gpa, + .frame_arena = .init(gpa), + }; + const g = &st.g; + // slot (0,0) is the space glyph (blank cells sample alpha=0 → bg only) + _ = c.ui_font_raster(font, scale, ' ', atlas_stage.ptr, @intCast(atlas_w), @intCast(cell_w), @intCast(cell_h), ascent); + g.pen_x = cell_w; + + // ---- the core: state replays from the embedded dump, no ptys ---- + pardes.image.start(std.Io.failing, gpa); // decode is synchronous; no io on wasm + syncCanvasSize(g); // size the backing canvas before deriving the grid + var opts = opts_in; + var pw: c_int = 0; + var ph: c_int = 0; + _ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph); + opts.cols = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), cell_w))); + opts.rows = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), cell_h))); + st.core = try pardes.Pardes.initFromDump(gpa, opts, embedded_dump); + live_web_state = st; + + _ = c.SDL_StartTextInput(window); + std.os.emscripten.emscripten_set_main_loop_arg(webFrame, st, 0, 0); +} + +fn webFrame(arg: ?*anyopaque) callconv(.c) void { + const st: *WebState = @ptrCast(@alignCast(arg.?)); + const g = &st.g; + const core = st.core; + // 1. input + var sev = std.mem.zeroes(c.SDL_Event); + while (c.SDL_PollEvent(&sev)) dispatch(g, core, &sev); + // 2. effects + drainEffectsWeb(core, st.gpa, g); + if (core.quit) return std.os.emscripten.emscripten_cancel_main_loop(); + // 3. the backing canvas follows the page, the grid follows the canvas + syncCanvasSize(g); + var pw: c_int = 0; + var ph: c_int = 0; + _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); + const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w))); + const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h))); + if (cols != core.screen_w or rows != core.screen_h) + core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); + // 4. render + _ = st.frame_arena.reset(.retain_capacity); + const surface = core.render(st.frame_arena.allocator()) catch return; + renderFrameGl(g, st.gpa, surface, core.show_debug) catch return; +} + +/// the canvas follows the page (CSS); the SDL window follows the canvas, +/// backed at web_render_scale (postWinsize in the prototype). +fn syncCanvasSize(g: *Gui) void { + var css_w: f64 = 0; + var css_h: f64 = 0; + if (emscripten_get_element_css_size("#canvas", &css_w, &css_h) != EMSCRIPTEN_RESULT_SUCCESS or css_w <= 0 or css_h <= 0) return; + const want_w: c_int = @intFromFloat(@round(css_w * web_render_scale)); + const want_h: c_int = @intFromFloat(@round(css_h * web_render_scale)); + var cur_w: c_int = 0; + var cur_h: c_int = 0; + _ = c.SDL_GetWindowSizeInPixels(g.window, &cur_w, &cur_h); + if (cur_w != want_w or cur_h != want_h) { + _ = c.SDL_SetWindowSize(g.window, want_w, want_h); + _ = c.SDL_SyncWindow(g.window); + } +} + +/// PARDES_TEST_GRID=1: headless. No SDL at all — stdin escape sequences in, +/// the rendered Surface out as text frames (same framing as the prototype). +fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { + const io = init.io; + const gpa = init.gpa; + const env = init.environ_map; + + pardes.image.start(io, gpa); + defer pardes.image.stop(); + + const grid_cols = envU16(env, "PARDES_TEST_COLS") orelse 80; + const grid_rows = envU16(env, "PARDES_TEST_ROWS") orelse 24; + var opts = opts_in; + if (opts.load_path != null) { + opts.cols = grid_cols; + opts.rows = grid_rows; + } + const core = if (opts.load_path) |lp| blk: { + const bytes = try @import("../look.zig").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(); + // the requested grid arrives as a resize EVENT (not init opts) so the core + // counts a resize and releases the shell greeting on first output + if (opts.load_path == null) + core.update(.{ .resize = .{ .cols = grid_cols, .rows = grid_rows } }); + + 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 ptys: [pardes.MAX_PANES]?Pty = @splat(null); + // per-slot spawn generation: drops a dead shell's late output/eof when its + // pane id has been respawned (see drainEffects .spawn) + var gens: [pardes.MAX_PANES]u32 = @splat(0); + defer for (&ptys) |*slot| if (slot.*) |pt| { + _ = libc.close(pt.fd); + slot.* = null; + }; + var queue: Queue = .{ .gpa = gpa, .sdl_wake = false }; + defer queue.close(); + drainEffects(core, &ptys, &gens, gpa, &queue, null, false); + for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), gens[id], &queue); + + setStdinRaw() catch {}; // stdin may be a pipe, not a pty — best effort + + var frame_arena: std.heap.ArenaAllocator = .init(gpa); + defer frame_arena.deinit(); + var feed: StdinFeed = .{}; + + // first frame before touching stdin, so `printf '' | pardes` still shows one + { + const surface = try core.render(frame_arena.allocator()); + try dumpGrid(gpa, surface); + } + while (!core.quit) { + const r = try feed.pump(gpa, core, null); + if (r.eof) break; + var n_events: usize = r.n_events; + var msgs = queue.take(); + for (msgs.items) |m| switch (m) { + .output => |o| { + if (gens[o.pane] == o.gen) + core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } }); + gpa.free(o.bytes); + n_events += 1; + }, + .eof => |e| { + _ = libc.close(e.fd); // the dead reader's master — stale or current + if (gens[e.pane] == e.gen) { + ptys[e.pane] = null; + core.update(.{ .eof = .{ .pane = e.pane } }); + } + n_events += 1; + }, + .lsp => |l| { + core.update(.{ .lsp_resp = .{ .id = l.id, .rows = l.rows } }); + gpa.free(l.rows); + n_events += 1; + }, + }; + msgs.deinit(gpa); + drainEffects(core, &ptys, &gens, gpa, &queue, null, true); + pollCwds(core, &ptys); + if (n_events == 0) continue; // idle tick: nothing changed, no frame + _ = frame_arena.reset(.retain_capacity); + const surface = try core.render(frame_arena.allocator()); + try dumpGrid(gpa, surface); + } +} + +// ===================================================================== +// test-mode stdin: terminal escape sequences (vaxis.Parser) → core events, +// plus the private synthetic finger OSC: +// ESC ] 777;finger;;;;; BEL (normalized 0..1) +// ===================================================================== + +const StdinFeed = struct { + parser: vaxis.Parser = .{}, + cache: vaxis.GraphemeCache = .{}, + buf: [1024]u8 = undefined, + fill: usize = 0, + last_cols: u16 = 0, + last_rows: u16 = 0, + + const Result = struct { eof: bool = false, n_events: usize = 0 }; + + /// Poll stdin briefly and translate what arrived. `g` is null in grid mode. + fn pump(f: *StdinFeed, gpa: std.mem.Allocator, core: *pardes.Pardes, g: ?*Gui) !Result { + var out: Result = .{}; + // a pty stdin also carries the winsize; poll it in place of SIGWINCH + var ws: posix.winsize = std.mem.zeroes(posix.winsize); + if (posix.system.ioctl(0, posix.T.IOCGWINSZ, @intFromPtr(&ws)) == 0 and + ws.col > 0 and ws.row > 0 and ws.col <= 1000 and ws.row <= 1000 and + (ws.col != f.last_cols or ws.row != f.last_rows)) + { + f.last_cols = ws.col; + f.last_rows = ws.row; + f.applyResize(core, g, ws.col, ws.row); + out.n_events += 1; + } + + var fds = [_]posix.pollfd{.{ .fd = 0, .events = posix.POLL.IN, .revents = 0 }}; + _ = posix.poll(&fds, 16) catch return out; + if ((fds[0].revents & posix.POLL.IN) == 0) { + if ((fds[0].revents & (posix.POLL.HUP | posix.POLL.ERR)) != 0) out.eof = true; + return out; + } + const rn = libc.read(0, f.buf[f.fill..].ptr, f.buf.len - f.fill); + if (rn < 0) return out; + if (rn == 0) { + out.eof = true; + return out; + } + const len = f.fill + @as(usize, @intCast(rn)); + f.fill = 0; + + var seq_start: usize = 0; + while (seq_start < len) { + // private finger OSC first (vaxis would swallow it as unknown OSC) + const prefix = "\x1b]777;finger;"; + if (std.mem.startsWith(u8, f.buf[seq_start..len], prefix)) { + const body = f.buf[seq_start + prefix.len .. len]; + const end = std.mem.indexOfScalar(u8, body, 0x07) orelse { + std.mem.copyForwards(u8, f.buf[0 .. len - seq_start], f.buf[seq_start..len]); + f.fill = len - seq_start; + break; + }; + if (parseFinger(body[0..end])) |finger_in| { + var finger = finger_in; + if (g) |gp| { + var wpw: c_int = 0; + var wph: c_int = 0; + _ = c.SDL_GetWindowSizeInPixels(gp.window, &wpw, &wph); + const win_w: f32 = @floatFromInt(@max(wpw, 1)); + const win_h: f32 = @floatFromInt(@max(wph, 1)); + if (core.crt_on) { + // touch points are physical: same CRT mapping as the mouse + const m = crt.map(finger.x * win_w, finger.y * win_h, win_w, win_h); + finger.x = m.x / win_w; + finger.y = m.y / win_h; + } + handleFinger(&gp.touch, core, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h)); + } else { + handleFinger(&grid_touch, core, finger, @floatFromInt(core.screen_w), @floatFromInt(core.screen_h), 1, 1); + } + out.n_events += 1; + } + seq_start += prefix.len + end + 1; + continue; + } + if (len - seq_start < prefix.len and std.mem.startsWith(u8, prefix, f.buf[seq_start..len])) { + std.mem.copyForwards(u8, f.buf[0 .. len - seq_start], f.buf[seq_start..len]); + f.fill = len - seq_start; + break; + } + + const result = f.parser.parse(f.buf[seq_start..len], gpa) catch break; + if (result.n == 0) { // partial sequence: shift to front, read more + std.mem.copyForwards(u8, f.buf[0 .. len - seq_start], f.buf[seq_start..len]); + f.fill = len - seq_start; + break; + } + seq_start += result.n; + const event = result.event orelse continue; + switch (event) { + .key_press => |key| { + var text: []const u8 = ""; + if (key.text) |t| text = f.cache.put(t); + core.update(.{ .key = .{ + .cp = mapKey(effCp(key)), + .text = text, + .ctrl = key.mods.ctrl, + .alt = key.mods.alt, + .shift = key.mods.shift, + } }); + out.n_events += 1; + }, + .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, + else => null, + }; + if (button) |b| { + core.update(.{ .mouse = .{ + .button = b, + .kind = switch (m.type) { + .press => .press, + .release => .release, + .motion => .motion, + .drag => .drag, + }, + .col = @intCast(@max(m.col, 0)), + .row = @intCast(@max(m.row, 0)), + .ctrl = m.mods.ctrl, + } }); + out.n_events += 1; + } + }, + .winsize => |vw| { + f.applyResize(core, g, @intCast(vw.cols), @intCast(vw.rows)); + out.n_events += 1; + }, + .paste => |text| { + core.update(.{ .paste = text }); + gpa.free(@constCast(text)); + out.n_events += 1; + }, + else => {}, + } + } + return out; + } + + fn applyResize(f: *StdinFeed, core: *pardes.Pardes, g: ?*Gui, cols: u16, rows: u16) void { + _ = f; + if (g) |gp| { + // capture mode: resize the window; the frame loop resizes the core + _ = c.SDL_SetWindowSize(gp.window, @intCast(cols * gp.cell_w), @intCast(rows * gp.cell_h)); + _ = c.SDL_SyncWindow(gp.window); + } else { + core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); + } + } +}; + +var grid_touch: Touch = .{}; // grid test mode has no Gui to hang it off + +fn parseFinger(payload: []const u8) ?Finger { + var parts = std.mem.splitScalar(u8, payload, ';'); + const type_name = parts.next() orelse return null; + const id_s = parts.next() orelse return null; + const x_s = parts.next() orelse return null; + const y_s = parts.next() orelse return null; + const pressure_s = parts.next() orelse return null; + if (parts.next() != null) return null; + const kind: @FieldType(Finger, "kind") = if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_DOWN") or std.mem.eql(u8, type_name, "down")) + .down + else if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_MOTION") or std.mem.eql(u8, type_name, "motion")) + .motion + else if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_UP") or std.mem.eql(u8, type_name, "up")) + .up + else if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_CANCELED") or std.mem.eql(u8, type_name, "cancel")) + .cancel + else + return null; + return .{ + .kind = kind, + .id = std.fmt.parseInt(u64, id_s, 10) catch return null, + .x = std.fmt.parseFloat(f32, x_s) catch return null, + .y = std.fmt.parseFloat(f32, y_s) catch return null, + .pressure = std.fmt.parseFloat(f32, pressure_s) catch return null, + }; +} + +fn setStdinRaw() !void { + var term = try posix.tcgetattr(0); + term.iflag.BRKINT = false; + term.iflag.ICRNL = false; + term.iflag.INPCK = false; + term.iflag.ISTRIP = false; + term.iflag.IXON = false; + term.oflag.OPOST = false; + term.lflag.ECHO = false; + term.lflag.ICANON = false; + term.lflag.IEXTEN = false; + term.lflag.ISIG = false; + term.cc[@intFromEnum(posix.V.MIN)] = 1; + term.cc[@intFromEnum(posix.V.TIME)] = 0; + try posix.tcsetattr(0, .NOW, term); +} + +// PARDES_TEST_GRID: one text frame per render, prototype-compatible framing. +fn dumpGrid(gpa: std.mem.Allocator, surface: *pardes.Surface) !void { + if (surface.cols == 0 or surface.rows == 0) return; + // build the whole frame in one buffer → one write → atomic snapshots + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(gpa); + const cur_x: u16 = if (surface.cursor) |cu| cu.x else 0; + const cur_y: u16 = if (surface.cursor) |cu| cu.y else 0; + var hdr: [128]u8 = undefined; + const hdr_str = std.fmt.bufPrint(&hdr, "\n---FRAME:{d}:{d}:{d}:{d}:{}---\n", .{ + surface.rows, surface.cols, cur_x, cur_y, surface.cursor != null, + }) catch return; + try buf.appendSlice(gpa, hdr_str); + var y: u16 = 0; + while (y < surface.rows) : (y += 1) { + var x: u16 = 0; + while (x < surface.cols) : (x += 1) { + const g = surface.at(x, y).grapheme(); + if (g.len > 0) try buf.appendSlice(gpa, g) else try buf.append(gpa, ' '); + } + try buf.append(gpa, '\n'); + } + try buf.appendSlice(gpa, "---ENDFRAME---\n"); + writeFd(1, buf.items); +} + +// ===================================================================== +// SDL event dispatch +// ===================================================================== + +fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void { + switch (sev.type) { + c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => core.quit = true, + // window resizes are picked up by the per-frame grid check + c.SDL_EVENT_KEY_DOWN => { + g.live_ctrl = (sev.key.mod & c.SDL_KMOD_CTRL) != 0; + g.live_alt = (sev.key.mod & c.SDL_KMOD_ALT) != 0; + keyDown(core, sev.key.key, sev.key.mod); + }, + c.SDL_EVENT_KEY_UP => { + g.live_ctrl = (sev.key.mod & c.SDL_KMOD_CTRL) != 0; + g.live_alt = (sev.key.mod & c.SDL_KMOD_ALT) != 0; + }, + c.SDL_EVENT_TEXT_INPUT => { + const tptr = sev.text.text orelse return; + var tlen: usize = 0; + while (tptr[tlen] != 0) : (tlen += 1) {} + if (tlen == 0) return; + const text: []const u8 = tptr[0..tlen]; + const cplen = std.unicode.utf8ByteSequenceLength(text[0]) catch return; + if (cplen > text.len) return; + const cp = std.unicode.utf8Decode(text[0..cplen]) catch return; + core.update(.{ .key = .{ .cp = cp, .text = text[0..cplen], .ctrl = g.live_ctrl, .alt = g.live_alt } }); + }, + c.SDL_EVENT_MOUSE_BUTTON_DOWN, c.SDL_EVENT_MOUSE_BUTTON_UP => { + const b = sev.button; + g.mouse_x = b.x; + g.mouse_y = b.y; + const button: pardes.Mouse.Button = switch (b.button) { + 1 => .left, + 2 => .middle, + 3 => .right, + else => return, + }; + const mc = mouseCell(g, core, b.x, b.y); + core.update(.{ + .mouse = .{ + .button = button, + .kind = if (b.down) .press else .release, + .col = mc.col, + .row = mc.row, + // asked of SDL directly rather than read off g.live_ctrl: + // that one is bookkeeping from KEY events, and a ctrl-click + // with no key pressed since startup would miss it + .ctrl = (c.SDL_GetModState() & c.SDL_KMOD_CTRL) != 0, + }, + }); + }, + c.SDL_EVENT_MOUSE_MOTION => { + const m = sev.motion; + g.mouse_x = m.x; + g.mouse_y = m.y; + // pad cursor continues from wherever the pointer last was + g.pad_x = m.x; + g.pad_y = m.y; + // drag = motion with a button held (selection extension keys off it) + const held: ?pardes.Mouse.Button = if ((m.state & c.SDL_BUTTON_LMASK) != 0) + .left + else if ((m.state & c.SDL_BUTTON_MMASK) != 0) + .middle + else if ((m.state & c.SDL_BUTTON_RMASK) != 0) + .right + else + null; + const mc = mouseCell(g, core, m.x, m.y); + core.update(.{ .mouse = .{ + .button = held orelse .none, + .kind = if (held != null) .drag else .motion, + .col = mc.col, + .row = mc.row, + } }); + }, + c.SDL_EVENT_MOUSE_WHEEL => { + const w = sev.wheel; + if (w.y == 0 and w.x == 0) return; + g.mouse_x = w.mouse_x; + g.mouse_y = w.mouse_y; + const mc = mouseCell(g, core, w.mouse_x, w.mouse_y); + if (w.y != 0) core.update(.{ .mouse = .{ + .button = if (w.y > 0) .wheel_up else .wheel_down, + .kind = .press, + .col = mc.col, + .row = mc.row, + } }); + if (w.x != 0) core.update(.{ .mouse = .{ + .button = if (w.x > 0) .wheel_right else .wheel_left, + .kind = .press, + .col = mc.col, + .row = mc.row, + } }); + }, + c.SDL_EVENT_FINGER_DOWN, c.SDL_EVENT_FINGER_MOTION, c.SDL_EVENT_FINGER_UP, c.SDL_EVENT_FINGER_CANCELED => { + const f = sev.tfinger; + var finger: Finger = .{ + .kind = switch (sev.type) { + c.SDL_EVENT_FINGER_DOWN => .down, + c.SDL_EVENT_FINGER_MOTION => .motion, + c.SDL_EVENT_FINGER_UP => .up, + else => .cancel, + }, + .id = @intCast(f.fingerID), + .x = f.x, + .y = f.y, + .pressure = f.pressure, + }; + var pw: c_int = 0; + var ph: c_int = 0; + _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); + var win_w: f32 = @floatFromInt(@max(pw, 1)); + var win_h: f32 = @floatFromInt(@max(ph, 1)); + var fcw: f32 = @floatFromInt(g.cell_w); + var fch: f32 = @floatFromInt(g.cell_h); + if (!is_emscripten and core.crt_on) { + // touch points are physical too: same CRT mapping as the mouse + const m = crt.map(finger.x * win_w, finger.y * win_h, win_w, win_h); + finger.x = m.x / win_w; + finger.y = m.y / win_h; + } + if (is_emscripten) { + // remap normalized coords into the letterboxed grid so the + // shared touch machine's cell math stays exact + const layout = cellLayout(g, core.screen_w, core.screen_h, @intCast(@max(pw, 1)), @intCast(@max(ph, 1))); + const grid_w = @max(1.0, @as(f32, @floatFromInt(core.screen_w)) * layout.w); + const grid_h = @max(1.0, @as(f32, @floatFromInt(core.screen_h)) * layout.h); + finger.x = std.math.clamp((finger.x * win_w - layout.x_off) / grid_w, 0.0, 1.0); + finger.y = std.math.clamp((finger.y * win_h - layout.y_off) / grid_h, 0.0, 1.0); + win_w = grid_w; + win_h = grid_h; + fcw = layout.w; + fch = layout.h; + } + handleFinger(&g.touch, core, finger, win_w, win_h, fcw, fch); + }, + c.SDL_EVENT_PINCH_BEGIN, c.SDL_EVENT_PINCH_UPDATE => core.update(.{ .pinch = sev.pinch.scale }), + // ---- steamdeck: first gamepad drives a virtual mouse (buttons here, + // axes polled per frame in pollGamepad) ---- + c.SDL_EVENT_GAMEPAD_ADDED => { + if (g.gamepad == null) g.gamepad = c.SDL_OpenGamepad(sev.gdevice.which); + }, + c.SDL_EVENT_GAMEPAD_REMOVED => { + if (g.gamepad) |pad| { + c.SDL_CloseGamepad(pad); + g.gamepad = null; + } + }, + // deck.zig maps buttons/triggers/trackpads to pointer, clicks, + // wheel, keys and rumble; this arm just applies its actions + c.SDL_EVENT_GAMEPAD_BUTTON_DOWN, + c.SDL_EVENT_GAMEPAD_BUTTON_UP, + c.SDL_EVENT_GAMEPAD_AXIS_MOTION, + c.SDL_EVENT_GAMEPAD_TOUCHPAD_DOWN, + c.SDL_EVENT_GAMEPAD_TOUCHPAD_MOTION, + c.SDL_EVENT_GAMEPAD_TOUCHPAD_UP, + => { + const in: deck.Input = switch (sev.type) { + c.SDL_EVENT_GAMEPAD_BUTTON_DOWN, c.SDL_EVENT_GAMEPAD_BUTTON_UP => .{ .button = .{ + .idx = sev.gbutton.button, + .down = sev.gbutton.down, + } }, + c.SDL_EVENT_GAMEPAD_AXIS_MOTION => .{ .axis = .{ + .idx = sev.gaxis.axis, + .value = sev.gaxis.value, + } }, + else => .{ .touch = .{ + .pad = @intCast(std.math.clamp(sev.gtouchpad.touchpad, 0, 255)), + .phase = switch (sev.type) { + c.SDL_EVENT_GAMEPAD_TOUCHPAD_DOWN => .down, + c.SDL_EVENT_GAMEPAD_TOUCHPAD_UP => .up, + else => .motion, + }, + .x = sev.gtouchpad.x, + .y = sev.gtouchpad.y, + .pressure = sev.gtouchpad.pressure, + } }, + }; + var acts: [3]deck.Action = undefined; + for (acts[0..g.deck.feed(in, &acts)]) |act| switch (act) { + .move => |mv| { + var pw: c_int = 0; + var ph: c_int = 0; + _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); + // pad_x/pad_y stay PHYSICAL; mouseCell applies the CRT + // mapping once at cell conversion, never into the pad + const old = mouseCell(g, core, g.pad_x, g.pad_y); + g.pad_x = std.math.clamp(g.pad_x + mv.dx, 0, @as(f32, @floatFromInt(@max(pw, 1))) - 1); + g.pad_y = std.math.clamp(g.pad_y + mv.dy, 0, @as(f32, @floatFromInt(@max(ph, 1))) - 1); + // the SDL cursor (plan9 arrow) rides along, so the pad + // cursor and a hardware mouse are one visible pointer + c.SDL_WarpMouseInWindow(g.window, g.pad_x, g.pad_y); + const mc = mouseCell(g, core, g.pad_x, g.pad_y); + if (mc.col == old.col and mc.row == old.row) continue; + // moving with a click held drags, so selections stretch + // (a firm right-pad press drags-selects like a laptop pad) + const held: ?pardes.Mouse.Button = if (g.deck.r2_held or g.deck.rpad_click) + .left + else if (g.deck.l2_held) + .middle + else if (g.deck.look_held) + .right + else + null; + core.update(.{ .mouse = .{ + .button = held orelse .none, + .kind = if (held != null) .drag else .motion, + .col = mc.col, + .row = mc.row, + } }); + }, + .click => |ck| { + const mc = mouseCell(g, core, g.pad_x, g.pad_y); + core.update(.{ .mouse = .{ + .button = switch (ck.which) { + .select => .left, + .execute => .middle, + .look => .right, + }, + .kind = if (ck.down) .press else .release, + .col = mc.col, + .row = mc.row, + } }); + }, + .wheel => |ticks| { + const mc = mouseCell(g, core, g.pad_x, g.pad_y); + var left = ticks; + while (left != 0) { + left += if (ticks > 0) -1 else 1; + core.update(.{ .mouse = .{ + .button = if (ticks > 0) .wheel_down else .wheel_up, + .kind = .press, + .col = mc.col, + .row = mc.row, + } }); + } + }, + .key => |k| core.update(.{ + .key = switch (k) { + .n => .{ .cp = 'n', .text = "n" }, + .cap_n => .{ .cp = 'N', .text = "N" }, + .enter => .{ .cp = pardes.Key.enter }, + .tab => .{ .cp = pardes.Key.tab }, + // back paddle: Ctrl- flips tty mode + .tty_toggle => .{ .cp = core.opts.tty_toggle, .ctrl = true }, + }, + }), + // a brief gentle ack for execute/look, not a buzz + .rumble => if (g.gamepad) |pad| { + _ = c.SDL_RumbleGamepad(pad, 0x4000, 0x4000, 80); + }, + }; + }, + else => {}, + } +} + +/// Special keys + ctrl/alt shortcuts. Plain printable keys arrive as +/// SDL_EVENT_TEXT_INPUT instead (so shift/layout map correctly). +fn keyDown(core: *pardes.Pardes, sym: c.SDL_Keycode, mod: c.SDL_Keymod) void { + const ctrl = (mod & c.SDL_KMOD_CTRL) != 0; + const alt = (mod & c.SDL_KMOD_ALT) != 0; + const shift = (mod & c.SDL_KMOD_SHIFT) != 0; + const gui_mod = (mod & c.SDL_KMOD_GUI) != 0; + const cp: u21 = switch (sym) { + c.SDLK_RETURN, c.SDLK_KP_ENTER => pardes.Key.enter, + c.SDLK_BACKSPACE => pardes.Key.backspace, + c.SDLK_TAB => pardes.Key.tab, + c.SDLK_ESCAPE => pardes.Key.escape, + c.SDLK_LEFT => pardes.Key.left, + c.SDLK_RIGHT => pardes.Key.right, + c.SDLK_UP => pardes.Key.up, + c.SDLK_DOWN => pardes.Key.down, + c.SDLK_HOME => pardes.Key.home, + c.SDLK_END => pardes.Key.end, + c.SDLK_PAGEUP => pardes.Key.page_up, + c.SDLK_PAGEDOWN => pardes.Key.page_down, + c.SDLK_DELETE => pardes.Key.delete, + else => blk: { + // letters / digits / punctuation only as a modifier shortcut; + // plain printable (incl. space) goes via TEXT_INPUT + if (!(ctrl or alt or gui_mod)) break :blk 0; + if (sym < 128 and sym >= ' ') break :blk @intCast(sym); + break :blk 0; + }, + }; + if (cp == 0) return; + core.update(.{ .key = .{ .cp = cp, .ctrl = ctrl, .alt = alt, .shift = shift } }); +} + +fn pixelCell(px: f32, cell: u32) u16 { + return pixelCellF(px, @floatFromInt(cell)); +} + +fn pixelCellF(px: f32, cell: f32) u16 { + const idx = @floor(@max(px, 0) / @max(cell, 1)); + return @intFromFloat(@min(idx, 10_000)); +} + +/// mouse coords → cell. Native windows are pixel-exact; web SDL reports CSS +/// points into a letterboxed canvas backed at web_render_scale. With the Crt +/// builtin on, the physical point first maps to the scene point the shader +/// displays there (crt.map) — this is the one physical→logical spot shared +/// by the real mouse and the deck's virtual cursor. +fn mouseCell(g: *const Gui, core: *const pardes.Pardes, x: f32, y: f32) struct { col: u16, row: u16 } { + if (!is_emscripten) { + var mx = x; + var my = y; + if (core.crt_on) { + var pw: c_int = 0; + var ph: c_int = 0; + _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); + const m = crt.map(mx, my, @floatFromInt(@max(pw, 1)), @floatFromInt(@max(ph, 1))); + mx = m.x; + my = m.y; + } + return .{ .col = pixelCell(mx, g.cell_w), .row = pixelCell(my, g.cell_h) }; + } + var pw: c_int = 0; + var ph: c_int = 0; + _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); + var ww: c_int = 0; + var wh: c_int = 0; + _ = c.SDL_GetWindowSize(g.window, &ww, &wh); + const px = x * @as(f32, @floatFromInt(@max(pw, 1))) / @as(f32, @floatFromInt(@max(ww, 1))); + const py = y * @as(f32, @floatFromInt(@max(ph, 1))) / @as(f32, @floatFromInt(@max(wh, 1))); + const layout = cellLayout(g, core.screen_w, core.screen_h, @intCast(@max(pw, 1)), @intCast(@max(ph, 1))); + return .{ + .col = pixelCellF(px - layout.x_off, layout.w), + .row = pixelCellF(py - layout.y_off, layout.h), + }; +} + +/// steamdeck support: poll the sticks each frame, mirroring the trackpads — +/// RIGHT stick moves the virtual cursor at ~cell granularity (emits +/// button-less motion so hover works), LEFT stick accumulates into wheel +/// ticks (both axes: vertical + horizontal) at the cursor position. +fn pollGamepad(g: *Gui, core: *pardes.Pardes) void { + const pad = g.gamepad orelse return; + var pw: c_int = 0; + var ph: c_int = 0; + _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); + const win_w: f32 = @floatFromInt(@max(pw, 1)); + const win_h: f32 = @floatFromInt(@max(ph, 1)); + const deadzone: f32 = 8000; + // right stick = pointer + const ax: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_RIGHTX)); + const ay: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_RIGHTY)); + const old = mouseCell(g, core, g.pad_x, g.pad_y); + // full tilt ≈ 0.4 cell-heights per frame: a gentle, aimable glide (the + // mouse-move sensitivity knob — raise for a faster pointer) + const speed: f32 = @as(f32, @floatFromInt(g.cell_h)) * 0.4; + if (@abs(ax) > deadzone) g.pad_x = std.math.clamp(g.pad_x + ax / 32767.0 * speed, 0, win_w - 1); + if (@abs(ay) > deadzone) g.pad_y = std.math.clamp(g.pad_y + ay / 32767.0 * speed, 0, win_h - 1); + // only on actual stick movement — an unconditional per-frame warp would + // pin the pointer and fight any hardware mouse + if (@abs(ax) > deadzone or @abs(ay) > deadzone) c.SDL_WarpMouseInWindow(g.window, g.pad_x, g.pad_y); + const mc = mouseCell(g, core, g.pad_x, g.pad_y); + if (mc.col != old.col or mc.row != old.row) + core.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = mc.col, .row = mc.row } }); + // left stick = scroll (both axes) + const lx: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_LEFTX)); + const ly: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_LEFTY)); + if (@abs(ly) > deadzone) g.pad_scroll += ly / 32767.0 * 0.15; + if (@abs(lx) > deadzone) g.pad_scroll_h += lx / 32767.0 * 0.15; + while (@abs(g.pad_scroll) >= 1.0) { + const button: pardes.Mouse.Button = if (g.pad_scroll < 0) .wheel_up else .wheel_down; + g.pad_scroll += if (g.pad_scroll < 0) 1.0 else -1.0; + core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = mc.col, .row = mc.row } }); + } + while (@abs(g.pad_scroll_h) >= 1.0) { + const button: pardes.Mouse.Button = if (g.pad_scroll_h < 0) .wheel_left else .wheel_right; + g.pad_scroll_h += if (g.pad_scroll_h < 0) 1.0 else -1.0; + core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = mc.col, .row = mc.row } }); + } +} + +// ===================================================================== +// effects — identical duties to tty.zig's drainEffects, plus SDL clipboard +// ===================================================================== + +fn drainEffects( + core: *pardes.Pardes, + ptys: *[pardes.MAX_PANES]?Pty, + gens: *[pardes.MAX_PANES]u32, + gpa: std.mem.Allocator, + queue: *Queue, + g: ?*Gui, // null in grid test mode (no SDL: clipboard effects are no-ops) + 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. Kill + // it; its detached reader wakes on child death and the gen-guarded + // eof closes the old fd (not here — the reader still reads it). + if (ptys[sp.pane]) |old| { + _ = libc.kill(old.pid, libc.SIG.KILL); + 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 pt = forkShell(cwd_z, core.screen_h, core.screen_w); + ptys[sp.pane] = pt; + // report the pane's starting directory back to the core (tags) + var lbuf: [1024]u8 = undefined; + if (look.shellCwd(pt.pid, &lbuf)) |wd| core.setCwd(sp.pane, wd); + if (threads_ok) spawnReader(gpa, pt, sp.pane, gens[sp.pane], queue); + }, + .write => |w| { + if (ptys[w.pane]) |pt| writeFd(pt.fd, 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.fd, 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); + core.setLastDump(path); + _ = libc.close(fd); + }, + .set_clipboard => { + if (g == null) continue; + const y = core.yank orelse continue; + const z = gpa.dupeZ(u8, y) catch continue; + defer gpa.free(z); + _ = c.SDL_SetClipboardText(z.ptr); + }, + .lsp => |e| if (threads_ok) spawnLsp(core, gpa, queue, e), + .quit => {}, + }; +} + +fn forkShell(cwd: ?[*:0]const u8, rows: u16, cols: u16) Pty { + 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) { + if (cwd) |cd| _ = chdir(cd); + const argv: [4:null]?[*:0]const u8 = .{ bash_path, "--rcfile", "/tmp/pardes-osc133.bash", null }; + _ = execv(bash_path, &argv); + _exit(127); + } + return .{ .fd = master, .pid = pid }; +} + +fn pollCwds(core: *pardes.Pardes, ptys: *[pardes.MAX_PANES]?Pty) void { + for (ptys, 0..) |slot, id| if (slot) |pt| { + var lbuf: [1024]u8 = undefined; + if (look.shellCwd(pt.pid, &lbuf)) |cwd| core.setCwd(id, cwd); + }; +} + +/// web: no ptys, no fs — panes replay from the embedded dump. Only the +/// clipboard and open_link effects have a browser meaning. +fn drainEffectsWeb(core: *pardes.Pardes, gpa: std.mem.Allocator, g: *Gui) void { + _ = g; + while (core.nextEffect()) |effect| switch (effect) { + .set_clipboard => { + const y = core.yank orelse continue; + const z = gpa.dupeZ(u8, y) catch continue; + defer gpa.free(z); + _ = c.SDL_SetClipboardText(z.ptr); + }, + // look on a URL → a new tab + .open_link => |url| openLinkWeb(gpa, url.slice()), + // nothing to spawn/write/resize/save/dump into — all no-ops + .spawn, .write, .resize_pty, .save_file, .write_dump, .lsp, .quit => {}, + }; +} + +/// window.open in a tiny generated script; single quotes and backslashes are +/// %-escaped so the url can't break out of the JS string literal. +fn openLinkWeb(gpa: std.mem.Allocator, url: []const u8) void { + var safe: std.ArrayList(u8) = .empty; + defer safe.deinit(gpa); + for (url) |ch| switch (ch) { + '\'' => safe.appendSlice(gpa, "%27") catch return, + '\\' => safe.appendSlice(gpa, "%5C") catch return, + else => safe.append(gpa, ch) catch return, + }; + const script = std.fmt.allocPrintSentinel(gpa, "window.open('{s}','_blank')", .{safe.items}, 0) catch return; + defer gpa.free(script); + std.os.emscripten.emscripten_run_script(script.ptr); +} + +// ===================================================================== +// render: Surface → instanced quads → SDL GPU +// ===================================================================== + +fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, crt_on: bool, debug_on: bool) !void { + const cmd = c.SDL_AcquireGPUCommandBuffer(g.device) orelse return; + var sw: u32 = 0; + var sh: u32 = 0; + var target: *c.SDL_GPUTexture = undefined; + if (g.capture) { + // capture: render offscreen (the window may never present), dump PPM + sw = @as(u32, surface.cols) * g.cell_w; + sh = @as(u32, surface.rows) * g.cell_h; + if (sw == 0 or sh == 0) { + _ = c.SDL_SubmitGPUCommandBuffer(cmd); + return; + } + try ensureCaptureTexture(g, sw, sh); + target = g.capture_tex.?; + } else { + var swap_tex: ?*c.SDL_GPUTexture = null; + if (!c.SDL_AcquireGPUSwapchainTexture(cmd, g.window, &swap_tex, &sw, &sh)) { + _ = c.SDL_SubmitGPUCommandBuffer(cmd); + return; + } + target = swap_tex orelse { // swapchain busy: skip the frame + _ = c.SDL_SubmitGPUCommandBuffer(cmd); + return; + }; + } + const win_w: f32 = @floatFromInt(sw); + const win_h: f32 = @floatFromInt(sh); + + // Crt on: the scene pass lands in an offscreen texture, then a second + // pass draws it through the CRT shader onto the real target + if (crt_on) try ensureSceneTexture(g, sw, sh); + const scene: *c.SDL_GPUTexture = if (crt_on) g.scene_tex.? else target; + + const overlay_count = buildTouchOverlay(g, sw, sh, debug_on); + if (overlay_count != 0) uploadOverlayGpu(g, cmd, overlay_count); + + const cells: u32 = @as(u32, surface.cols) * surface.rows; + if (cells != 0) try ensureVbuf(g, cells); + + var color_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo); + color_target.texture = scene; + color_target.clear_color = .{ + .r = @as(f32, @floatFromInt(bg_default[0])) / 255.0, + .g = @as(f32, @floatFromInt(bg_default[1])) / 255.0, + .b = @as(f32, @floatFromInt(bg_default[2])) / 255.0, + .a = 1.0, + }; + color_target.load_op = c.SDL_GPU_LOADOP_CLEAR; + color_target.store_op = c.SDL_GPU_STOREOP_STORE; + + if (cells != 0 and g.vbuf != null) { + // ponytail: full re-upload every frame; the prototype's dirty-range + // diffing (cell_keys + coalesced ranges) is skipped for now. + const vptr: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, g.vxfer.?, false) orelse { + _ = c.SDL_SubmitGPUCommandBuffer(cmd); + return; + }); + const instances: [*]CellInstance = @ptrCast(@alignCast(vptr)); + const layout = fixedCellLayout(g); + const cursor_idx: u32 = if (surface.cursor) |cu| @as(u32, cu.y) * surface.cols + cu.x else cells; + const cursor_bar = if (surface.cursor) |cu| cu.bar else false; + var row: u16 = 0; + while (row < surface.rows) : (row += 1) { + var col: u16 = 0; + while (col < surface.cols) : (col += 1) { + const idx: u32 = @as(u32, row) * surface.cols + col; + emitInstance(g, instances, idx, col, row, layout, win_w, win_h, surface.at(col, row), idx == cursor_idx, cursor_bar); + } + } + c.SDL_UnmapGPUTransferBuffer(g.device, g.vxfer.?); + + // emitInstance may have rasterized new glyphs into the staging atlas; + // upload after vertex generation so this frame has what it references + if (g.atlas_dirty) uploadAtlas(g, cmd); + + const copy = c.SDL_BeginGPUCopyPass(cmd); + const src = c.SDL_GPUTransferBufferLocation{ .transfer_buffer = g.vxfer.?, .offset = 0 }; + const dst = c.SDL_GPUBufferRegion{ .buffer = g.vbuf.?, .offset = 0, .size = cells * @sizeOf(CellInstance) }; + c.SDL_UploadToGPUBuffer(copy, &src, &dst, false); + c.SDL_EndGPUCopyPass(copy); + + const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null); + c.SDL_BindGPUGraphicsPipeline(rp, g.pipeline); + const samp_binding = c.SDL_GPUTextureSamplerBinding{ .texture = g.atlas_tex, .sampler = g.atlas_sampler }; + c.SDL_BindGPUFragmentSamplers(rp, 0, &samp_binding, 1); + const vbinding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = 0 }; + c.SDL_BindGPUVertexBuffers(rp, 0, &vbinding, 1); + c.SDL_DrawGPUPrimitives(rp, 6, cells, 0, 0); + drawOverlayGpu(g, rp, overlay_count); + c.SDL_EndGPURenderPass(rp); + } else { + // no grid yet: just clear to the page bg + const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null); + drawOverlayGpu(g, rp, overlay_count); + c.SDL_EndGPURenderPass(rp); + } + + if (crt_on) { + var crt_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo); + crt_target.texture = target; + crt_target.load_op = c.SDL_GPU_LOADOP_DONT_CARE; + crt_target.store_op = c.SDL_GPU_STOREOP_STORE; + const rp = c.SDL_BeginGPURenderPass(cmd, &crt_target, 1, null); + c.SDL_BindGPUGraphicsPipeline(rp, g.crt_pipeline); + const samp_binding = c.SDL_GPUTextureSamplerBinding{ .texture = g.scene_tex.?, .sampler = g.atlas_sampler }; + c.SDL_BindGPUFragmentSamplers(rp, 0, &samp_binding, 1); + c.SDL_DrawGPUPrimitives(rp, 3, 1, 0, 0); + c.SDL_EndGPURenderPass(rp); + } + + if (g.capture) return captureFrame(g, gpa, cmd, target, sw, sh); + _ = c.SDL_SubmitGPUCommandBuffer(cmd); +} + +fn emitInstance( + g: *Gui, + instances: [*]CellInstance, + idx: u32, + col: u16, + row: u16, + layout: CellLayout, + win_w: f32, + win_h: f32, + cell: *const pardes.Cell, + is_cursor: bool, + cursor_bar: bool, +) void { + // resolve the cell style to concrete fg/bg, matching the prototype's + // cellKey: reverse swaps, invisible hides, dim darkens. The cursor cell + // renders as reverse (cancelling an already-reversed cell); an insert + // cursor keeps the cell colors and draws a bar glyph instead. + var fg = fg_default; + var bg = bg_default; + var reverse = is_cursor and !cursor_bar; + if (!cell.default) { + const st = cell.style; + fg = switch (st.fg) { + .default => fg_default, + .index => |i| palColor(i), + .rgb => |rgb| rgb, + }; + bg = switch (st.bg) { + .default => bg_default, + .index => |i| palColor(i), + .rgb => |rgb| rgb, + }; + if (st.reverse) reverse = !reverse; + if (st.invisible) fg = bg; + if (st.dim) for (&fg) |*ch| { + ch.* = @intCast(@as(u16, ch.*) * 6 / 10); + }; + } + if (reverse) std.mem.swap([3]u8, &fg, &bg); + + // cell pixel rect (top-left origin) → NDC (y up); the layout letterboxes on web + const px0 = layout.x_off + @as(f32, @floatFromInt(col)) * layout.w; + const py0 = layout.y_off + @as(f32, @floatFromInt(row)) * layout.h; + const x0 = (px0 / win_w) * 2.0 - 1.0; + const x1 = ((px0 + layout.w) / win_w) * 2.0 - 1.0; + const y0 = 1.0 - (py0 / win_h) * 2.0; + const y1 = 1.0 - ((py0 + layout.h) / win_h) * 2.0; + + const grapheme = cell.grapheme(); + var cp: u32 = if (cell.default or grapheme.len == 0) ' ' else firstCp(grapheme); + // ponytail: the insert bar is the left-eighth block glyph over the cell + // (hides the char under it); a dedicated quad if that ever matters + if (is_cursor and cursor_bar) cp = 0x258F; + const slot = if (cp == ' ') g.space_slot else ensureGlyph(g, cp); + const aw_f: f32 = @floatFromInt(atlas_w); + const ah_f: f32 = @floatFromInt(atlas_h); + + instances[idx] = .{ + .x0 = x0, + .y0 = y0, + .x1 = x1, + .y1 = y1, + .u0 = @as(f32, @floatFromInt(slot.u)) / aw_f, + .v0 = @as(f32, @floatFromInt(slot.v)) / ah_f, + .u1 = @as(f32, @floatFromInt(slot.u + g.cell_w)) / aw_f, + .v1 = @as(f32, @floatFromInt(slot.v + g.cell_h)) / ah_f, + .fr = @as(f32, @floatFromInt(fg[0])) / 255.0, + .fg = @as(f32, @floatFromInt(fg[1])) / 255.0, + .fb = @as(f32, @floatFromInt(fg[2])) / 255.0, + .br = @as(f32, @floatFromInt(bg[0])) / 255.0, + .bg = @as(f32, @floatFromInt(bg[1])) / 255.0, + .bb = @as(f32, @floatFromInt(bg[2])) / 255.0, + }; +} + +fn palColor(idx: u8) [3]u8 { + const p = ghostty_vt.color.default[idx]; + return .{ p.r, p.g, p.b }; +} + +// first codepoint of a UTF-8 grapheme; space on failure/empty +fn firstCp(s: []const u8) u32 { + if (s.len == 0) return ' '; + const n = std.unicode.utf8ByteSequenceLength(s[0]) catch return ' '; + if (n > s.len) return ' '; + return std.unicode.utf8Decode(s[0..n]) catch ' '; +} + +fn ensureGlyph(g: *Gui, cp: u32) Slot { + if (g.glyphs.get(cp)) |s| return s; + if (g.pen_x + g.cell_w > atlas_w) { + g.pen_x = 0; + g.pen_y += g.cell_h; + } + if (g.pen_y + g.cell_h > atlas_h) return g.space_slot; // atlas full: fall back + const s = Slot{ .u = g.pen_x, .v = g.pen_y }; + const out = g.atlas_stage.ptr + @as(usize, s.v) * atlas_w + s.u; + _ = c.ui_font_raster(g.font, g.scale, @intCast(cp), out, @intCast(atlas_w), @intCast(g.cell_w), @intCast(g.cell_h), g.ascent); + g.glyphs.put(cp, s) catch return g.space_slot; + g.pen_x += g.cell_w; + g.atlas_dirty = true; + return s; +} + +fn uploadAtlas(g: *Gui, cmd: *c.SDL_GPUCommandBuffer) void { + const ptr = c.SDL_MapGPUTransferBuffer(g.device, g.atlas_xfer, false) orelse return; + const dst: [*]u8 = @ptrCast(ptr); + @memcpy(dst[0 .. atlas_w * atlas_h], g.atlas_stage); + c.SDL_UnmapGPUTransferBuffer(g.device, g.atlas_xfer); + + const copy = c.SDL_BeginGPUCopyPass(cmd); + const src = c.SDL_GPUTextureTransferInfo{ .transfer_buffer = g.atlas_xfer, .offset = 0, .pixels_per_row = atlas_w, .rows_per_layer = atlas_h }; + const dstregion = c.SDL_GPUTextureRegion{ .texture = g.atlas_tex, .mip_level = 0, .layer = 0, .x = 0, .y = 0, .z = 0, .w = atlas_w, .h = atlas_h, .d = 1 }; + c.SDL_UploadToGPUTexture(copy, &src, &dstregion, false); + c.SDL_EndGPUCopyPass(copy); + g.atlas_dirty = false; +} + +fn ensureVbuf(g: *Gui, cells: u32) !void { + if (g.vbuf_cells == cells and g.vbuf != null) return; + if (g.vbuf) |b| c.SDL_ReleaseGPUBuffer(g.device, b); + if (g.vxfer) |x| c.SDL_ReleaseGPUTransferBuffer(g.device, x); + g.vbuf = null; + g.vxfer = null; + const size = cells * @sizeOf(CellInstance); + var vb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = size, .props = 0 }; + g.vbuf = c.SDL_CreateGPUBuffer(g.device, &vb_info) orelse return error.GpuCreate; + var xf_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = size, .props = 0 }; + g.vxfer = c.SDL_CreateGPUTransferBuffer(g.device, &xf_info) orelse return error.GpuCreate; + g.vbuf_cells = cells; +} + +fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline { + const vs = try makeShader(device, vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0); + defer c.SDL_ReleaseGPUShader(device, vs); + const fs = try makeShader(device, frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1); + defer c.SDL_ReleaseGPUShader(device, fs); + + var vbuf_desc = c.SDL_GPUVertexBufferDescription{ + .slot = 0, + .pitch = @sizeOf(CellInstance), + .input_rate = c.SDL_GPU_VERTEXINPUTRATE_INSTANCE, + .instance_step_rate = 0, + }; + var attrs = [_]c.SDL_GPUVertexAttribute{ + .{ .location = 0, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "x0") }, + .{ .location = 1, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "u0") }, + .{ .location = 2, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT3, .offset = @offsetOf(CellInstance, "fr") }, + .{ .location = 3, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT3, .offset = @offsetOf(CellInstance, "br") }, + }; + var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = std.mem.zeroes(c.SDL_GPUColorTargetBlendState) }; + + var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo); + info.vertex_shader = vs; + info.fragment_shader = fs; + info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST; + info.vertex_input_state = .{ + .vertex_buffer_descriptions = &vbuf_desc, + .num_vertex_buffers = 1, + .vertex_attributes = &attrs, + .num_vertex_attributes = attrs.len, + }; + info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL; + info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE; + info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1; + info.target_info = .{ .color_target_descriptions = &col_desc, .num_color_targets = 1 }; + return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate; +} + +fn makeOverlayPipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline { + const vs = try makeShader(device, overlay_vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0); + defer c.SDL_ReleaseGPUShader(device, vs); + const fs = try makeShader(device, overlay_frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 0); + defer c.SDL_ReleaseGPUShader(device, fs); + + var vbuf_desc = c.SDL_GPUVertexBufferDescription{ + .slot = 0, + .pitch = @sizeOf(OverlayVertex), + .input_rate = c.SDL_GPU_VERTEXINPUTRATE_VERTEX, + .instance_step_rate = 0, + }; + var attrs = [_]c.SDL_GPUVertexAttribute{ + .{ .location = 0, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT2, .offset = @offsetOf(OverlayVertex, "x") }, + .{ .location = 1, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(OverlayVertex, "r") }, + }; + var blend = std.mem.zeroes(c.SDL_GPUColorTargetBlendState); + blend.src_color_blendfactor = c.SDL_GPU_BLENDFACTOR_SRC_ALPHA; + blend.dst_color_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA; + blend.color_blend_op = c.SDL_GPU_BLENDOP_ADD; + blend.src_alpha_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE; + blend.dst_alpha_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA; + blend.alpha_blend_op = c.SDL_GPU_BLENDOP_ADD; + blend.enable_blend = true; + var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = blend }; + + var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo); + info.vertex_shader = vs; + info.fragment_shader = fs; + info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST; + info.vertex_input_state = .{ + .vertex_buffer_descriptions = &vbuf_desc, + .num_vertex_buffers = 1, + .vertex_attributes = &attrs, + .num_vertex_attributes = attrs.len, + }; + info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL; + info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE; + info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1; + info.target_info = .{ .color_target_descriptions = &col_desc, .num_color_targets = 1 }; + return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate; +} + +// the CRT pass: a fullscreen triangle sampling the scene texture, no vertex +// buffers at all (positions from gl_VertexIndex) +fn makeCrtPipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline { + const vs = try makeShader(device, crt_vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0); + defer c.SDL_ReleaseGPUShader(device, vs); + const fs = try makeShader(device, crt_frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1); + defer c.SDL_ReleaseGPUShader(device, fs); + + var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = std.mem.zeroes(c.SDL_GPUColorTargetBlendState) }; + var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo); + info.vertex_shader = vs; + info.fragment_shader = fs; + info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST; + info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL; + info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE; + info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1; + info.target_info = .{ .color_target_descriptions = &col_desc, .num_color_targets = 1 }; + return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate; +} + +fn makeShader(device: *c.SDL_GPUDevice, code: []const u8, stage: c.SDL_GPUShaderStage, n_samplers: u32) !*c.SDL_GPUShader { + var info = std.mem.zeroes(c.SDL_GPUShaderCreateInfo); + info.code = code.ptr; + info.code_size = code.len; + info.entrypoint = "main"; + info.format = c.SDL_GPU_SHADERFORMAT_SPIRV; + info.stage = stage; + info.num_samplers = n_samplers; + return c.SDL_CreateGPUShader(device, &info) orelse error.GpuCreate; +} + +// ===================================================================== +// web render: Surface → the same instanced quads → WebGL2. Full instance +// re-upload each frame; attribs re-specified per draw (no VAO to manage). +// ===================================================================== + +fn renderFrameGl(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, debug_on: bool) !void { + var pw: c_int = 0; + var ph: c_int = 0; + _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); + const sw: u32 = @intCast(@max(pw, 1)); + const sh: u32 = @intCast(@max(ph, 1)); + c.glViewport(0, 0, @intCast(sw), @intCast(sh)); + c.glClearColor( + @as(f32, @floatFromInt(bg_default[0])) / 255.0, + @as(f32, @floatFromInt(bg_default[1])) / 255.0, + @as(f32, @floatFromInt(bg_default[2])) / 255.0, + 1.0, + ); + c.glClear(c.GL_COLOR_BUFFER_BIT); + + const overlay_count = buildTouchOverlay(g, sw, sh, debug_on); + const cells: u32 = @as(u32, surface.cols) * surface.rows; + if (cells == 0) { + drawOverlayGl(g, overlay_count); + _ = c.SDL_GL_SwapWindow(g.window); + return; + } + if (g.gl_instances.len < cells) { + gpa.free(g.gl_instances); + g.gl_instances = try gpa.alloc(CellInstance, cells); + } + + const win_w: f32 = @floatFromInt(sw); + const win_h: f32 = @floatFromInt(sh); + const layout = cellLayout(g, surface.cols, surface.rows, sw, sh); + const cursor_idx: u32 = if (surface.cursor) |cu| @as(u32, cu.y) * surface.cols + cu.x else cells; + const cursor_bar = if (surface.cursor) |cu| cu.bar else false; + var row: u16 = 0; + while (row < surface.rows) : (row += 1) { + var col: u16 = 0; + while (col < surface.cols) : (col += 1) { + const idx: u32 = @as(u32, row) * surface.cols + col; + emitInstance(g, g.gl_instances.ptr, idx, col, row, layout, win_w, win_h, surface.at(col, row), idx == cursor_idx, cursor_bar); + } + } + if (g.atlas_dirty) uploadAtlasGl(g); + + c.glUseProgram(g.gl_program); + c.glActiveTexture(c.GL_TEXTURE0); + c.glBindTexture(c.GL_TEXTURE_2D, g.gl_atlas_tex); + c.glUniform1i(g.gl_u_atlas, 0); + c.glBindBuffer(c.GL_ARRAY_BUFFER, g.gl_vbo); + c.glBufferData(c.GL_ARRAY_BUFFER, @intCast(cells * @sizeOf(CellInstance)), g.gl_instances.ptr, c.GL_STREAM_DRAW); + const stride: c_int = @intCast(@sizeOf(CellInstance)); + c.glEnableVertexAttribArray(0); + c.glVertexAttribPointer(0, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "x0"))); + c.glVertexAttribDivisor(0, 1); + c.glEnableVertexAttribArray(1); + c.glVertexAttribPointer(1, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "u0"))); + c.glVertexAttribDivisor(1, 1); + c.glEnableVertexAttribArray(2); + c.glVertexAttribPointer(2, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "fr"))); + c.glVertexAttribDivisor(2, 1); + c.glEnableVertexAttribArray(3); + c.glVertexAttribPointer(3, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "br"))); + c.glVertexAttribDivisor(3, 1); + c.glDrawArraysInstanced(c.GL_TRIANGLES, 0, 6, @intCast(cells)); + drawOverlayGl(g, overlay_count); + _ = c.SDL_GL_SwapWindow(g.window); +} + +fn uploadAtlasGl(g: *Gui) void { + c.glBindTexture(c.GL_TEXTURE_2D, g.gl_atlas_tex); + c.glPixelStorei(c.GL_UNPACK_ALIGNMENT, 1); + c.glTexSubImage2D(c.GL_TEXTURE_2D, 0, 0, 0, atlas_w, atlas_h, c.GL_RED, c.GL_UNSIGNED_BYTE, g.atlas_stage.ptr); + g.atlas_dirty = false; +} + +fn drawOverlayGl(g: *Gui, vertex_count: u32) void { + if (vertex_count == 0) return; + c.glUseProgram(g.gl_overlay_program); + c.glBindBuffer(c.GL_ARRAY_BUFFER, g.gl_overlay_vbo); + c.glBufferData(c.GL_ARRAY_BUFFER, @intCast(vertex_count * @sizeOf(OverlayVertex)), g.overlay_vertices.ptr, c.GL_STREAM_DRAW); + const stride: c_int = @intCast(@sizeOf(OverlayVertex)); + c.glEnableVertexAttribArray(0); + c.glVertexAttribPointer(0, 2, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(OverlayVertex, "x"))); + c.glVertexAttribDivisor(0, 0); + c.glEnableVertexAttribArray(1); + c.glVertexAttribPointer(1, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(OverlayVertex, "r"))); + c.glVertexAttribDivisor(1, 0); + c.glDisableVertexAttribArray(2); + c.glDisableVertexAttribArray(3); + c.glEnable(c.GL_BLEND); + c.glBlendFuncSeparate(c.GL_SRC_ALPHA, c.GL_ONE_MINUS_SRC_ALPHA, c.GL_ONE, c.GL_ONE_MINUS_SRC_ALPHA); + c.glDrawArrays(c.GL_TRIANGLES, 0, @intCast(vertex_count)); + c.glDisable(c.GL_BLEND); +} + +fn makeGlProgramFrom(vertex_source: []const u8, fragment_source: []const u8) !c_uint { + const vs = try makeGlShader(c.GL_VERTEX_SHADER, vertex_source); + defer c.glDeleteShader(vs); + const fs = try makeGlShader(c.GL_FRAGMENT_SHADER, fragment_source); + defer c.glDeleteShader(fs); + const program = c.glCreateProgram(); + if (program == 0) return error.GpuCreate; + c.glAttachShader(program, vs); + c.glAttachShader(program, fs); + c.glLinkProgram(program); + var ok: c_int = 0; + c.glGetProgramiv(program, c.GL_LINK_STATUS, &ok); + if (ok == 0) { + log.err("WebGL program link failed", .{}); + c.glDeleteProgram(program); + return error.GpuCreate; + } + return program; +} + +fn makeGlShader(kind: c_uint, source: []const u8) !c_uint { + const shader = c.glCreateShader(kind); + if (shader == 0) return error.GpuCreate; + var src_ptr = [_][*c]const u8{@ptrCast(source.ptr)}; + var src_len = [_]c_int{@intCast(source.len)}; + c.glShaderSource(shader, 1, &src_ptr, &src_len); + c.glCompileShader(shader); + var ok: c_int = 0; + c.glGetShaderiv(shader, c.GL_COMPILE_STATUS, &ok); + if (ok == 0) { + log.err("WebGL shader compile failed", .{}); + c.glDeleteShader(shader); + return error.GpuCreate; + } + return shader; +} + +// ===================================================================== +// PARDES_TEST frame capture: download the render target, write latest.ppm +// ===================================================================== + +fn ensureSceneTexture(g: *Gui, width: u32, height: u32) !void { + if (g.scene_tex != null and g.scene_tex_w == width and g.scene_tex_h == height) return; + if (g.scene_tex) |t| c.SDL_ReleaseGPUTexture(g.device, t); + var info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); + info.type = c.SDL_GPU_TEXTURETYPE_2D; + info.format = g.swapchain_format; + info.usage = c.SDL_GPU_TEXTUREUSAGE_COLOR_TARGET | c.SDL_GPU_TEXTUREUSAGE_SAMPLER; + info.width = width; + info.height = height; + info.layer_count_or_depth = 1; + info.num_levels = 1; + info.sample_count = c.SDL_GPU_SAMPLECOUNT_1; + g.scene_tex = c.SDL_CreateGPUTexture(g.device, &info) orelse return error.GpuCreate; + g.scene_tex_w = width; + g.scene_tex_h = height; +} + +fn ensureCaptureTexture(g: *Gui, width: u32, height: u32) !void { + if (g.capture_tex != null and g.capture_tex_w == width and g.capture_tex_h == height) return; + if (g.capture_tex) |t| c.SDL_ReleaseGPUTexture(g.device, t); + var info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); + info.type = c.SDL_GPU_TEXTURETYPE_2D; + info.format = g.swapchain_format; + info.usage = c.SDL_GPU_TEXTUREUSAGE_COLOR_TARGET; + info.width = width; + info.height = height; + info.layer_count_or_depth = 1; + info.num_levels = 1; + info.sample_count = c.SDL_GPU_SAMPLECOUNT_1; + g.capture_tex = c.SDL_CreateGPUTexture(g.device, &info) orelse return error.GpuCreate; + g.capture_tex_w = width; + g.capture_tex_h = height; +} + +fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, target: *c.SDL_GPUTexture, sw: u32, sh: u32) !void { + const size = c.SDL_CalculateGPUTextureFormatSize(g.swapchain_format, sw, sh, 1); + if (size == 0) { + _ = c.SDL_SubmitGPUCommandBuffer(cmd); + return error.UnsupportedCaptureFormat; + } + if (g.capture_xfer == null or g.capture_xfer_size < size) { + if (g.capture_xfer) |x| c.SDL_ReleaseGPUTransferBuffer(g.device, x); + var info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_DOWNLOAD, .size = size, .props = 0 }; + g.capture_xfer = c.SDL_CreateGPUTransferBuffer(g.device, &info) orelse { + _ = c.SDL_SubmitGPUCommandBuffer(cmd); + return error.GpuCreate; + }; + g.capture_xfer_size = size; + } + const xfer = g.capture_xfer.?; + const copy = c.SDL_BeginGPUCopyPass(cmd); + const src = c.SDL_GPUTextureRegion{ .texture = target, .mip_level = 0, .layer = 0, .x = 0, .y = 0, .z = 0, .w = sw, .h = sh, .d = 1 }; + const dst = c.SDL_GPUTextureTransferInfo{ .transfer_buffer = xfer, .offset = 0, .pixels_per_row = sw, .rows_per_layer = sh }; + c.SDL_DownloadFromGPUTexture(copy, &src, &dst); + c.SDL_EndGPUCopyPass(copy); + + const fence = c.SDL_SubmitGPUCommandBufferAndAcquireFence(cmd) orelse return error.GpuSubmit; + defer c.SDL_ReleaseGPUFence(g.device, fence); + var fences = [_]*c.SDL_GPUFence{fence}; + if (!c.SDL_WaitForGPUFences(g.device, true, &fences, 1)) return error.GpuSubmit; + + const mapped = c.SDL_MapGPUTransferBuffer(g.device, xfer, false) orelse return error.GpuMap; + defer c.SDL_UnmapGPUTransferBuffer(g.device, xfer); + const pixels: [*]const u8 = @ptrCast(mapped); + try writeCapturePpm(g, gpa, pixels[0..size], sw, sh); +} + +fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u32, height: u32) !void { + if (g.capture_dir.len == 0) return; + const bpp = c.SDL_GPUTextureFormatTexelBlockSize(g.swapchain_format); + if (bpp != 4) return error.UnsupportedCaptureFormat; + const bgr = switch (g.swapchain_format) { + c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM, c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM_SRGB => false, + c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM, c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM_SRGB => true, + else => return error.UnsupportedCaptureFormat, + }; + + var tmp_buf: [4096]u8 = undefined; + var final_buf: [4096]u8 = undefined; + const tmp_path = std.fmt.bufPrintSentinel(&tmp_buf, "{s}/latest.ppm.tmp", .{g.capture_dir}, 0) catch return error.CapturePathTooLong; + const final_path = std.fmt.bufPrintSentinel(&final_buf, "{s}/latest.ppm", .{g.capture_dir}, 0) catch return error.CapturePathTooLong; + + const fd = libc.open(tmp_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); + if (fd < 0) return error.CaptureWriteFailed; + defer _ = libc.close(fd); + + var header: [64]u8 = undefined; + const hdr = std.fmt.bufPrint(&header, "P6\n{d} {d}\n255\n", .{ width, height }) catch return error.CaptureWriteFailed; + writeFd(fd, hdr); + + const row_rgb = try gpa.alloc(u8, @as(usize, width) * 3); + defer gpa.free(row_rgb); + for (0..height) |row| { + const src = pixels[row * width * 4 ..][0 .. @as(usize, width) * 4]; + for (0..width) |x| { + const si = x * 4; + const di = x * 3; + row_rgb[di + 0] = src[si + if (bgr) @as(usize, 2) else 0]; + row_rgb[di + 1] = src[si + 1]; + row_rgb[di + 2] = src[si + if (bgr) @as(usize, 0) else 2]; + } + writeFd(fd, row_rgb); + } + if (libc.rename(tmp_path, final_path) != 0) return error.CaptureWriteFailed; +} + +// ===================================================================== +// touch debug overlay: per-finger colored circles + trails + a click-action +// flash HUD, alpha-blended over the grid only while the Debug builtin is on. +// ===================================================================== + +fn buildTouchOverlay(g: *Gui, sw: u32, sh: u32, debug_on: bool) u32 { + if (!debug_on) { + g.touch.click_flash_frames = 0; + return 0; + } + if (g.overlay_vertices.len == 0 or sw == 0 or sh == 0) return 0; + const win_w: f32 = @floatFromInt(sw); + const win_h: f32 = @floatFromInt(sh); + var builder = OverlayBuilder{ .vertices = g.overlay_vertices, .win_w = win_w, .win_h = win_h }; + const t = &g.touch; + + if (t.click_flash_frames != 0) { + const flash = @as(f32, @floatFromInt(t.click_flash_frames)) / @as(f32, @floatFromInt(touch_click_flash_max_frames)); + const label = touchClickLabel(t.click_count, t.click_button); + const glyph_scale = 3.0; + const char_step = 4.0 * glyph_scale; + const pad = 5.0; + const stripe_w = 4.0; + const label_w = pad * 2.0 + stripe_w + 3.0 + @as(f32, @floatFromInt(label.len)) * char_step - glyph_scale; + const label_h = pad * 2.0 + 5.0 * glyph_scale; + builder.addRect(0.0, 0.0, win_w, win_h, .{ .r = 1.0, .g = 0.78, .b = 0.18, .a = 0.055 * flash }); + builder.addRect(8.0, 8.0, 8.0 + label_w, 8.0 + label_h, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.54 + 0.22 * flash }); + builder.addRect(8.0 + pad, 8.0 + pad, 8.0 + pad + stripe_w, 8.0 + label_h - pad, .{ .r = 1.0, .g = 0.78, .b = 0.18, .a = 0.96 }); + builder.addMiniText(label[0..], 8.0 + pad + stripe_w + 3.0, 8.0 + pad, glyph_scale, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.96 }); + t.click_flash_frames -= 1; + } + + for (&t.points) |*tp| { + if (!tp.active) continue; + const cx = std.math.clamp(tp.x, 0.0, 1.0) * win_w; + const cy = std.math.clamp(tp.y, 0.0, 1.0) * win_h; + const radius = (12.0 + std.math.clamp(tp.pressure, 0.0, 1.0) * 4.0) * 4.0; + const color = touchColor(tp.id); + + if (tp.trail_len > 1) { + const first = if (tp.trail_len == max_touch_trail_points) tp.trail_next else 0; + for (0..tp.trail_len - 1) |trail_i| { + const sample = tp.trail[(first + trail_i) % max_touch_trail_points]; + const fade = @as(f32, @floatFromInt(trail_i + 1)) / @as(f32, @floatFromInt(tp.trail_len)); + var trail_color = color; + trail_color.a *= 0.42 * fade; + const tx = std.math.clamp(sample.x, 0.0, 1.0) * win_w; + const ty = std.math.clamp(sample.y, 0.0, 1.0) * win_h; + const trail_radius = (12.0 + std.math.clamp(sample.pressure, 0.0, 1.0) * 4.0) * 2.8; + builder.addCircle(tx, ty, trail_radius, trail_color); + } + } + + builder.addCircle(cx, cy, radius + 4.0, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.62 }); + builder.addCircle(cx, cy, radius, color); + builder.addCircle(cx, cy, 2.4, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.92 }); + builder.addIdTag(tp.id, cx, cy, color); + } + return @intCast(builder.len); +} + +fn uploadOverlayGpu(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, vertex_count: u32) void { + const byte_len: u32 = vertex_count * @sizeOf(OverlayVertex); + const ptr = c.SDL_MapGPUTransferBuffer(g.device, g.overlay_vxfer, false) orelse return; + const dst: [*]u8 = @ptrCast(ptr); + const src: [*]const u8 = @ptrCast(g.overlay_vertices.ptr); + @memcpy(dst[0..byte_len], src[0..byte_len]); + c.SDL_UnmapGPUTransferBuffer(g.device, g.overlay_vxfer); + const copy = c.SDL_BeginGPUCopyPass(cmd); + const src_loc = c.SDL_GPUTransferBufferLocation{ .transfer_buffer = g.overlay_vxfer, .offset = 0 }; + const dst_region = c.SDL_GPUBufferRegion{ .buffer = g.overlay_vbuf, .offset = 0, .size = byte_len }; + c.SDL_UploadToGPUBuffer(copy, &src_loc, &dst_region, false); + c.SDL_EndGPUCopyPass(copy); +} + +fn drawOverlayGpu(g: *Gui, rp: ?*c.SDL_GPURenderPass, vertex_count: u32) void { + if (vertex_count == 0) return; + const pass = rp orelse return; + c.SDL_BindGPUGraphicsPipeline(pass, g.overlay_pipeline); + const binding = c.SDL_GPUBufferBinding{ .buffer = g.overlay_vbuf, .offset = 0 }; + c.SDL_BindGPUVertexBuffers(pass, 0, &binding, 1); + c.SDL_DrawGPUPrimitives(pass, vertex_count, 1, 0, 0); +} + +fn touchColor(id: u64) OverlayColor { + const palette = [_]OverlayColor{ + .{ .r = 0.10, .g = 0.72, .b = 1.00, .a = 0.88 }, + .{ .r = 1.00, .g = 0.32, .b = 0.50, .a = 0.88 }, + .{ .r = 0.35, .g = 0.90, .b = 0.38, .a = 0.88 }, + .{ .r = 1.00, .g = 0.78, .b = 0.18, .a = 0.88 }, + .{ .r = 0.78, .g = 0.52, .b = 1.00, .a = 0.88 }, + .{ .r = 0.10, .g = 0.88, .b = 0.75, .a = 0.88 }, + .{ .r = 1.00, .g = 0.48, .b = 0.18, .a = 0.88 }, + .{ .r = 0.78, .g = 0.90, .b = 1.00, .a = 0.88 }, + }; + return palette[@intCast(id % palette.len)]; +} + +const OverlayBuilder = struct { + vertices: []OverlayVertex, + len: usize = 0, + win_w: f32, + win_h: f32, + + fn addVertex(b: *OverlayBuilder, px: f32, py: f32, color: OverlayColor) void { + if (b.len >= b.vertices.len) return; + b.vertices[b.len] = .{ + .x = (px / b.win_w) * 2.0 - 1.0, + .y = 1.0 - (py / b.win_h) * 2.0, + .r = color.r, + .g = color.g, + .b = color.b, + .a = color.a, + }; + b.len += 1; + } + + fn addTri(b: *OverlayBuilder, x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32, color: OverlayColor) void { + if (b.len + 3 > b.vertices.len) return; + b.addVertex(x0, y0, color); + b.addVertex(x1, y1, color); + b.addVertex(x2, y2, color); + } + + fn addRect(b: *OverlayBuilder, x0_in: f32, y0_in: f32, x1_in: f32, y1_in: f32, color: OverlayColor) void { + if (b.len + 6 > b.vertices.len) return; + const x0 = std.math.clamp(@min(x0_in, x1_in), 0.0, b.win_w); + const x1 = std.math.clamp(@max(x0_in, x1_in), 0.0, b.win_w); + const y0 = std.math.clamp(@min(y0_in, y1_in), 0.0, b.win_h); + const y1 = std.math.clamp(@max(y0_in, y1_in), 0.0, b.win_h); + if (x1 <= x0 or y1 <= y0) return; + b.addTri(x0, y0, x1, y0, x1, y1, color); + b.addTri(x0, y0, x1, y1, x0, y1, color); + } + + fn addCircle(b: *OverlayBuilder, cx: f32, cy: f32, radius: f32, color: OverlayColor) void { + var i: usize = 0; + while (i + 1 < circle_points.len) : (i += 1) { + const p0 = circle_points[i]; + const p1 = circle_points[i + 1]; + b.addTri(cx, cy, cx + p0[0] * radius, cy + p0[1] * radius, cx + p1[0] * radius, cy + p1[1] * radius, color); + } + } + + fn addIdTag(b: *OverlayBuilder, id: u64, cx: f32, cy: f32, color: OverlayColor) void { + const label = touchIdLabel(id); + const glyph_scale = 2.0; + const char_step = 4.0 * glyph_scale; + const pad = 4.0; + const stripe_w = 3.0; + const label_w = pad * 2.0 + stripe_w + 3.0 + @as(f32, @floatFromInt(label.len)) * char_step - glyph_scale; + const label_h = pad * 2.0 + 5.0 * glyph_scale; + + var x = cx + 20.0; + if (x + label_w > b.win_w - 2.0) x = cx - label_w - 20.0; + x = std.math.clamp(x, 2.0, @max(2.0, b.win_w - label_w - 2.0)); + var y = cy - label_h - 10.0; + if (y < 2.0) y = cy + 20.0; + y = std.math.clamp(y, 2.0, @max(2.0, b.win_h - label_h - 2.0)); + + b.addRect(x, y, x + label_w, y + label_h, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.72 }); + b.addRect(x + pad, y + pad, x + pad + stripe_w, y + label_h - pad, color); + b.addMiniText(label[0..], x + pad + stripe_w + 3.0, y + pad, glyph_scale, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.95 }); + } + + fn addMiniText(b: *OverlayBuilder, text: []const u8, x: f32, y: f32, scale: f32, color: OverlayColor) void { + var cx = x; + for (text) |ch| { + const rows = miniGlyph(ch); + const masks = [_]u8{ 0b100, 0b010, 0b001 }; + for (rows, 0..) |bits, row| { + for (masks, 0..) |mask, colidx| { + if ((bits & mask) == 0) continue; + const x0 = cx + @as(f32, @floatFromInt(colidx)) * scale; + const y0 = y + @as(f32, @floatFromInt(row)) * scale; + b.addRect(x0, y0, x0 + scale, y0 + scale, color); + } + } + cx += 4.0 * scale; + } + } +}; + +const circle_points = [_][2]f32{ + .{ 1.0000000, 0.0000000 }, .{ 0.9238795, 0.3826834 }, .{ 0.7071068, 0.7071068 }, .{ 0.3826834, 0.9238795 }, + .{ 0.0000000, 1.0000000 }, .{ -0.3826834, 0.9238795 }, .{ -0.7071068, 0.7071068 }, .{ -0.9238795, 0.3826834 }, + .{ -1.0000000, 0.0000000 }, .{ -0.9238795, -0.3826834 }, .{ -0.7071068, -0.7071068 }, .{ -0.3826834, -0.9238795 }, + .{ 0.0000000, -1.0000000 }, .{ 0.3826834, -0.9238795 }, .{ 0.7071068, -0.7071068 }, .{ 0.9238795, -0.3826834 }, + .{ 1.0000000, 0.0000000 }, +}; + +fn touchIdLabel(id: u64) [9]u8 { + var out: [9]u8 = undefined; + out[0] = '#'; + const low: u32 = @truncate(id); + for (0..8) |i| { + const shift: u5 = @intCast((7 - i) * 4); + out[i + 1] = hexDigit((low >> shift) & 0xf); + } + return out; +} + +fn touchClickLabel(count: u32, button: pardes.Mouse.Button) [14]u8 { + var out: [14]u8 = undefined; + @memcpy(out[0..6], if (button == .right) "LOOK #" else "MID #"); + for (0..8) |i| { + const shift: u5 = @intCast((7 - i) * 4); + out[i + 6] = hexDigit((count >> shift) & 0xf); + } + return out; +} + +fn hexDigit(n: u32) u8 { + return if (n < 10) @intCast('0' + n) else @intCast('A' + (n - 10)); +} + +fn miniGlyph(ch: u8) [5]u8 { + return switch (ch) { + '#' => .{ 0b101, 0b111, 0b101, 0b111, 0b101 }, + '0' => .{ 0b111, 0b101, 0b101, 0b101, 0b111 }, + '1' => .{ 0b010, 0b110, 0b010, 0b010, 0b111 }, + '2' => .{ 0b111, 0b001, 0b111, 0b100, 0b111 }, + '3' => .{ 0b111, 0b001, 0b111, 0b001, 0b111 }, + '4' => .{ 0b101, 0b101, 0b111, 0b001, 0b001 }, + '5' => .{ 0b111, 0b100, 0b111, 0b001, 0b111 }, + '6' => .{ 0b111, 0b100, 0b111, 0b101, 0b111 }, + '7' => .{ 0b111, 0b001, 0b010, 0b010, 0b010 }, + '8' => .{ 0b111, 0b101, 0b111, 0b101, 0b111 }, + '9' => .{ 0b111, 0b101, 0b111, 0b001, 0b111 }, + 'A' => .{ 0b111, 0b101, 0b111, 0b101, 0b101 }, + 'B' => .{ 0b110, 0b101, 0b110, 0b101, 0b110 }, + 'C' => .{ 0b111, 0b100, 0b100, 0b100, 0b111 }, + 'D' => .{ 0b110, 0b101, 0b101, 0b101, 0b110 }, + 'E' => .{ 0b111, 0b100, 0b111, 0b100, 0b111 }, + 'F' => .{ 0b111, 0b100, 0b111, 0b100, 0b100 }, + 'I' => .{ 0b111, 0b010, 0b010, 0b010, 0b111 }, + 'K' => .{ 0b101, 0b101, 0b110, 0b101, 0b101 }, + 'L' => .{ 0b100, 0b100, 0b100, 0b100, 0b111 }, + 'M' => .{ 0b101, 0b111, 0b111, 0b101, 0b101 }, + 'O' => .{ 0b111, 0b101, 0b101, 0b101, 0b111 }, + else => .{ 0, 0, 0, 0, 0 }, + }; +} + +// ===================================================================== +// shared plumbing (same shapes as tty.zig) +// ===================================================================== + +/// 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()) |cp| { + if (it.nextCodepoint() == null) return cp; + } + } + 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 envU16(env: *std.process.Environ.Map, name: []const u8) ?u16 { + const raw = env.get(name) orelse return null; + return std.fmt.parseInt(u16, raw, 10) catch null; +} + +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); + } +} diff --git a/src/lsp.zig b/src/lsp.zig deleted file mode 100644 index 536047a3..00000000 --- a/src/lsp.zig +++ /dev/null @@ -1,162 +0,0 @@ -//! The language-intelligence seam. -//! -//! The core never speaks a protocol and never blocks. It emits an `lsp` Effect -//! naming a Kind, a file and a byte offset; a shell runs `query` on a worker -//! and posts the answer back as an `lsp_resp` Event. That is the whole async -//! execution model — the same shape the pty readers already use, because a -//! language query is just another thing that answers later. -//! -//! Every backend renders into ONE format: `+Search` rows. A location is -//! `path:LINE:COL text`, which is what look.zig already resolves and what n/N -//! already steps, so a multi-result answer IS helix's picker and a single -//! result IS a jump — with no picker UI written for it. Free text (hover, -//! a rename's diff) rides the same buffer as plain lines. -//! -//! `query` is the ONLY thing an implementation supplies. Swapping backends is -//! swapping this one function, which is also how the three competing -//! implementations are measured against each other: same core, same harness, -//! same rows, different `query`. -const std = @import("std"); - -/// What the caller wants to know. The helix command each one backs is named -/// alongside, because the keymap is helix's and these are its verbs. -pub const Kind = enum { - /// gd - definition, - /// gD - declaration, - /// gy - type_definition, - /// gi - implementation, - /// gr - references, - /// SPC k - hover, - /// SPC s - document_symbols, - /// SPC S (arg = the query) - workspace_symbols, - /// SPC d, and the list that ]d / [d step - diagnostics, - /// SPC D - workspace_diagnostics, - /// SPC r (arg = the new name) - rename, - /// SPC a - code_action, - /// = - format, - /// SPC h - select_refs, - - // The two introspection kinds. A backend that answers nothing is - // indistinguishable from a backend that is broken, so these exist to tell - // those apart — they are the only Kinds whose answer is ABOUT the backend - // rather than about the code. - /// SPC l i — configuration, capabilities and the recent-query log - status, - /// SPC l w — why the query at the cursor answers what it does. Narrates - /// the REAL resolution path rather than re-deriving it, so it cannot drift - /// away from what `gd` actually did. - explain, - - /// Whether an answer of exactly one row should JUMP rather than open a - /// results buffer. Helix: the five gotos jump on a single location and - /// show a picker on several; a symbol list is always a picker. - pub fn jumpsWhenSingle(k: Kind) bool { - return switch (k) { - .definition, .declaration, .type_definition, .implementation, .references => true, - else => false, - }; - } - - /// The buffer an answer opens. Kept distinct from `+Search` only where the - /// content is not a list of locations — n/N over prose is nonsense. - pub fn bufferName(k: Kind) []const u8 { - return switch (k) { - .hover => "+Hover", - .code_action, .format, .rename => "+Lsp", - // prose about the backend, never a list of locations - .status, .explain => "+Lsp", - else => "+Search", - }; - } -}; - -/// One question. `source` is a snapshot of the buffer taken by the shell -/// before the worker starts — the core keeps editing while this is in flight, -/// so a backend must never reach back into core memory. -pub const Req = struct { - kind: Kind, - /// absolute path of the file the offset is in - path: []const u8, - /// the buffer's bytes, NUL-terminated (std.zig.Ast and zls both want a - /// sentinel, and every backend has to parse this same text) - source: [:0]const u8, - /// cursor position, a byte offset into `source` - offset: u32, - /// kind-specific argument: the new name for a rename, the query for - /// workspace symbols. Empty otherwise. - arg: []const u8 = "", - /// where the project starts — the directory of the pane that asked. A - /// backend that indexes more than one file walks from here. - root: []const u8 = "", -}; - -/// Emit one `path:LINE:COL text` row. Line and column are 1-based, the way -/// every other row in a `+Search` buffer is (and the way look.zig parses one). -pub fn row( - out: *std.Io.Writer, - path: []const u8, - line: usize, - col: usize, - text: []const u8, -) void { - out.print("{s}:{d}:{d} {s}\n", .{ - path, line + 1, col + 1, std.mem.trim(u8, text, " \t\r\n"), - }) catch {}; -} - -/// Byte offset -> (line, column), both 0-based. Every backend needs it to turn -/// an AST token into a row, so it lives here rather than three times over. -pub fn lineCol(source: []const u8, offset: usize) struct { line: usize, col: usize } { - const upto = source[0..@min(offset, source.len)]; - const line = std.mem.count(u8, upto, "\n"); - const bol = if (std.mem.lastIndexOfScalar(u8, upto, '\n')) |i| i + 1 else 0; - return .{ .line = line, .col = upto.len - bol }; -} - -/// Answer `req`, writing rows to `out`. Runs on a worker thread with no -/// access to the core: everything it may read is in `req`. -/// -/// `out` is a plain `std.Io.Writer` — the shell owns the buffer behind it (an -/// `Io.Writer.Allocating`), so a backend never allocates the result, never -/// frees it, and cannot get the allocator wrong. Write failures are the -/// writer's problem; a backend may ignore them. -/// -/// `arena` is freed wholesale when the query returns; `gpa` is for a backend's -/// own longer-lived scratch. Errors are not reported — a backend that cannot -/// answer writes nothing, and the core treats "no rows" as "no result", which -/// is also what a language server still starting up looks like. -pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: Req, out: *std.Io.Writer) void { - backend.query(gpa, arena, req, out); -} - -/// ZLS, linked in as a module and called directly — no subprocess, no -/// JSON-RPC. See `lsp_zls.zig`. The web shell has no threads, never emits the -/// effect, and cannot build ZLS anyway, so there it is the empty backend the -/// base tree shipped with. -const backend = if (@import("pardes_config").zls_backend) @import("lsp_zls.zig") else struct { - pub fn query(_: std.mem.Allocator, _: std.mem.Allocator, _: Req, _: *std.Io.Writer) void {} - pub const supports: std.EnumSet(Kind) = .initEmpty(); -}; - -/// What this backend can actually answer, for the evaluation harness and for -/// the core (a Kind that is not supported never leaves the keymap). An -/// implementation narrows this to what it really does — claiming a feature it -/// does not have shows up immediately in the harness's matrix. -pub const supports: std.EnumSet(Kind) = backend.supports; - -/// Name shown by the harness and in `SPC ?`. Each implementation renames it. -pub const backend_name = "zls-inproc"; diff --git a/src/lsp/lsp.zig b/src/lsp/lsp.zig new file mode 100644 index 00000000..536047a3 --- /dev/null +++ b/src/lsp/lsp.zig @@ -0,0 +1,162 @@ +//! The language-intelligence seam. +//! +//! The core never speaks a protocol and never blocks. It emits an `lsp` Effect +//! naming a Kind, a file and a byte offset; a shell runs `query` on a worker +//! and posts the answer back as an `lsp_resp` Event. That is the whole async +//! execution model — the same shape the pty readers already use, because a +//! language query is just another thing that answers later. +//! +//! Every backend renders into ONE format: `+Search` rows. A location is +//! `path:LINE:COL text`, which is what look.zig already resolves and what n/N +//! already steps, so a multi-result answer IS helix's picker and a single +//! result IS a jump — with no picker UI written for it. Free text (hover, +//! a rename's diff) rides the same buffer as plain lines. +//! +//! `query` is the ONLY thing an implementation supplies. Swapping backends is +//! swapping this one function, which is also how the three competing +//! implementations are measured against each other: same core, same harness, +//! same rows, different `query`. +const std = @import("std"); + +/// What the caller wants to know. The helix command each one backs is named +/// alongside, because the keymap is helix's and these are its verbs. +pub const Kind = enum { + /// gd + definition, + /// gD + declaration, + /// gy + type_definition, + /// gi + implementation, + /// gr + references, + /// SPC k + hover, + /// SPC s + document_symbols, + /// SPC S (arg = the query) + workspace_symbols, + /// SPC d, and the list that ]d / [d step + diagnostics, + /// SPC D + workspace_diagnostics, + /// SPC r (arg = the new name) + rename, + /// SPC a + code_action, + /// = + format, + /// SPC h + select_refs, + + // The two introspection kinds. A backend that answers nothing is + // indistinguishable from a backend that is broken, so these exist to tell + // those apart — they are the only Kinds whose answer is ABOUT the backend + // rather than about the code. + /// SPC l i — configuration, capabilities and the recent-query log + status, + /// SPC l w — why the query at the cursor answers what it does. Narrates + /// the REAL resolution path rather than re-deriving it, so it cannot drift + /// away from what `gd` actually did. + explain, + + /// Whether an answer of exactly one row should JUMP rather than open a + /// results buffer. Helix: the five gotos jump on a single location and + /// show a picker on several; a symbol list is always a picker. + pub fn jumpsWhenSingle(k: Kind) bool { + return switch (k) { + .definition, .declaration, .type_definition, .implementation, .references => true, + else => false, + }; + } + + /// The buffer an answer opens. Kept distinct from `+Search` only where the + /// content is not a list of locations — n/N over prose is nonsense. + pub fn bufferName(k: Kind) []const u8 { + return switch (k) { + .hover => "+Hover", + .code_action, .format, .rename => "+Lsp", + // prose about the backend, never a list of locations + .status, .explain => "+Lsp", + else => "+Search", + }; + } +}; + +/// One question. `source` is a snapshot of the buffer taken by the shell +/// before the worker starts — the core keeps editing while this is in flight, +/// so a backend must never reach back into core memory. +pub const Req = struct { + kind: Kind, + /// absolute path of the file the offset is in + path: []const u8, + /// the buffer's bytes, NUL-terminated (std.zig.Ast and zls both want a + /// sentinel, and every backend has to parse this same text) + source: [:0]const u8, + /// cursor position, a byte offset into `source` + offset: u32, + /// kind-specific argument: the new name for a rename, the query for + /// workspace symbols. Empty otherwise. + arg: []const u8 = "", + /// where the project starts — the directory of the pane that asked. A + /// backend that indexes more than one file walks from here. + root: []const u8 = "", +}; + +/// Emit one `path:LINE:COL text` row. Line and column are 1-based, the way +/// every other row in a `+Search` buffer is (and the way look.zig parses one). +pub fn row( + out: *std.Io.Writer, + path: []const u8, + line: usize, + col: usize, + text: []const u8, +) void { + out.print("{s}:{d}:{d} {s}\n", .{ + path, line + 1, col + 1, std.mem.trim(u8, text, " \t\r\n"), + }) catch {}; +} + +/// Byte offset -> (line, column), both 0-based. Every backend needs it to turn +/// an AST token into a row, so it lives here rather than three times over. +pub fn lineCol(source: []const u8, offset: usize) struct { line: usize, col: usize } { + const upto = source[0..@min(offset, source.len)]; + const line = std.mem.count(u8, upto, "\n"); + const bol = if (std.mem.lastIndexOfScalar(u8, upto, '\n')) |i| i + 1 else 0; + return .{ .line = line, .col = upto.len - bol }; +} + +/// Answer `req`, writing rows to `out`. Runs on a worker thread with no +/// access to the core: everything it may read is in `req`. +/// +/// `out` is a plain `std.Io.Writer` — the shell owns the buffer behind it (an +/// `Io.Writer.Allocating`), so a backend never allocates the result, never +/// frees it, and cannot get the allocator wrong. Write failures are the +/// writer's problem; a backend may ignore them. +/// +/// `arena` is freed wholesale when the query returns; `gpa` is for a backend's +/// own longer-lived scratch. Errors are not reported — a backend that cannot +/// answer writes nothing, and the core treats "no rows" as "no result", which +/// is also what a language server still starting up looks like. +pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: Req, out: *std.Io.Writer) void { + backend.query(gpa, arena, req, out); +} + +/// ZLS, linked in as a module and called directly — no subprocess, no +/// JSON-RPC. See `lsp_zls.zig`. The web shell has no threads, never emits the +/// effect, and cannot build ZLS anyway, so there it is the empty backend the +/// base tree shipped with. +const backend = if (@import("pardes_config").zls_backend) @import("lsp_zls.zig") else struct { + pub fn query(_: std.mem.Allocator, _: std.mem.Allocator, _: Req, _: *std.Io.Writer) void {} + pub const supports: std.EnumSet(Kind) = .initEmpty(); +}; + +/// What this backend can actually answer, for the evaluation harness and for +/// the core (a Kind that is not supported never leaves the keymap). An +/// implementation narrows this to what it really does — claiming a feature it +/// does not have shows up immediately in the harness's matrix. +pub const supports: std.EnumSet(Kind) = backend.supports; + +/// Name shown by the harness and in `SPC ?`. Each implementation renames it. +pub const backend_name = "zls-inproc"; diff --git a/src/lsp/lsp_zls.zig b/src/lsp/lsp_zls.zig new file mode 100644 index 00000000..6f5bbcdd --- /dev/null +++ b/src/lsp/lsp_zls.zig @@ -0,0 +1,1073 @@ +//! ZLS as a LIBRARY, not a language server. +//! +//! There is no subprocess, no JSON-RPC, no `initialize` handshake and no +//! `Server`. `gd` runs ZLS's own analyser on this thread and comes back with a +//! token; the whole "protocol" is a struct field. ZLS's build.zig already +//! publishes its guts as an importable module (`b.modules.put("zls", ...)`), +//! and everything under `features/` that is not welded to `*Server` is a plain +//! function over (analyser, arena, handle, offset) — so the parts we want are +//! callable and the parts we don't want cost nothing. +//! +//! What that buys and what it costs: +//! +//! - Cold IS warm. There is no server to start, no workspace to index, no +//! handshake to wait for. The first `gd` after launch pays for parsing this +//! file and whatever it imports, and nothing else. +//! - It also means NOTHING IS CACHED between queries. Every call builds a +//! DocumentStore, resolves imports, and throws it all away, because `query` +//! is handed an arena that dies on return and a `req` that is a snapshot of +//! a buffer the user is still typing into. A cache would have to be a global +//! with its own mutex and its own invalidation story; that is a real design, +//! not a line of code, and it is the obvious next step rather than something +//! smuggled in here. +//! - `zig_exe_path` is null on purpose: shelling out to the compiler is the +//! thing this backend exists to avoid. `zig_lib_dir` is the build's own +//! (`b.graph.zig_lib_directory`), which is what makes `std` resolvable — +//! `gd` on `std.mem.count` lands in the real `mem.zig`. +//! +//! The price is DEPENDENCIES, and it is worth being exact about how far it +//! goes. ZLS resolves a non-`std`, non-relative import only through a +//! `BuildFile` whose config came from running `zig build --build-runner`, so +//! with no compiler it resolves none of them. We paper over ONE case of +//! that: `gd` on the import string itself (`moduleRoot`, fed by build.zig). +//! That is a fallback in OUR handler and ZLS cannot see it, so the analyser +//! still cannot type `vaxis`, and `gd` on `vaxis.init` still finds nothing. +//! Making member access work means giving ZLS a real build config — either +//! by letting it run the build runner (a subprocess, and with no cross-query +//! cache that is once PER KEYPRESS) or by synthesizing a BuildConfig and +//! reaching into `BuildFile.impl`. Both are real work; neither is smuggled +//! in here. `@import("builtin")` and `@cImport` are unresolved for the same +//! reason. +const std = @import("std"); +const zls = @import("zls"); +const lsp = @import("lsp.zig"); +const cfg = @import("pardes_config"); + +const Analyser = zls.Analyser; +const DocumentStore = zls.DocumentStore; +const InternPool = zls.analyser.InternPool; +const offsets = zls.offsets; +const Uri = zls.Uri; +const types = zls.lsp.types; +const Ast = std.zig.Ast; + +/// BYTE offsets. ZLS defaults to utf-16 because that is what the wire protocol +/// says; we are not on a wire, and `+Search` rows are byte columns. +const enc: offsets.Encoding = .@"utf-8"; + +/// Guards on the workspace walks (workspace symbols / workspace diagnostics). +/// A language query runs on every keypress of `SPC S`; it may not turn into an +/// unbounded tree walk because someone opened a file in `/`. +const max_files = 512; +const max_rows = 2000; + +pub const supports: std.EnumSet(lsp.Kind) = .initMany(&.{ + .definition, + .declaration, + .type_definition, + .implementation, + .references, + .select_refs, + .hover, + .document_symbols, + .workspace_symbols, + .diagnostics, + .workspace_diagnostics, + .rename, + .format, + .code_action, + .status, + .explain, +}); + +// ------------------------------------------------------------ introspection +// +// Everything below exists because of the contract two lines down: this backend +// never fails loudly. That is right for an editor — a thrown analyser must not +// take the process with it — but it means a broken backend and a correct one +// that found nothing look identical from the outside. So every query leaves a +// record, and `SPC l i` reads them back. + +/// One finished query. Fixed-size and inline: this array is process-global and +/// lives forever, so it must not own heap. +const LogEntry = struct { + used: bool = false, + kind: lsp.Kind = .definition, + us: u64 = 0, + rows: usize = 0, + offset: u32 = 0, + /// the error `run` returned, or empty when it returned normally. THIS is + /// the thing you cannot see any other way. + err: [40]u8 = @splat(0), + err_len: u8 = 0, + /// basename only — a full path would need most of this struct + file: [64]u8 = @splat(0), + file_len: u8 = 0, + + fn errName(e: *const LogEntry) []const u8 { + return e.err[0..e.err_len]; + } + fn fileName(e: *const LogEntry) []const u8 { + return e.file[0..e.file_len]; + } +}; + +const log_cap = 24; +var log_buf: [log_cap]LogEntry = @splat(.{}); +var log_next: usize = 0; +var log_total: u64 = 0; +/// 0.16 has no std.Thread.Mutex; the critical sections here are a memcpy, so +/// spinning on the lock-free one is enough — the same call gui.zig's pty queue +/// makes. Only WORKER threads touch this; the core never does. +var log_mu: std.atomic.Mutex = .unlocked; + +fn logLock() void { + while (!log_mu.tryLock()) std.atomic.spinLoopHint(); +} + +fn record(req: lsp.Req, us: u64, rows: usize, err: []const u8) void { + logLock(); + defer log_mu.unlock(); + const e = &log_buf[log_next]; + e.* = .{ .used = true, .kind = req.kind, .us = us, .rows = rows, .offset = req.offset }; + const base = std.fs.path.basename(req.path); + e.file_len = @intCast(@min(base.len, e.file.len)); + @memcpy(e.file[0..e.file_len], base[0..e.file_len]); + e.err_len = @intCast(@min(err.len, e.err.len)); + @memcpy(e.err[0..e.err_len], err[0..e.err_len]); + log_next = (log_next + 1) % log_cap; + log_total += 1; +} + +/// A duration cannot go in a snapshot golden — it differs every run. The +/// snapshot harness sets PARDES_LSP_NOTIME so the two introspection views +/// print `-` instead, the same way it pins PARDES_DUMP for the dump tests. +/// Nothing else reads it, and unset (i.e. every real run) shows real numbers. +fn hideTime() bool { + const v = std.c.getenv("PARDES_LSP_NOTIME") orelse return false; + return std.mem.span(v).len != 0; +} + +fn nowUs() u64 { + var ts: std.c.timespec = undefined; + _ = std.c.clock_gettime(.MONOTONIC, &ts); + return @as(u64, @intCast(ts.sec)) *| 1_000_000 +| @as(u64, @intCast(ts.nsec)) / 1000; +} + +/// The narration `SPC l w` prints. It is threaded through the REAL resolution +/// path rather than re-derived beside it: a debug view that reimplements the +/// logic is a debug view that can disagree with it, and the whole point is to +/// find out what actually happened. +const Trace = struct { + on: bool = false, + arena: std.mem.Allocator, + buf: std.ArrayList(u8) = .empty, + + fn note(t: *Trace, comptime fmt: []const u8, args: anytype) void { + if (!t.on) return; + t.buf.print(t.arena, fmt ++ "\n", args) catch {}; + } +}; + +/// The seam's contract: never fail, never panic, no rows is a legal answer. +/// Every error path in here — OOM, a cancelled io, a file that vanished — +/// collapses to "appended nothing", which the core already treats as "no +/// result". There is deliberately no error rendering. +pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) void { + // status reads the log; recording it would push a real query out of a + // 24-entry ring every time you looked at it. + if (req.kind == .status) return status(arena, req, out) catch {}; + + // Rows land in a scratch buffer first, for two reasons a plain Writer + // cannot serve: the log wants an exact row count, and `explain` throws the + // rows away and prints the narration in their place — neither a count nor + // a rewind exists on a Writer you do not own. + var scratch: std.Io.Writer.Allocating = .init(gpa); + defer scratch.deinit(); + + var tr: Trace = .{ .arena = arena, .on = req.kind == .explain }; + const t0 = nowUs(); + var err_name: []const u8 = ""; + run(gpa, arena, req, &scratch.writer, &tr) catch |e| { + err_name = @errorName(e); + tr.note("ERROR: {s} — the query threw; the editor shows this as 'no result'", .{err_name}); + }; + const us = nowUs() -| t0; + const rows = std.mem.count(u8, scratch.written(), "\n"); + record(req, us, rows, err_name); + + if (req.kind != .explain) { + out.writeAll(scratch.written()) catch {}; + return; + } + // the question was never "where is it", it was "what did you do" + out.print("lsp explain — the definition query at byte {d} of {s}\n\n", .{ + req.offset, if (req.path.len == 0) "(no file)" else std.fs.path.basename(req.path), + }) catch {}; + out.writeAll(tr.buf.items) catch {}; + if (hideTime()) + out.print("\n{d} row(s)\n", .{rows}) catch {} + else + out.print("\n{d} row(s) in {d}us\n", .{ rows, us }) catch {}; +} + +fn run(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer, tr: *Trace) !void { + // The blocking, single-threaded std.Io — the same one look.zig walks + // directories with. ZLS wants an `Io` for every file read; a worker thread + // that is allowed to block wants the one that just does the syscall. + const io = std.Io.Threaded.global_single_threaded.io(); + + // The two workspace kinds are about the PROJECT, not about this buffer, so + // they answer from any pane and never need the analyser, the store or the + // intern pool — a walk and a parse each. + // `explain` narrates the definition query; everywhere below dispatches on + // the effective kind, so the trace follows the code `gd` really runs. + const kind: lsp.Kind = if (req.kind == .explain) .definition else req.kind; + + switch (kind) { + .workspace_symbols => return workspaceSymbols(gpa, arena, io, req, out), + .workspace_diagnostics => return workspaceDiagnostics(gpa, arena, io, req, out), + // Everything else is about THIS file, and this backend speaks Zig. The + // core does not gate the keymap by file type (a pane is a pane), so + // the gate is here: `gd` in a README must find nothing rather than + // parse prose as Zig and confidently resolve a word out of it. + else => if (!std.mem.endsWith(u8, req.path, ".zig")) { + tr.note("STOP: not a .zig file ({s}) — this backend only reads Zig", .{ + if (req.path.len == 0) "no path: this pane has no file behind it" else req.path, + }); + return; + }, + } + // Pure tree, no analyser. + if (kind == .format) return formatQuery(arena, req, out); + + var env: std.process.Environ.Map = .init(arena); + var lib_dir: ?std.Build.Cache.Directory = null; + if (zigLibPath()) |p| { + if (std.Io.Dir.cwd().openDir(io, p, .{})) |h| { + lib_dir = .{ .handle = h, .path = p }; + tr.note("zig lib dir: {s} (open) — `std` imports resolve", .{p}); + } else |e| tr.note("zig lib dir: {s} FAILED to open ({s}) — nothing in `std` will resolve", .{ p, @errorName(e) }); + } else tr.note("zig lib dir: NOT SET — nothing in `std` will resolve", .{}); + defer if (lib_dir) |d| d.handle.close(io); + + var ip: InternPool = try .init(io, gpa); + defer ip.deinit(gpa); + + var diags: zls.DiagnosticsCollection = .{ .io = io, .allocator = gpa }; + defer diags.deinit(); + + var store: DocumentStore = .{ + .io = io, + .allocator = gpa, + .config = .{ + .environ_map = &env, + // no compiler subprocess: that is the entire point of this backend + .zig_exe_path = null, + .zig_lib_dir = lib_dir, + .build_runner_path = null, + .builtin_path = null, + .global_cache_dir = null, + .wasi_preopens = {}, + }, + .diagnostics_collection = &diags, + }; + defer store.deinit(); + + // The buffer the user is looking at, not the file on disk: `source` is the + // shell's snapshot, so unsaved edits are what gets analysed. + const uri: Uri = try .fromPath(arena, req.path); + try store.openLspSyncedDocument(uri, req.source); + const handle = store.getHandle(uri) orelse { + tr.note("STOP: the document store would not take this buffer", .{}); + return; + }; + if (handle.tree.mode == .zon) { + tr.note("STOP: parsed as .zon, not Zig code — there is nothing to resolve", .{}); + return; + } + tr.note("parsed {d} bytes, {d} syntax error(s){s}", .{ + req.source.len, + handle.tree.errors.len, + if (handle.tree.errors.len == 0) "" else " — a broken tree resolves badly or not at all", + }); + + var analyser: Analyser = .init(gpa, arena, &store, &ip, handle); + defer analyser.deinit(); + + const off: usize = @min(req.offset, req.source.len); + + switch (kind) { + .definition, .implementation => try goto(arena, &analyser, handle, off, .definition, out, tr), + .declaration => try goto(arena, &analyser, handle, off, .declaration, out, tr), + .type_definition => try goto(arena, &analyser, handle, off, .type_definition, out, tr), + .hover => try hover(arena, &analyser, handle, off, out), + .document_symbols => try documentSymbols(gpa, arena, req.path, &handle.tree, out), + .references, .select_refs => try references(arena, &analyser, handle, off, null, out), + .rename => try references(arena, &analyser, handle, off, req.arg, out), + .diagnostics => try diagnostics(gpa, arena, req.path, &handle.tree, out), + .code_action => try codeActions(gpa, arena, &analyser, handle, off, out), + else => {}, + } +} + +/// `SPC l i`. What the backend IS: which ZLS, which stdlib, what it will and +/// will not answer, and the last two dozen queries with their timings and — +/// the point of the whole exercise — the errors that `query` swallowed. +fn status(arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !void { + const io = std.Io.Threaded.global_single_threaded.io(); + try out.print("backend: {s}\nzls: {s} (compiled in — no server process, no JSON-RPC)\n", .{ + lsp.backend_name, cfg.zls_version, + }); + + // The single most common cause of "gd does nothing in std": say whether it + // is actually openable, not just what it is set to. + if (zigLibPath()) |p| { + if (std.Io.Dir.cwd().openDir(io, p, .{})) |h| { + var d = h; + d.close(io); + try out.print("zig lib dir: {s} [OK]\n", .{p}); + } else |e| try out.print("zig lib dir: {s} [CANNOT OPEN: {s}] — nothing in `std` will resolve\n", .{ p, @errorName(e) }); + } else try out.print("zig lib dir: NOT SET — nothing in `std` will resolve\n", .{}); + try out.print("offsets: {s} walk caps: {d} files, {d} rows\n", .{ @tagName(enc), max_files, max_rows }); + // The dependency module map, which is the answer to "why does gd work on + // std and not on @import(\"vaxis\")" — if a name is not on this list, the + // build did not import it and nothing can resolve it. + try out.print("\ndependency imports gd can follow ({d}):\n", .{cfg.module_names.len}); + for (cfg.module_names, cfg.module_roots) |n, r| { + try out.print(" {s:<14} {s}\n", .{ n, r }); + } + try out.print("asked from: {s}\n", .{if (req.path.len == 0) "a pane with no file" else req.path}); + + try out.print("\nanswers:", .{}); + var it = supports.iterator(); + var n: usize = 0; + while (it.next()) |k| : (n += 1) { + if (k == .status or k == .explain) continue; // these two are the debugger, not a feature + try out.print("{s}{s}", .{ if (n == 0) " " else ", ", @tagName(k) }); + } + try out.print("\nrefuses: ", .{}); + n = 0; + for (std.enums.values(lsp.Kind)) |k| { + if (supports.contains(k)) continue; + try out.print("{s}{s}", .{ if (n == 0) "" else ", ", @tagName(k) }); + n += 1; + } + if (n == 0) try out.print("(nothing)", .{}); + try out.print("\n", .{}); + + // The log. Oldest first, so it reads like a transcript. + logLock(); + defer log_mu.unlock(); + try out.print("\nlast queries ({d} total, keeping {d}):\n", .{ log_total, log_cap }); + var shown: usize = 0; + for (0..log_cap) |i| { + const e = &log_buf[(log_next + i) % log_cap]; + if (!e.used) continue; + shown += 1; + if (hideTime()) { + try out.print(" {s:<22} {s:<20} @{d:<7} {d:>4} row(s){s}{s}\n", .{ + @tagName(e.kind), e.fileName(), e.offset, e.rows, + if (e.err_len == 0) "" else " ERROR: ", e.errName(), + }); + } else { + try out.print(" {s:<22} {s:<20} @{d:<7} {d:>7}us {d:>4} row(s){s}{s}\n", .{ + @tagName(e.kind), e.fileName(), e.offset, e.us, e.rows, + if (e.err_len == 0) "" else " ERROR: ", e.errName(), + }); + } + } + if (shown == 0) try out.print(" (none yet — press gd somewhere, then ask again)\n", .{}); + _ = arena; +} + +/// The root source file of a module this build imports, or null. +/// +/// ZLS answers `@import("std")` from `zig_lib_dir` and a relative `.zig` path +/// from the filesystem, but ANY OTHER name — every dependency in +/// build.zig.zon — it can only resolve by running `zig build --build-runner` +/// to discover the module graph. This backend sets `zig_exe_path = null` on +/// purpose, so that branch always returned nothing: `gd` worked perfectly into +/// `std` and silently did nothing on `@import("vaxis")`. +/// +/// We never needed the compiler for this. build.zig IS the module graph, so it +/// folds its own `root_mod.import_table` into these two parallel arrays at +/// configure time. Correct by construction: a dependency added or renamed in +/// build.zig cannot forget to appear here. +/// +/// LIMITS, in the order you will hit them: +/// 1. This resolves the IMPORT STRING only. It is consulted from our own +/// `goto`, not from inside ZLS, so the analyser still cannot type the +/// `vaxis` const — `gd` on `vaxis.init` finds nothing. Fixing that means +/// giving ZLS a BuildConfig; see the note at the top of this file. +/// 2. These are THIS build's imports. A file inside a dependency importing +/// that dependency's own internal module name is a miss — that would mean +/// running ITS build.zig. +/// 3. A module whose root source is a GENERATED file is absent from the +/// table: it has no path until make() runs. +fn moduleRoot(name: []const u8) ?[]const u8 { + comptime std.debug.assert(cfg.module_names.len == cfg.module_roots.len); + for (cfg.module_names, cfg.module_roots) |n, r| { + if (std.mem.eql(u8, n, name)) return r; + } + return null; +} + +/// Why a field access came back empty. Worth its own function because ONE of +/// the reasons is a known, explainable hole rather than "no idea": if the left +/// side starts with a dependency module name, the analyser could not type it +/// because ZLS has no build config, and no amount of staring at the code will +/// reveal that. +fn explainFieldMiss(tr: *Trace, lhs: []const u8) void { + var head: usize = 0; + while (head < lhs.len and (std.ascii.isAlphanumeric(lhs[head]) or lhs[head] == '_')) head += 1; + if (head != 0 and moduleRoot(lhs[0..head]) != null) { + tr.note(" `{s}` IS a build.zig dependency, and this is the known hole: the module", .{lhs[0..head]}); + tr.note(" map is consulted by our @import handler, not by ZLS, so the analyser cannot", .{}); + tr.note(" type it. `gd` on the import string works; `gd` on a member does not.", .{}); + return; + } + tr.note(" (a value whose type needs comptime, an unresolved @import, or genuinely absent)", .{}); +} + +/// The zig lib directory, which is the one thing this backend cannot work out +/// for itself: it is baked in at build time from `b.graph.zig_lib_directory`, +/// i.e. the exact stdlib pardes was compiled against, and ZIG_LIB_DIR wins if +/// the user moved it. Without this, `gd` on anything in `std` finds nothing. +fn zigLibPath() ?[]const u8 { + if (std.c.getenv("ZIG_LIB_DIR")) |p| { + const s = std.mem.span(p); + if (s.len != 0) return s; + } + if (cfg.zig_lib_dir.len != 0) return cfg.zig_lib_dir; + return null; +} + +// ---------------------------------------------------------------- rows + +/// Byte offset -> line, without rescanning the file per row. Built once per +/// query over the file we emit many rows from (symbols, references, +/// diagnostics); a goto that emits two rows just scans. +const Lines = struct { + starts: []const u32, + src: []const u8, + + fn build(arena: std.mem.Allocator, src: []const u8) !Lines { + var starts: std.ArrayList(u32) = .empty; + try starts.append(arena, 0); + for (src, 0..) |c, i| if (c == '\n') try starts.append(arena, @intCast(i + 1)); + return .{ .starts = starts.items, .src = src }; + } + + fn line(l: Lines, n: usize) []const u8 { + if (n >= l.starts.len) return ""; + const a = l.starts[n]; + const b = if (n + 1 < l.starts.len) l.starts[n + 1] - 1 else l.src.len; + return l.src[a..@min(b, l.src.len)]; + } +}; + +/// The source line at a 0-based line number, scanned. For the one-or-two-row +/// answers (a goto lands in a file we opened only to read one line out of). +fn lineAt(src: []const u8, want: usize) []const u8 { + var it = std.mem.splitScalar(u8, src, '\n'); + var i: usize = 0; + while (it.next()) |l| : (i += 1) if (i == want) return l; + return ""; +} + +/// Emit one row for a token in whatever file it lives in — which for a goto +/// into `std` is not the file the cursor was in, so the path comes off the +/// handle's uri rather than off `req`. +fn rowForToken(arena: std.mem.Allocator, th: Analyser.TokenWithHandle, out: *std.Io.Writer) !void { + const tree = &th.handle.tree; + if (th.token >= tree.tokens.len) return; + const r = offsets.tokenToRange(tree, th.token, enc); + const path = th.handle.uri.toFsPath(arena) catch return; + lsp.row(out, path, r.start.line, r.start.character, lineAt(tree.source, r.start.line)); +} + +// ---------------------------------------------------------------- goto + +const GotoKind = enum { declaration, definition, type_definition }; + +/// `goto.zig:gotoHandler` minus the Server, minus the LSP types, minus the +/// linkSupport branch: dispatch on the position context, resolve to a decl, +/// resolve the decl to a token, print the token's line. The five helix gotos +/// collapse onto three ZLS kinds (`gi` behaves as `gd`, which is what ZLS's +/// own implementation handler does too). +fn goto( + arena: std.mem.Allocator, + analyser: *Analyser, + handle: *DocumentStore.Handle, + off: usize, + kind: GotoKind, + out: *std.Io.Writer, + tr: *Trace, +) !void { + const tree = &handle.tree; + const ctx = try Analyser.getPositionContext(arena, tree, off, true); + tr.note("position context: .{s}", .{@tagName(ctx)}); + + var decls: std.ArrayList(Analyser.DeclWithHandle) = .empty; + switch (ctx) { + .var_access, .test_doctest_name => { + const name_loc = offsets.identifierLocFromIndex(tree, off) orelse { + tr.note("STOP: no identifier under the cursor", .{}); + return; + }; + const name = offsets.locToSlice(tree.source, name_loc); + tr.note("identifier: `{s}` — a plain name, so: innermost scope outward", .{name}); + if (std.mem.eql(u8, name, "_")) { + tr.note("STOP: `_` is the discard, it declares nothing", .{}); + return; + } + if (std.zig.isPrimitive(name)) { + tr.note("STOP: `{s}` is a builtin primitive — it has no declaration in any file", .{name}); + return; + } + const d = try analyser.lookupSymbolGlobal(handle, name, off) orelse { + tr.note("STOP: lookupSymbolGlobal found no `{s}` in scope here", .{name}); + return; + }; + try decls.append(arena, d); + }, + .field_access => |loc| { + const name_loc = offsets.identifierLocFromIndex(tree, off) orelse { + tr.note("STOP: no identifier under the cursor", .{}); + return; + }; + const name = offsets.locToSlice(tree.source, name_loc); + const held = offsets.locMerge(loc, name_loc); + tr.note("field access: `{s}` on `{s}` — needs the TYPE of the left side", .{ + name, offsets.locToSlice(tree.source, loc), + }); + // NOTE both misses: a null result AND an empty one. ZLS returns an + // empty slice when it typed the left side but found no such member, + // and null when it could not type it at all — from the outside both + // are "gd did nothing", so both have to be explained. + const found = try analyser.getSymbolFieldAccesses(arena, handle, off, held, name) orelse { + tr.note("STOP: could not resolve the left side to a type at all", .{}); + explainFieldMiss(tr, offsets.locToSlice(tree.source, loc)); + return; + }; + if (found.len == 0) { + tr.note("STOP: the left side resolved, but nothing named `{s}` was found on it", .{name}); + explainFieldMiss(tr, offsets.locToSlice(tree.source, loc)); + return; + } + try decls.appendSlice(arena, found); + }, + .label_access, .label_decl => { + const name_loc = offsets.identifierLocFromIndex(tree, off) orelse return; + const name = offsets.locToSlice(tree.source, name_loc); + const d = try Analyser.lookupLabel(handle, name, off) orelse return; + try decls.append(arena, d); + }, + .enum_literal => { + const name_loc = offsets.identifierLocFromIndex(tree, off) orelse return; + const name = offsets.locToSlice(tree.source, name_loc); + const d = try analyser.getSymbolEnumLiteral(handle, off, name) orelse return; + try decls.append(arena, d); + }, + // `gd` on `@import("foo.zig")` opens foo.zig. Only relative imports + // resolve — a package name needs the build graph we do not run. + .import_string_literal, .embedfile_string_literal => { + const loc = ctx.stringLiteralContentLoc(tree.source); + if (loc.start == loc.end) return; + const str = offsets.locToSlice(tree.source, loc); + tr.note("import string: \"{s}\"", .{str}); + const res = try analyser.store.uriFromImportStr(arena, handle, str); + switch (res) { + .none => { + // ZLS gave up, which for a DEPENDENCY name is not a real + // dead end — it just means it wanted the module graph and + // we would not start a compiler to get one. Our build.zig + // knows the graph and baked it in; consult that. + if (moduleRoot(str)) |path| { + tr.note("`{s}` is a build.zig dependency; resolved from the compiled-in module map", .{str}); + lsp.row(out, path, 0, 0, str); + return; + } + tr.note("STOP: `{s}` does not resolve to a file. Relative paths, `std` and this", .{str}); + tr.note(" build's own dependencies work; `builtin`, `root` and a dependency's", .{}); + tr.note(" OWN internal module names need the build graph we do not run.", .{}); + return; + }, + .one => |u| lsp.row(out, u.toFsPath(arena) catch return, 0, 0, str), + .many => |us| for (us) |u| lsp.row(out, u.toFsPath(arena) catch continue, 0, 0, str), + } + return; + }, + else => { + tr.note("STOP: nothing to resolve in a .{s} context — `gd` only answers on", .{@tagName(ctx)}); + tr.note(" an identifier, a field access, a label, an enum literal or an @import string.", .{}); + return; + }, + } + + tr.note("resolved to {d} declaration(s); asking each for its {s} token", .{ decls.items.len, @tagName(kind) }); + // One decl that fails to resolve must not swallow the others: a field + // access can name several and a partial answer beats none. + for (decls.items) |d| { + switch (kind) { + .declaration => try rowForToken(arena, d.definitionToken(analyser, false) catch continue, out), + .definition => try rowForToken(arena, d.definitionToken(analyser, true) catch continue, out), + .type_definition => { + // peel error unions / pointers / optionals the way ZLS does, + // then take the container's own name token + if (try d.resolveType(analyser)) |ty| { + var r = ty; + while (true) { + r = try analyser.resolveUnwrapErrorUnionType(r, .payload) orelse + try analyser.resolveDerefType(r) orelse + try analyser.resolveOptionalUnwrap(r) orelse break; + } + if (r.typeDefinitionToken()) |th| { + try rowForToken(arena, th, out); + continue; + } + } + const nd = try d.typeDeclarationNode() orelse continue; + const t2 = &nd.handle.tree; + const rr = offsets.nodeToRange(t2, nd.node, enc); + const path = nd.handle.uri.toFsPath(arena) catch continue; + lsp.row(out, path, rr.start.line, rr.start.character, lineAt(t2.source, rr.start.line)); + }, + } + } +} + +// ---------------------------------------------------------------- hover + +/// `hover.hover` is already Server-free, so this is a call and a reformat: the +/// `+Hover` buffer holds prose, not locations, so the markdown comes through +/// as lines and nothing arms the n/N stepper. Fenced-code markers are dropped +/// because a plain-text pane has nothing to do with them. +fn hover( + arena: std.mem.Allocator, + analyser: *Analyser, + handle: *DocumentStore.Handle, + off: usize, + out: *std.Io.Writer, +) !void { + const h = try zls.hover.hover(analyser, arena, handle, off, .markdown, enc) orelse return; + const text = switch (h.contents) { + .markup_content => |m| m.value, + else => return, + }; + var it = std.mem.splitScalar(u8, text, '\n'); + while (it.next()) |ln| { + if (std.mem.startsWith(u8, ln, "```")) continue; + out.print("{s}\n", .{std.mem.trimEnd(u8, ln, " \t\r")}) catch return; + } +} + +// ---------------------------------------------------------------- symbols + +/// `document_symbol.getDocumentSymbols` needs an arena, a tree and an encoding +/// — no store, no analyser, no intern pool. That makes `SPC s` the cheapest +/// thing in this file: a parse and a walk. +fn documentSymbols( + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + path: []const u8, + tree: *const Ast, + out: *std.Io.Writer, +) !void { + const syms = try zls.document_symbol.getDocumentSymbols(arena, tree, enc); + var n: usize = 0; + try emitSymbols(gpa, arena, path, syms, "", &n, out); +} + +/// Depth-first so the rows read in source order, with the parent's name as a +/// qualifier — `Kind.jumpsWhenSingle` rather than a bare `jumpsWhenSingle`, +/// because a flat list of method names is not navigable. +fn emitSymbols( + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + path: []const u8, + syms: []const types.DocumentSymbol, + prefix: []const u8, + n: *usize, + out: *std.Io.Writer, +) !void { + for (syms) |s| { + if (n.* >= max_rows) return; + n.* += 1; + const name = if (prefix.len == 0) + s.name + else + try std.fmt.allocPrint(arena, "{s}.{s}", .{ prefix, s.name }); + const text = if (s.detail) |d| + try std.fmt.allocPrint(arena, "{s} {s}", .{ name, d }) + else + name; + lsp.row(out, path, s.selectionRange.start.line, s.selectionRange.start.character, text); + if (s.children) |kids| try emitSymbols(gpa, arena, path, kids, name, n, out); + } +} + +/// `SPC S`: every `.zig` under the pane's directory, parsed, walked, filtered +/// by substring. ZLS's own workspace symbols would only see files already in +/// the DocumentStore (which is one), so this walks the tree itself — and +/// because `getDocumentSymbols` needs nothing but a tree, it never has to open +/// a store or resolve an import to do it. +fn workspaceSymbols( + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + io: std.Io, + req: lsp.Req, + out: *std.Io.Writer, +) !void { + if (req.arg.len == 0) return; + var files: std.ArrayList([]const u8) = .empty; + try collectZigFiles(arena, io, req.root, &files); + var n: usize = 0; + for (files.items) |path| { + if (n >= max_rows) return; + const src = readFileZ(arena, io, path) catch continue; + var tree: Ast = Ast.parse(arena, src, .zig) catch continue; + defer tree.deinit(arena); + const syms = zls.document_symbol.getDocumentSymbols(arena, &tree, enc) catch continue; + try filterSymbols(gpa, arena, path, syms, "", req.arg, &n, out); + } +} + +fn filterSymbols( + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + path: []const u8, + syms: []const types.DocumentSymbol, + prefix: []const u8, + needle: []const u8, + n: *usize, + out: *std.Io.Writer, +) !void { + for (syms) |s| { + if (n.* >= max_rows) return; + const name = if (prefix.len == 0) + s.name + else + try std.fmt.allocPrint(arena, "{s}.{s}", .{ prefix, s.name }); + if (containsIgnoreCase(s.name, needle)) { + n.* += 1; + lsp.row(out, path, s.selectionRange.start.line, s.selectionRange.start.character, name); + } + if (s.children) |kids| try filterSymbols(gpa, arena, path, kids, name, needle, n, out); + } +} + +fn containsIgnoreCase(hay: []const u8, needle: []const u8) bool { + if (needle.len == 0) return true; + if (needle.len > hay.len) return false; + var i: usize = 0; + outer: while (i + needle.len <= hay.len) : (i += 1) { + for (needle, 0..) |c, j| + if (std.ascii.toLower(hay[i + j]) != std.ascii.toLower(c)) continue :outer; + return true; + } + return false; +} + +// ------------------------------------------------------------- references + +/// `gr` / `SPC h` / `SPC r`. ZLS's `symbolReferences` is private and its +/// `referencesHandler` takes a `*Server`, so this is the same algorithm from +/// the outside: find the decl under the cursor, then offer every identifier +/// token in the file that spells the same name back to the analyser and keep +/// the ones that resolve to the same decl. Resolving rather than matching is +/// what makes a shadowed local not a reference to the outer one. +/// +/// LIMIT: this file only. A workspace-wide answer means loading every project +/// file into the store and running the analyser over each — seconds, not +/// milliseconds, on every keypress, and the store's own workspace iteration +/// has the same restriction (it can only see handles that were loaded). +/// +/// `new_name` non-null makes it a rename PREVIEW: the same rows, annotated +/// with the replacement. The seam returns rows, not edits, so `SPC r` shows +/// what would change and changes nothing — an honest half of rename. +fn references( + arena: std.mem.Allocator, + analyser: *Analyser, + handle: *DocumentStore.Handle, + off: usize, + new_name: ?[]const u8, + out: *std.Io.Writer, +) !void { + const tree = &handle.tree; + const target = try declAt(arena, analyser, handle, off) orelse return; + // The decl's name token indexes ITS OWN file, which after an alias resolve + // is not necessarily this one — and on a half-typed file it is not + // necessarily an identifier either, which `identifierTokenToNameSlice` + // asserts. Both of those are crashes on a keystroke, so both are checked. + const decl_tree = &target.handle.tree; + const name_tok = target.nameToken(); + if (name_tok >= decl_tree.tokens.len) return; + if (decl_tree.tokenTag(name_tok) != .identifier) return; + const want = offsets.identifierTokenToNameSlice(decl_tree, name_tok); + if (want.len == 0) return; + + const lines: Lines = try .build(arena, tree.source); + var n: usize = 0; + for (0..tree.tokens.len) |i| { + if (n >= max_rows) return; + const tok: Ast.TokenIndex = @intCast(i); + if (tree.tokenTag(tok) != .identifier) continue; + if (!std.mem.eql(u8, offsets.identifierTokenToNameSlice(tree, tok), want)) continue; + const at = tree.tokenStart(tok); + const d = (declAt(arena, analyser, handle, at) catch continue) orelse continue; + if (!d.eql(target)) continue; + n += 1; + const r = offsets.tokenToRange(tree, tok, enc); + const text = if (new_name) |nn| + try std.fmt.allocPrint(arena, "{s} -> {s} {s}", .{ want, nn, std.mem.trim(u8, lines.line(r.start.line), " \t") }) + else + lines.line(r.start.line); + lsp.row(out, handle.uri.toFsPath(arena) catch return, r.start.line, r.start.character, text); + } +} + +/// The decl under a byte offset, whatever the surrounding syntax is. Shared by +/// references and by rename, which is references with a label on it. +fn declAt( + arena: std.mem.Allocator, + analyser: *Analyser, + handle: *DocumentStore.Handle, + off: usize, +) !?Analyser.DeclWithHandle { + const tree = &handle.tree; + const ctx = try Analyser.getPositionContext(arena, tree, off, true); + const name_loc = offsets.identifierLocFromIndex(tree, off) orelse return null; + const name = offsets.locToSlice(tree.source, name_loc); + const d = switch (ctx) { + .var_access, .test_doctest_name => try analyser.lookupSymbolGlobal(handle, name, off), + .field_access => |loc| blk: { + const found = try analyser.getSymbolFieldAccesses(arena, handle, off, offsets.locMerge(loc, name_loc), name) orelse break :blk null; + break :blk if (found.len != 0) found[0] else null; + }, + .label_access, .label_decl => try Analyser.lookupLabel(handle, name, off), + .enum_literal => try analyser.getSymbolEnumLiteral(handle, off, name), + else => null, + } orelse return null; + return try analyser.resolveVarDeclAlias(d) orelse d; +} + +// ------------------------------------------------------------ diagnostics + +/// `SPC d` and the list `]d`/`[d` step. ZLS's `getAstCheckDiagnostics` prefers +/// spawning `zig ast-check`; its OTHER branch runs `std.zig.AstGen` in this +/// process, which is the branch a subprocess-free backend wants, so that is +/// what is inlined here (it is ten lines and needs a `*Server` only for the +/// config it reads). +/// +/// A clean file emits ONE row saying so. Silence already means "no backend" in +/// this seam — `lspResponse` opens nothing for zero rows — so a `SPC d` that +/// checked and found nothing has to be able to say the difference. +fn diagnostics( + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + path: []const u8, + tree: *const Ast, + out: *std.Io.Writer, +) !void { + const n = try treeDiagnostics(gpa, arena, path, tree, out); + if (n == 0) lsp.row(out, path, 0, 0, "no diagnostics"); +} + +/// `zig ast-check`, in this process. ZLS spawns the compiler for this when it +/// has one; its fallback branch is `std.zig.AstGen` directly, and that fallback +/// is this backend's only branch. Caller owns the bundle. +fn astCheck(gpa: std.mem.Allocator, path: []const u8, tree: *const Ast) !std.zig.ErrorBundle { + if (tree.errors.len != 0) return .empty; + var zir = std.zig.AstGen.generate(gpa, tree.*) catch return .empty; + defer zir.deinit(gpa); + if (!zir.hasCompileErrors()) return .empty; + var wip: std.zig.ErrorBundle.Wip = undefined; + try wip.init(gpa); + defer wip.deinit(); + wip.addZirErrorMessages(zir, tree.*, tree.source, path) catch return .empty; + return wip.toOwnedBundle("") catch .empty; +} + +/// Parse errors first (AstGen cannot run on a tree that did not parse), then +/// the AstGen pass. Returns how many rows were emitted. +fn treeDiagnostics( + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + path: []const u8, + tree: *const Ast, + out: *std.Io.Writer, +) !usize { + var n: usize = 0; + if (tree.errors.len != 0) { + const lines: Lines = try .build(arena, tree.source); + for (tree.errors) |e| { + if (n >= max_rows) return n; + var buf: std.ArrayList(u8) = .empty; + var w: std.Io.Writer.Allocating = .fromArrayList(arena, &buf); + tree.renderError(e, &w.writer) catch continue; + const at = tree.tokenStart(e.token); + const lc = lsp.lineCol(tree.source, at); + n += 1; + lsp.row(out, path, lc.line, lc.col, try std.fmt.allocPrint(arena, "error: {s} {s}", .{ + w.written(), std.mem.trim(u8, lines.line(lc.line), " \t"), + })); + } + return n; + } + + var bundle = try astCheck(gpa, path, tree); + defer bundle.deinit(gpa); + if (bundle.errorMessageCount() == 0) return n; + for (bundle.getMessages()) |m| { + if (n >= max_rows) return n; + const em = bundle.getErrorMessage(m); + if (em.src_loc == .none) continue; + const sl = bundle.getSourceLocation(em.src_loc); + n += 1; + lsp.row(out, path, sl.line, sl.column, try std.fmt.allocPrint(arena, "error: {s}", .{ + bundle.nullTerminatedString(em.msg), + })); + } + return n; +} + +/// `SPC D`: the same check over every `.zig` under the pane's directory. No +/// analyser and no store — AstGen is a per-file pass — so this is bounded by +/// how fast Zig can parse the tree, which for pardes's own `src/` is tens of +/// milliseconds. +fn workspaceDiagnostics( + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + io: std.Io, + req: lsp.Req, + out: *std.Io.Writer, +) !void { + var files: std.ArrayList([]const u8) = .empty; + try collectZigFiles(arena, io, req.root, &files); + var total: usize = 0; + for (files.items) |path| { + const src = readFileZ(arena, io, path) catch continue; + var tree: Ast = Ast.parse(arena, src, .zig) catch continue; + defer tree.deinit(arena); + total += treeDiagnostics(gpa, arena, path, &tree, out) catch continue; + if (total >= max_rows) return; + } + if (total == 0) lsp.row(out, req.path, 0, 0, try std.fmt.allocPrint(arena, "no diagnostics in {d} file(s)", .{files.items.len})); +} + +// ----------------------------------------------------------- code actions + +/// `SPC a`. `code_actions.Builder` is one of the Server-free ones — it wants +/// an arena, an analyser, a handle and an ErrorBundle — so the only missing +/// piece is the bundle, which `astCheck` above produces without a compiler. +/// +/// It lists what could be done, and does none of it: the seam hands back rows, +/// not edits, so there is no channel through which a backend could apply a +/// fix. `+Lsp` is prose, so these are lines rather than locations. +fn codeActions( + gpa: std.mem.Allocator, + arena: std.mem.Allocator, + analyser: *Analyser, + handle: *DocumentStore.Handle, + off: usize, + out: *std.Io.Writer, +) !void { + const tree = &handle.tree; + if (tree.errors.len != 0) { + out.print("no code actions: file does not parse\n", .{}) catch {}; + return; + } + var bundle = try astCheck(gpa, "", tree); + defer bundle.deinit(gpa); + + var builder: zls.code_actions.Builder = .{ + .arena = arena, + .analyser = analyser, + .handle = handle, + .offset_encoding = enc, + .only_kinds = null, + }; + builder.generateCodeAction(bundle) catch {}; + const at = offsets.locToRange(tree.source, .{ .start = off, .end = off }, enc); + builder.generateCodeActionsInRange(at) catch {}; + + for (builder.actions.items) |a| { + out.print("{s}\n", .{a.title}) catch return; + } + if (builder.actions.items.len == 0) out.print("no code actions\n", .{}) catch {}; +} + +// ---------------------------------------------------------------- format + +/// `=`. The seam returns ROWS, not edits — there is no channel through which a +/// backend can rewrite the buffer — so format reports rather than formats: one +/// row per line `zig fmt` would change, in the `+Lsp` buffer. That is the +/// honest shape of "format" behind a read-only seam, and it is still the +/// answer to the question the keypress asks. +fn formatQuery(arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !void { + var tree: Ast = try .parse(arena, req.source, .zig); + defer tree.deinit(arena); + if (tree.errors.len != 0) { + lsp.row(out, req.path, 0, 0, "cannot format: file does not parse"); + return; + } + var buf: std.ArrayList(u8) = .empty; + var w: std.Io.Writer.Allocating = .fromArrayList(arena, &buf); + tree.render(arena, &w.writer, .{}) catch return; + const formatted = w.written(); + if (std.mem.eql(u8, formatted, req.source)) { + lsp.row(out, req.path, 0, 0, "already formatted"); + return; + } + var a = std.mem.splitScalar(u8, req.source, '\n'); + var b = std.mem.splitScalar(u8, formatted, '\n'); + var ln: usize = 0; + var n: usize = 0; + while (n < max_rows) : (ln += 1) { + const x = a.next(); + const y = b.next(); + if (x == null and y == null) break; + const xs = x orelse ""; + const ys = y orelse ""; + if (std.mem.eql(u8, xs, ys)) continue; + n += 1; + lsp.row(out, req.path, ln, 0, try std.fmt.allocPrint(arena, "- {s} + {s}", .{ + std.mem.trim(u8, xs, " \t"), std.mem.trim(u8, ys, " \t"), + })); + } +} + +// ------------------------------------------------------------------ files + +/// Every `.zig` under `root`, skipping the directories a source walk has no +/// business in. Capped, because this runs on a keypress. +fn collectZigFiles(arena: std.mem.Allocator, io: std.Io, root: []const u8, out: *std.ArrayList([]const u8)) !void { + if (root.len == 0) return; + var dir = std.Io.Dir.cwd().openDir(io, root, .{ .iterate = true }) catch return; + defer dir.close(io); + var w = dir.walkSelectively(arena) catch return; + defer w.deinit(); + while (out.items.len < max_files) { + const e = (w.next(io) catch break) orelse break; + switch (e.kind) { + .directory => { + if (e.basename.len != 0 and e.basename[0] == '.') continue; + if (std.mem.eql(u8, e.basename, "zig-out")) continue; + if (std.mem.eql(u8, e.basename, "zig-cache")) continue; + w.enter(io, e) catch {}; + }, + .file => { + if (!std.mem.endsWith(u8, e.basename, ".zig")) continue; + const p = std.fs.path.join(arena, &.{ root, e.path }) catch continue; + try out.append(arena, p); + }, + else => {}, + } + } +} + +fn readFileZ(arena: std.mem.Allocator, io: std.Io, path: []const u8) ![:0]u8 { + return std.Io.Dir.cwd().readFileAllocOptions(io, path, arena, .limited(4 * 1024 * 1024), .of(u8), 0); +} diff --git a/src/lsp_zls.zig b/src/lsp_zls.zig deleted file mode 100644 index 6f5bbcdd..00000000 --- a/src/lsp_zls.zig +++ /dev/null @@ -1,1073 +0,0 @@ -//! ZLS as a LIBRARY, not a language server. -//! -//! There is no subprocess, no JSON-RPC, no `initialize` handshake and no -//! `Server`. `gd` runs ZLS's own analyser on this thread and comes back with a -//! token; the whole "protocol" is a struct field. ZLS's build.zig already -//! publishes its guts as an importable module (`b.modules.put("zls", ...)`), -//! and everything under `features/` that is not welded to `*Server` is a plain -//! function over (analyser, arena, handle, offset) — so the parts we want are -//! callable and the parts we don't want cost nothing. -//! -//! What that buys and what it costs: -//! -//! - Cold IS warm. There is no server to start, no workspace to index, no -//! handshake to wait for. The first `gd` after launch pays for parsing this -//! file and whatever it imports, and nothing else. -//! - It also means NOTHING IS CACHED between queries. Every call builds a -//! DocumentStore, resolves imports, and throws it all away, because `query` -//! is handed an arena that dies on return and a `req` that is a snapshot of -//! a buffer the user is still typing into. A cache would have to be a global -//! with its own mutex and its own invalidation story; that is a real design, -//! not a line of code, and it is the obvious next step rather than something -//! smuggled in here. -//! - `zig_exe_path` is null on purpose: shelling out to the compiler is the -//! thing this backend exists to avoid. `zig_lib_dir` is the build's own -//! (`b.graph.zig_lib_directory`), which is what makes `std` resolvable — -//! `gd` on `std.mem.count` lands in the real `mem.zig`. -//! -//! The price is DEPENDENCIES, and it is worth being exact about how far it -//! goes. ZLS resolves a non-`std`, non-relative import only through a -//! `BuildFile` whose config came from running `zig build --build-runner`, so -//! with no compiler it resolves none of them. We paper over ONE case of -//! that: `gd` on the import string itself (`moduleRoot`, fed by build.zig). -//! That is a fallback in OUR handler and ZLS cannot see it, so the analyser -//! still cannot type `vaxis`, and `gd` on `vaxis.init` still finds nothing. -//! Making member access work means giving ZLS a real build config — either -//! by letting it run the build runner (a subprocess, and with no cross-query -//! cache that is once PER KEYPRESS) or by synthesizing a BuildConfig and -//! reaching into `BuildFile.impl`. Both are real work; neither is smuggled -//! in here. `@import("builtin")` and `@cImport` are unresolved for the same -//! reason. -const std = @import("std"); -const zls = @import("zls"); -const lsp = @import("lsp.zig"); -const cfg = @import("pardes_config"); - -const Analyser = zls.Analyser; -const DocumentStore = zls.DocumentStore; -const InternPool = zls.analyser.InternPool; -const offsets = zls.offsets; -const Uri = zls.Uri; -const types = zls.lsp.types; -const Ast = std.zig.Ast; - -/// BYTE offsets. ZLS defaults to utf-16 because that is what the wire protocol -/// says; we are not on a wire, and `+Search` rows are byte columns. -const enc: offsets.Encoding = .@"utf-8"; - -/// Guards on the workspace walks (workspace symbols / workspace diagnostics). -/// A language query runs on every keypress of `SPC S`; it may not turn into an -/// unbounded tree walk because someone opened a file in `/`. -const max_files = 512; -const max_rows = 2000; - -pub const supports: std.EnumSet(lsp.Kind) = .initMany(&.{ - .definition, - .declaration, - .type_definition, - .implementation, - .references, - .select_refs, - .hover, - .document_symbols, - .workspace_symbols, - .diagnostics, - .workspace_diagnostics, - .rename, - .format, - .code_action, - .status, - .explain, -}); - -// ------------------------------------------------------------ introspection -// -// Everything below exists because of the contract two lines down: this backend -// never fails loudly. That is right for an editor — a thrown analyser must not -// take the process with it — but it means a broken backend and a correct one -// that found nothing look identical from the outside. So every query leaves a -// record, and `SPC l i` reads them back. - -/// One finished query. Fixed-size and inline: this array is process-global and -/// lives forever, so it must not own heap. -const LogEntry = struct { - used: bool = false, - kind: lsp.Kind = .definition, - us: u64 = 0, - rows: usize = 0, - offset: u32 = 0, - /// the error `run` returned, or empty when it returned normally. THIS is - /// the thing you cannot see any other way. - err: [40]u8 = @splat(0), - err_len: u8 = 0, - /// basename only — a full path would need most of this struct - file: [64]u8 = @splat(0), - file_len: u8 = 0, - - fn errName(e: *const LogEntry) []const u8 { - return e.err[0..e.err_len]; - } - fn fileName(e: *const LogEntry) []const u8 { - return e.file[0..e.file_len]; - } -}; - -const log_cap = 24; -var log_buf: [log_cap]LogEntry = @splat(.{}); -var log_next: usize = 0; -var log_total: u64 = 0; -/// 0.16 has no std.Thread.Mutex; the critical sections here are a memcpy, so -/// spinning on the lock-free one is enough — the same call gui.zig's pty queue -/// makes. Only WORKER threads touch this; the core never does. -var log_mu: std.atomic.Mutex = .unlocked; - -fn logLock() void { - while (!log_mu.tryLock()) std.atomic.spinLoopHint(); -} - -fn record(req: lsp.Req, us: u64, rows: usize, err: []const u8) void { - logLock(); - defer log_mu.unlock(); - const e = &log_buf[log_next]; - e.* = .{ .used = true, .kind = req.kind, .us = us, .rows = rows, .offset = req.offset }; - const base = std.fs.path.basename(req.path); - e.file_len = @intCast(@min(base.len, e.file.len)); - @memcpy(e.file[0..e.file_len], base[0..e.file_len]); - e.err_len = @intCast(@min(err.len, e.err.len)); - @memcpy(e.err[0..e.err_len], err[0..e.err_len]); - log_next = (log_next + 1) % log_cap; - log_total += 1; -} - -/// A duration cannot go in a snapshot golden — it differs every run. The -/// snapshot harness sets PARDES_LSP_NOTIME so the two introspection views -/// print `-` instead, the same way it pins PARDES_DUMP for the dump tests. -/// Nothing else reads it, and unset (i.e. every real run) shows real numbers. -fn hideTime() bool { - const v = std.c.getenv("PARDES_LSP_NOTIME") orelse return false; - return std.mem.span(v).len != 0; -} - -fn nowUs() u64 { - var ts: std.c.timespec = undefined; - _ = std.c.clock_gettime(.MONOTONIC, &ts); - return @as(u64, @intCast(ts.sec)) *| 1_000_000 +| @as(u64, @intCast(ts.nsec)) / 1000; -} - -/// The narration `SPC l w` prints. It is threaded through the REAL resolution -/// path rather than re-derived beside it: a debug view that reimplements the -/// logic is a debug view that can disagree with it, and the whole point is to -/// find out what actually happened. -const Trace = struct { - on: bool = false, - arena: std.mem.Allocator, - buf: std.ArrayList(u8) = .empty, - - fn note(t: *Trace, comptime fmt: []const u8, args: anytype) void { - if (!t.on) return; - t.buf.print(t.arena, fmt ++ "\n", args) catch {}; - } -}; - -/// The seam's contract: never fail, never panic, no rows is a legal answer. -/// Every error path in here — OOM, a cancelled io, a file that vanished — -/// collapses to "appended nothing", which the core already treats as "no -/// result". There is deliberately no error rendering. -pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) void { - // status reads the log; recording it would push a real query out of a - // 24-entry ring every time you looked at it. - if (req.kind == .status) return status(arena, req, out) catch {}; - - // Rows land in a scratch buffer first, for two reasons a plain Writer - // cannot serve: the log wants an exact row count, and `explain` throws the - // rows away and prints the narration in their place — neither a count nor - // a rewind exists on a Writer you do not own. - var scratch: std.Io.Writer.Allocating = .init(gpa); - defer scratch.deinit(); - - var tr: Trace = .{ .arena = arena, .on = req.kind == .explain }; - const t0 = nowUs(); - var err_name: []const u8 = ""; - run(gpa, arena, req, &scratch.writer, &tr) catch |e| { - err_name = @errorName(e); - tr.note("ERROR: {s} — the query threw; the editor shows this as 'no result'", .{err_name}); - }; - const us = nowUs() -| t0; - const rows = std.mem.count(u8, scratch.written(), "\n"); - record(req, us, rows, err_name); - - if (req.kind != .explain) { - out.writeAll(scratch.written()) catch {}; - return; - } - // the question was never "where is it", it was "what did you do" - out.print("lsp explain — the definition query at byte {d} of {s}\n\n", .{ - req.offset, if (req.path.len == 0) "(no file)" else std.fs.path.basename(req.path), - }) catch {}; - out.writeAll(tr.buf.items) catch {}; - if (hideTime()) - out.print("\n{d} row(s)\n", .{rows}) catch {} - else - out.print("\n{d} row(s) in {d}us\n", .{ rows, us }) catch {}; -} - -fn run(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer, tr: *Trace) !void { - // The blocking, single-threaded std.Io — the same one look.zig walks - // directories with. ZLS wants an `Io` for every file read; a worker thread - // that is allowed to block wants the one that just does the syscall. - const io = std.Io.Threaded.global_single_threaded.io(); - - // The two workspace kinds are about the PROJECT, not about this buffer, so - // they answer from any pane and never need the analyser, the store or the - // intern pool — a walk and a parse each. - // `explain` narrates the definition query; everywhere below dispatches on - // the effective kind, so the trace follows the code `gd` really runs. - const kind: lsp.Kind = if (req.kind == .explain) .definition else req.kind; - - switch (kind) { - .workspace_symbols => return workspaceSymbols(gpa, arena, io, req, out), - .workspace_diagnostics => return workspaceDiagnostics(gpa, arena, io, req, out), - // Everything else is about THIS file, and this backend speaks Zig. The - // core does not gate the keymap by file type (a pane is a pane), so - // the gate is here: `gd` in a README must find nothing rather than - // parse prose as Zig and confidently resolve a word out of it. - else => if (!std.mem.endsWith(u8, req.path, ".zig")) { - tr.note("STOP: not a .zig file ({s}) — this backend only reads Zig", .{ - if (req.path.len == 0) "no path: this pane has no file behind it" else req.path, - }); - return; - }, - } - // Pure tree, no analyser. - if (kind == .format) return formatQuery(arena, req, out); - - var env: std.process.Environ.Map = .init(arena); - var lib_dir: ?std.Build.Cache.Directory = null; - if (zigLibPath()) |p| { - if (std.Io.Dir.cwd().openDir(io, p, .{})) |h| { - lib_dir = .{ .handle = h, .path = p }; - tr.note("zig lib dir: {s} (open) — `std` imports resolve", .{p}); - } else |e| tr.note("zig lib dir: {s} FAILED to open ({s}) — nothing in `std` will resolve", .{ p, @errorName(e) }); - } else tr.note("zig lib dir: NOT SET — nothing in `std` will resolve", .{}); - defer if (lib_dir) |d| d.handle.close(io); - - var ip: InternPool = try .init(io, gpa); - defer ip.deinit(gpa); - - var diags: zls.DiagnosticsCollection = .{ .io = io, .allocator = gpa }; - defer diags.deinit(); - - var store: DocumentStore = .{ - .io = io, - .allocator = gpa, - .config = .{ - .environ_map = &env, - // no compiler subprocess: that is the entire point of this backend - .zig_exe_path = null, - .zig_lib_dir = lib_dir, - .build_runner_path = null, - .builtin_path = null, - .global_cache_dir = null, - .wasi_preopens = {}, - }, - .diagnostics_collection = &diags, - }; - defer store.deinit(); - - // The buffer the user is looking at, not the file on disk: `source` is the - // shell's snapshot, so unsaved edits are what gets analysed. - const uri: Uri = try .fromPath(arena, req.path); - try store.openLspSyncedDocument(uri, req.source); - const handle = store.getHandle(uri) orelse { - tr.note("STOP: the document store would not take this buffer", .{}); - return; - }; - if (handle.tree.mode == .zon) { - tr.note("STOP: parsed as .zon, not Zig code — there is nothing to resolve", .{}); - return; - } - tr.note("parsed {d} bytes, {d} syntax error(s){s}", .{ - req.source.len, - handle.tree.errors.len, - if (handle.tree.errors.len == 0) "" else " — a broken tree resolves badly or not at all", - }); - - var analyser: Analyser = .init(gpa, arena, &store, &ip, handle); - defer analyser.deinit(); - - const off: usize = @min(req.offset, req.source.len); - - switch (kind) { - .definition, .implementation => try goto(arena, &analyser, handle, off, .definition, out, tr), - .declaration => try goto(arena, &analyser, handle, off, .declaration, out, tr), - .type_definition => try goto(arena, &analyser, handle, off, .type_definition, out, tr), - .hover => try hover(arena, &analyser, handle, off, out), - .document_symbols => try documentSymbols(gpa, arena, req.path, &handle.tree, out), - .references, .select_refs => try references(arena, &analyser, handle, off, null, out), - .rename => try references(arena, &analyser, handle, off, req.arg, out), - .diagnostics => try diagnostics(gpa, arena, req.path, &handle.tree, out), - .code_action => try codeActions(gpa, arena, &analyser, handle, off, out), - else => {}, - } -} - -/// `SPC l i`. What the backend IS: which ZLS, which stdlib, what it will and -/// will not answer, and the last two dozen queries with their timings and — -/// the point of the whole exercise — the errors that `query` swallowed. -fn status(arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !void { - const io = std.Io.Threaded.global_single_threaded.io(); - try out.print("backend: {s}\nzls: {s} (compiled in — no server process, no JSON-RPC)\n", .{ - lsp.backend_name, cfg.zls_version, - }); - - // The single most common cause of "gd does nothing in std": say whether it - // is actually openable, not just what it is set to. - if (zigLibPath()) |p| { - if (std.Io.Dir.cwd().openDir(io, p, .{})) |h| { - var d = h; - d.close(io); - try out.print("zig lib dir: {s} [OK]\n", .{p}); - } else |e| try out.print("zig lib dir: {s} [CANNOT OPEN: {s}] — nothing in `std` will resolve\n", .{ p, @errorName(e) }); - } else try out.print("zig lib dir: NOT SET — nothing in `std` will resolve\n", .{}); - try out.print("offsets: {s} walk caps: {d} files, {d} rows\n", .{ @tagName(enc), max_files, max_rows }); - // The dependency module map, which is the answer to "why does gd work on - // std and not on @import(\"vaxis\")" — if a name is not on this list, the - // build did not import it and nothing can resolve it. - try out.print("\ndependency imports gd can follow ({d}):\n", .{cfg.module_names.len}); - for (cfg.module_names, cfg.module_roots) |n, r| { - try out.print(" {s:<14} {s}\n", .{ n, r }); - } - try out.print("asked from: {s}\n", .{if (req.path.len == 0) "a pane with no file" else req.path}); - - try out.print("\nanswers:", .{}); - var it = supports.iterator(); - var n: usize = 0; - while (it.next()) |k| : (n += 1) { - if (k == .status or k == .explain) continue; // these two are the debugger, not a feature - try out.print("{s}{s}", .{ if (n == 0) " " else ", ", @tagName(k) }); - } - try out.print("\nrefuses: ", .{}); - n = 0; - for (std.enums.values(lsp.Kind)) |k| { - if (supports.contains(k)) continue; - try out.print("{s}{s}", .{ if (n == 0) "" else ", ", @tagName(k) }); - n += 1; - } - if (n == 0) try out.print("(nothing)", .{}); - try out.print("\n", .{}); - - // The log. Oldest first, so it reads like a transcript. - logLock(); - defer log_mu.unlock(); - try out.print("\nlast queries ({d} total, keeping {d}):\n", .{ log_total, log_cap }); - var shown: usize = 0; - for (0..log_cap) |i| { - const e = &log_buf[(log_next + i) % log_cap]; - if (!e.used) continue; - shown += 1; - if (hideTime()) { - try out.print(" {s:<22} {s:<20} @{d:<7} {d:>4} row(s){s}{s}\n", .{ - @tagName(e.kind), e.fileName(), e.offset, e.rows, - if (e.err_len == 0) "" else " ERROR: ", e.errName(), - }); - } else { - try out.print(" {s:<22} {s:<20} @{d:<7} {d:>7}us {d:>4} row(s){s}{s}\n", .{ - @tagName(e.kind), e.fileName(), e.offset, e.us, e.rows, - if (e.err_len == 0) "" else " ERROR: ", e.errName(), - }); - } - } - if (shown == 0) try out.print(" (none yet — press gd somewhere, then ask again)\n", .{}); - _ = arena; -} - -/// The root source file of a module this build imports, or null. -/// -/// ZLS answers `@import("std")` from `zig_lib_dir` and a relative `.zig` path -/// from the filesystem, but ANY OTHER name — every dependency in -/// build.zig.zon — it can only resolve by running `zig build --build-runner` -/// to discover the module graph. This backend sets `zig_exe_path = null` on -/// purpose, so that branch always returned nothing: `gd` worked perfectly into -/// `std` and silently did nothing on `@import("vaxis")`. -/// -/// We never needed the compiler for this. build.zig IS the module graph, so it -/// folds its own `root_mod.import_table` into these two parallel arrays at -/// configure time. Correct by construction: a dependency added or renamed in -/// build.zig cannot forget to appear here. -/// -/// LIMITS, in the order you will hit them: -/// 1. This resolves the IMPORT STRING only. It is consulted from our own -/// `goto`, not from inside ZLS, so the analyser still cannot type the -/// `vaxis` const — `gd` on `vaxis.init` finds nothing. Fixing that means -/// giving ZLS a BuildConfig; see the note at the top of this file. -/// 2. These are THIS build's imports. A file inside a dependency importing -/// that dependency's own internal module name is a miss — that would mean -/// running ITS build.zig. -/// 3. A module whose root source is a GENERATED file is absent from the -/// table: it has no path until make() runs. -fn moduleRoot(name: []const u8) ?[]const u8 { - comptime std.debug.assert(cfg.module_names.len == cfg.module_roots.len); - for (cfg.module_names, cfg.module_roots) |n, r| { - if (std.mem.eql(u8, n, name)) return r; - } - return null; -} - -/// Why a field access came back empty. Worth its own function because ONE of -/// the reasons is a known, explainable hole rather than "no idea": if the left -/// side starts with a dependency module name, the analyser could not type it -/// because ZLS has no build config, and no amount of staring at the code will -/// reveal that. -fn explainFieldMiss(tr: *Trace, lhs: []const u8) void { - var head: usize = 0; - while (head < lhs.len and (std.ascii.isAlphanumeric(lhs[head]) or lhs[head] == '_')) head += 1; - if (head != 0 and moduleRoot(lhs[0..head]) != null) { - tr.note(" `{s}` IS a build.zig dependency, and this is the known hole: the module", .{lhs[0..head]}); - tr.note(" map is consulted by our @import handler, not by ZLS, so the analyser cannot", .{}); - tr.note(" type it. `gd` on the import string works; `gd` on a member does not.", .{}); - return; - } - tr.note(" (a value whose type needs comptime, an unresolved @import, or genuinely absent)", .{}); -} - -/// The zig lib directory, which is the one thing this backend cannot work out -/// for itself: it is baked in at build time from `b.graph.zig_lib_directory`, -/// i.e. the exact stdlib pardes was compiled against, and ZIG_LIB_DIR wins if -/// the user moved it. Without this, `gd` on anything in `std` finds nothing. -fn zigLibPath() ?[]const u8 { - if (std.c.getenv("ZIG_LIB_DIR")) |p| { - const s = std.mem.span(p); - if (s.len != 0) return s; - } - if (cfg.zig_lib_dir.len != 0) return cfg.zig_lib_dir; - return null; -} - -// ---------------------------------------------------------------- rows - -/// Byte offset -> line, without rescanning the file per row. Built once per -/// query over the file we emit many rows from (symbols, references, -/// diagnostics); a goto that emits two rows just scans. -const Lines = struct { - starts: []const u32, - src: []const u8, - - fn build(arena: std.mem.Allocator, src: []const u8) !Lines { - var starts: std.ArrayList(u32) = .empty; - try starts.append(arena, 0); - for (src, 0..) |c, i| if (c == '\n') try starts.append(arena, @intCast(i + 1)); - return .{ .starts = starts.items, .src = src }; - } - - fn line(l: Lines, n: usize) []const u8 { - if (n >= l.starts.len) return ""; - const a = l.starts[n]; - const b = if (n + 1 < l.starts.len) l.starts[n + 1] - 1 else l.src.len; - return l.src[a..@min(b, l.src.len)]; - } -}; - -/// The source line at a 0-based line number, scanned. For the one-or-two-row -/// answers (a goto lands in a file we opened only to read one line out of). -fn lineAt(src: []const u8, want: usize) []const u8 { - var it = std.mem.splitScalar(u8, src, '\n'); - var i: usize = 0; - while (it.next()) |l| : (i += 1) if (i == want) return l; - return ""; -} - -/// Emit one row for a token in whatever file it lives in — which for a goto -/// into `std` is not the file the cursor was in, so the path comes off the -/// handle's uri rather than off `req`. -fn rowForToken(arena: std.mem.Allocator, th: Analyser.TokenWithHandle, out: *std.Io.Writer) !void { - const tree = &th.handle.tree; - if (th.token >= tree.tokens.len) return; - const r = offsets.tokenToRange(tree, th.token, enc); - const path = th.handle.uri.toFsPath(arena) catch return; - lsp.row(out, path, r.start.line, r.start.character, lineAt(tree.source, r.start.line)); -} - -// ---------------------------------------------------------------- goto - -const GotoKind = enum { declaration, definition, type_definition }; - -/// `goto.zig:gotoHandler` minus the Server, minus the LSP types, minus the -/// linkSupport branch: dispatch on the position context, resolve to a decl, -/// resolve the decl to a token, print the token's line. The five helix gotos -/// collapse onto three ZLS kinds (`gi` behaves as `gd`, which is what ZLS's -/// own implementation handler does too). -fn goto( - arena: std.mem.Allocator, - analyser: *Analyser, - handle: *DocumentStore.Handle, - off: usize, - kind: GotoKind, - out: *std.Io.Writer, - tr: *Trace, -) !void { - const tree = &handle.tree; - const ctx = try Analyser.getPositionContext(arena, tree, off, true); - tr.note("position context: .{s}", .{@tagName(ctx)}); - - var decls: std.ArrayList(Analyser.DeclWithHandle) = .empty; - switch (ctx) { - .var_access, .test_doctest_name => { - const name_loc = offsets.identifierLocFromIndex(tree, off) orelse { - tr.note("STOP: no identifier under the cursor", .{}); - return; - }; - const name = offsets.locToSlice(tree.source, name_loc); - tr.note("identifier: `{s}` — a plain name, so: innermost scope outward", .{name}); - if (std.mem.eql(u8, name, "_")) { - tr.note("STOP: `_` is the discard, it declares nothing", .{}); - return; - } - if (std.zig.isPrimitive(name)) { - tr.note("STOP: `{s}` is a builtin primitive — it has no declaration in any file", .{name}); - return; - } - const d = try analyser.lookupSymbolGlobal(handle, name, off) orelse { - tr.note("STOP: lookupSymbolGlobal found no `{s}` in scope here", .{name}); - return; - }; - try decls.append(arena, d); - }, - .field_access => |loc| { - const name_loc = offsets.identifierLocFromIndex(tree, off) orelse { - tr.note("STOP: no identifier under the cursor", .{}); - return; - }; - const name = offsets.locToSlice(tree.source, name_loc); - const held = offsets.locMerge(loc, name_loc); - tr.note("field access: `{s}` on `{s}` — needs the TYPE of the left side", .{ - name, offsets.locToSlice(tree.source, loc), - }); - // NOTE both misses: a null result AND an empty one. ZLS returns an - // empty slice when it typed the left side but found no such member, - // and null when it could not type it at all — from the outside both - // are "gd did nothing", so both have to be explained. - const found = try analyser.getSymbolFieldAccesses(arena, handle, off, held, name) orelse { - tr.note("STOP: could not resolve the left side to a type at all", .{}); - explainFieldMiss(tr, offsets.locToSlice(tree.source, loc)); - return; - }; - if (found.len == 0) { - tr.note("STOP: the left side resolved, but nothing named `{s}` was found on it", .{name}); - explainFieldMiss(tr, offsets.locToSlice(tree.source, loc)); - return; - } - try decls.appendSlice(arena, found); - }, - .label_access, .label_decl => { - const name_loc = offsets.identifierLocFromIndex(tree, off) orelse return; - const name = offsets.locToSlice(tree.source, name_loc); - const d = try Analyser.lookupLabel(handle, name, off) orelse return; - try decls.append(arena, d); - }, - .enum_literal => { - const name_loc = offsets.identifierLocFromIndex(tree, off) orelse return; - const name = offsets.locToSlice(tree.source, name_loc); - const d = try analyser.getSymbolEnumLiteral(handle, off, name) orelse return; - try decls.append(arena, d); - }, - // `gd` on `@import("foo.zig")` opens foo.zig. Only relative imports - // resolve — a package name needs the build graph we do not run. - .import_string_literal, .embedfile_string_literal => { - const loc = ctx.stringLiteralContentLoc(tree.source); - if (loc.start == loc.end) return; - const str = offsets.locToSlice(tree.source, loc); - tr.note("import string: \"{s}\"", .{str}); - const res = try analyser.store.uriFromImportStr(arena, handle, str); - switch (res) { - .none => { - // ZLS gave up, which for a DEPENDENCY name is not a real - // dead end — it just means it wanted the module graph and - // we would not start a compiler to get one. Our build.zig - // knows the graph and baked it in; consult that. - if (moduleRoot(str)) |path| { - tr.note("`{s}` is a build.zig dependency; resolved from the compiled-in module map", .{str}); - lsp.row(out, path, 0, 0, str); - return; - } - tr.note("STOP: `{s}` does not resolve to a file. Relative paths, `std` and this", .{str}); - tr.note(" build's own dependencies work; `builtin`, `root` and a dependency's", .{}); - tr.note(" OWN internal module names need the build graph we do not run.", .{}); - return; - }, - .one => |u| lsp.row(out, u.toFsPath(arena) catch return, 0, 0, str), - .many => |us| for (us) |u| lsp.row(out, u.toFsPath(arena) catch continue, 0, 0, str), - } - return; - }, - else => { - tr.note("STOP: nothing to resolve in a .{s} context — `gd` only answers on", .{@tagName(ctx)}); - tr.note(" an identifier, a field access, a label, an enum literal or an @import string.", .{}); - return; - }, - } - - tr.note("resolved to {d} declaration(s); asking each for its {s} token", .{ decls.items.len, @tagName(kind) }); - // One decl that fails to resolve must not swallow the others: a field - // access can name several and a partial answer beats none. - for (decls.items) |d| { - switch (kind) { - .declaration => try rowForToken(arena, d.definitionToken(analyser, false) catch continue, out), - .definition => try rowForToken(arena, d.definitionToken(analyser, true) catch continue, out), - .type_definition => { - // peel error unions / pointers / optionals the way ZLS does, - // then take the container's own name token - if (try d.resolveType(analyser)) |ty| { - var r = ty; - while (true) { - r = try analyser.resolveUnwrapErrorUnionType(r, .payload) orelse - try analyser.resolveDerefType(r) orelse - try analyser.resolveOptionalUnwrap(r) orelse break; - } - if (r.typeDefinitionToken()) |th| { - try rowForToken(arena, th, out); - continue; - } - } - const nd = try d.typeDeclarationNode() orelse continue; - const t2 = &nd.handle.tree; - const rr = offsets.nodeToRange(t2, nd.node, enc); - const path = nd.handle.uri.toFsPath(arena) catch continue; - lsp.row(out, path, rr.start.line, rr.start.character, lineAt(t2.source, rr.start.line)); - }, - } - } -} - -// ---------------------------------------------------------------- hover - -/// `hover.hover` is already Server-free, so this is a call and a reformat: the -/// `+Hover` buffer holds prose, not locations, so the markdown comes through -/// as lines and nothing arms the n/N stepper. Fenced-code markers are dropped -/// because a plain-text pane has nothing to do with them. -fn hover( - arena: std.mem.Allocator, - analyser: *Analyser, - handle: *DocumentStore.Handle, - off: usize, - out: *std.Io.Writer, -) !void { - const h = try zls.hover.hover(analyser, arena, handle, off, .markdown, enc) orelse return; - const text = switch (h.contents) { - .markup_content => |m| m.value, - else => return, - }; - var it = std.mem.splitScalar(u8, text, '\n'); - while (it.next()) |ln| { - if (std.mem.startsWith(u8, ln, "```")) continue; - out.print("{s}\n", .{std.mem.trimEnd(u8, ln, " \t\r")}) catch return; - } -} - -// ---------------------------------------------------------------- symbols - -/// `document_symbol.getDocumentSymbols` needs an arena, a tree and an encoding -/// — no store, no analyser, no intern pool. That makes `SPC s` the cheapest -/// thing in this file: a parse and a walk. -fn documentSymbols( - gpa: std.mem.Allocator, - arena: std.mem.Allocator, - path: []const u8, - tree: *const Ast, - out: *std.Io.Writer, -) !void { - const syms = try zls.document_symbol.getDocumentSymbols(arena, tree, enc); - var n: usize = 0; - try emitSymbols(gpa, arena, path, syms, "", &n, out); -} - -/// Depth-first so the rows read in source order, with the parent's name as a -/// qualifier — `Kind.jumpsWhenSingle` rather than a bare `jumpsWhenSingle`, -/// because a flat list of method names is not navigable. -fn emitSymbols( - gpa: std.mem.Allocator, - arena: std.mem.Allocator, - path: []const u8, - syms: []const types.DocumentSymbol, - prefix: []const u8, - n: *usize, - out: *std.Io.Writer, -) !void { - for (syms) |s| { - if (n.* >= max_rows) return; - n.* += 1; - const name = if (prefix.len == 0) - s.name - else - try std.fmt.allocPrint(arena, "{s}.{s}", .{ prefix, s.name }); - const text = if (s.detail) |d| - try std.fmt.allocPrint(arena, "{s} {s}", .{ name, d }) - else - name; - lsp.row(out, path, s.selectionRange.start.line, s.selectionRange.start.character, text); - if (s.children) |kids| try emitSymbols(gpa, arena, path, kids, name, n, out); - } -} - -/// `SPC S`: every `.zig` under the pane's directory, parsed, walked, filtered -/// by substring. ZLS's own workspace symbols would only see files already in -/// the DocumentStore (which is one), so this walks the tree itself — and -/// because `getDocumentSymbols` needs nothing but a tree, it never has to open -/// a store or resolve an import to do it. -fn workspaceSymbols( - gpa: std.mem.Allocator, - arena: std.mem.Allocator, - io: std.Io, - req: lsp.Req, - out: *std.Io.Writer, -) !void { - if (req.arg.len == 0) return; - var files: std.ArrayList([]const u8) = .empty; - try collectZigFiles(arena, io, req.root, &files); - var n: usize = 0; - for (files.items) |path| { - if (n >= max_rows) return; - const src = readFileZ(arena, io, path) catch continue; - var tree: Ast = Ast.parse(arena, src, .zig) catch continue; - defer tree.deinit(arena); - const syms = zls.document_symbol.getDocumentSymbols(arena, &tree, enc) catch continue; - try filterSymbols(gpa, arena, path, syms, "", req.arg, &n, out); - } -} - -fn filterSymbols( - gpa: std.mem.Allocator, - arena: std.mem.Allocator, - path: []const u8, - syms: []const types.DocumentSymbol, - prefix: []const u8, - needle: []const u8, - n: *usize, - out: *std.Io.Writer, -) !void { - for (syms) |s| { - if (n.* >= max_rows) return; - const name = if (prefix.len == 0) - s.name - else - try std.fmt.allocPrint(arena, "{s}.{s}", .{ prefix, s.name }); - if (containsIgnoreCase(s.name, needle)) { - n.* += 1; - lsp.row(out, path, s.selectionRange.start.line, s.selectionRange.start.character, name); - } - if (s.children) |kids| try filterSymbols(gpa, arena, path, kids, name, needle, n, out); - } -} - -fn containsIgnoreCase(hay: []const u8, needle: []const u8) bool { - if (needle.len == 0) return true; - if (needle.len > hay.len) return false; - var i: usize = 0; - outer: while (i + needle.len <= hay.len) : (i += 1) { - for (needle, 0..) |c, j| - if (std.ascii.toLower(hay[i + j]) != std.ascii.toLower(c)) continue :outer; - return true; - } - return false; -} - -// ------------------------------------------------------------- references - -/// `gr` / `SPC h` / `SPC r`. ZLS's `symbolReferences` is private and its -/// `referencesHandler` takes a `*Server`, so this is the same algorithm from -/// the outside: find the decl under the cursor, then offer every identifier -/// token in the file that spells the same name back to the analyser and keep -/// the ones that resolve to the same decl. Resolving rather than matching is -/// what makes a shadowed local not a reference to the outer one. -/// -/// LIMIT: this file only. A workspace-wide answer means loading every project -/// file into the store and running the analyser over each — seconds, not -/// milliseconds, on every keypress, and the store's own workspace iteration -/// has the same restriction (it can only see handles that were loaded). -/// -/// `new_name` non-null makes it a rename PREVIEW: the same rows, annotated -/// with the replacement. The seam returns rows, not edits, so `SPC r` shows -/// what would change and changes nothing — an honest half of rename. -fn references( - arena: std.mem.Allocator, - analyser: *Analyser, - handle: *DocumentStore.Handle, - off: usize, - new_name: ?[]const u8, - out: *std.Io.Writer, -) !void { - const tree = &handle.tree; - const target = try declAt(arena, analyser, handle, off) orelse return; - // The decl's name token indexes ITS OWN file, which after an alias resolve - // is not necessarily this one — and on a half-typed file it is not - // necessarily an identifier either, which `identifierTokenToNameSlice` - // asserts. Both of those are crashes on a keystroke, so both are checked. - const decl_tree = &target.handle.tree; - const name_tok = target.nameToken(); - if (name_tok >= decl_tree.tokens.len) return; - if (decl_tree.tokenTag(name_tok) != .identifier) return; - const want = offsets.identifierTokenToNameSlice(decl_tree, name_tok); - if (want.len == 0) return; - - const lines: Lines = try .build(arena, tree.source); - var n: usize = 0; - for (0..tree.tokens.len) |i| { - if (n >= max_rows) return; - const tok: Ast.TokenIndex = @intCast(i); - if (tree.tokenTag(tok) != .identifier) continue; - if (!std.mem.eql(u8, offsets.identifierTokenToNameSlice(tree, tok), want)) continue; - const at = tree.tokenStart(tok); - const d = (declAt(arena, analyser, handle, at) catch continue) orelse continue; - if (!d.eql(target)) continue; - n += 1; - const r = offsets.tokenToRange(tree, tok, enc); - const text = if (new_name) |nn| - try std.fmt.allocPrint(arena, "{s} -> {s} {s}", .{ want, nn, std.mem.trim(u8, lines.line(r.start.line), " \t") }) - else - lines.line(r.start.line); - lsp.row(out, handle.uri.toFsPath(arena) catch return, r.start.line, r.start.character, text); - } -} - -/// The decl under a byte offset, whatever the surrounding syntax is. Shared by -/// references and by rename, which is references with a label on it. -fn declAt( - arena: std.mem.Allocator, - analyser: *Analyser, - handle: *DocumentStore.Handle, - off: usize, -) !?Analyser.DeclWithHandle { - const tree = &handle.tree; - const ctx = try Analyser.getPositionContext(arena, tree, off, true); - const name_loc = offsets.identifierLocFromIndex(tree, off) orelse return null; - const name = offsets.locToSlice(tree.source, name_loc); - const d = switch (ctx) { - .var_access, .test_doctest_name => try analyser.lookupSymbolGlobal(handle, name, off), - .field_access => |loc| blk: { - const found = try analyser.getSymbolFieldAccesses(arena, handle, off, offsets.locMerge(loc, name_loc), name) orelse break :blk null; - break :blk if (found.len != 0) found[0] else null; - }, - .label_access, .label_decl => try Analyser.lookupLabel(handle, name, off), - .enum_literal => try analyser.getSymbolEnumLiteral(handle, off, name), - else => null, - } orelse return null; - return try analyser.resolveVarDeclAlias(d) orelse d; -} - -// ------------------------------------------------------------ diagnostics - -/// `SPC d` and the list `]d`/`[d` step. ZLS's `getAstCheckDiagnostics` prefers -/// spawning `zig ast-check`; its OTHER branch runs `std.zig.AstGen` in this -/// process, which is the branch a subprocess-free backend wants, so that is -/// what is inlined here (it is ten lines and needs a `*Server` only for the -/// config it reads). -/// -/// A clean file emits ONE row saying so. Silence already means "no backend" in -/// this seam — `lspResponse` opens nothing for zero rows — so a `SPC d` that -/// checked and found nothing has to be able to say the difference. -fn diagnostics( - gpa: std.mem.Allocator, - arena: std.mem.Allocator, - path: []const u8, - tree: *const Ast, - out: *std.Io.Writer, -) !void { - const n = try treeDiagnostics(gpa, arena, path, tree, out); - if (n == 0) lsp.row(out, path, 0, 0, "no diagnostics"); -} - -/// `zig ast-check`, in this process. ZLS spawns the compiler for this when it -/// has one; its fallback branch is `std.zig.AstGen` directly, and that fallback -/// is this backend's only branch. Caller owns the bundle. -fn astCheck(gpa: std.mem.Allocator, path: []const u8, tree: *const Ast) !std.zig.ErrorBundle { - if (tree.errors.len != 0) return .empty; - var zir = std.zig.AstGen.generate(gpa, tree.*) catch return .empty; - defer zir.deinit(gpa); - if (!zir.hasCompileErrors()) return .empty; - var wip: std.zig.ErrorBundle.Wip = undefined; - try wip.init(gpa); - defer wip.deinit(); - wip.addZirErrorMessages(zir, tree.*, tree.source, path) catch return .empty; - return wip.toOwnedBundle("") catch .empty; -} - -/// Parse errors first (AstGen cannot run on a tree that did not parse), then -/// the AstGen pass. Returns how many rows were emitted. -fn treeDiagnostics( - gpa: std.mem.Allocator, - arena: std.mem.Allocator, - path: []const u8, - tree: *const Ast, - out: *std.Io.Writer, -) !usize { - var n: usize = 0; - if (tree.errors.len != 0) { - const lines: Lines = try .build(arena, tree.source); - for (tree.errors) |e| { - if (n >= max_rows) return n; - var buf: std.ArrayList(u8) = .empty; - var w: std.Io.Writer.Allocating = .fromArrayList(arena, &buf); - tree.renderError(e, &w.writer) catch continue; - const at = tree.tokenStart(e.token); - const lc = lsp.lineCol(tree.source, at); - n += 1; - lsp.row(out, path, lc.line, lc.col, try std.fmt.allocPrint(arena, "error: {s} {s}", .{ - w.written(), std.mem.trim(u8, lines.line(lc.line), " \t"), - })); - } - return n; - } - - var bundle = try astCheck(gpa, path, tree); - defer bundle.deinit(gpa); - if (bundle.errorMessageCount() == 0) return n; - for (bundle.getMessages()) |m| { - if (n >= max_rows) return n; - const em = bundle.getErrorMessage(m); - if (em.src_loc == .none) continue; - const sl = bundle.getSourceLocation(em.src_loc); - n += 1; - lsp.row(out, path, sl.line, sl.column, try std.fmt.allocPrint(arena, "error: {s}", .{ - bundle.nullTerminatedString(em.msg), - })); - } - return n; -} - -/// `SPC D`: the same check over every `.zig` under the pane's directory. No -/// analyser and no store — AstGen is a per-file pass — so this is bounded by -/// how fast Zig can parse the tree, which for pardes's own `src/` is tens of -/// milliseconds. -fn workspaceDiagnostics( - gpa: std.mem.Allocator, - arena: std.mem.Allocator, - io: std.Io, - req: lsp.Req, - out: *std.Io.Writer, -) !void { - var files: std.ArrayList([]const u8) = .empty; - try collectZigFiles(arena, io, req.root, &files); - var total: usize = 0; - for (files.items) |path| { - const src = readFileZ(arena, io, path) catch continue; - var tree: Ast = Ast.parse(arena, src, .zig) catch continue; - defer tree.deinit(arena); - total += treeDiagnostics(gpa, arena, path, &tree, out) catch continue; - if (total >= max_rows) return; - } - if (total == 0) lsp.row(out, req.path, 0, 0, try std.fmt.allocPrint(arena, "no diagnostics in {d} file(s)", .{files.items.len})); -} - -// ----------------------------------------------------------- code actions - -/// `SPC a`. `code_actions.Builder` is one of the Server-free ones — it wants -/// an arena, an analyser, a handle and an ErrorBundle — so the only missing -/// piece is the bundle, which `astCheck` above produces without a compiler. -/// -/// It lists what could be done, and does none of it: the seam hands back rows, -/// not edits, so there is no channel through which a backend could apply a -/// fix. `+Lsp` is prose, so these are lines rather than locations. -fn codeActions( - gpa: std.mem.Allocator, - arena: std.mem.Allocator, - analyser: *Analyser, - handle: *DocumentStore.Handle, - off: usize, - out: *std.Io.Writer, -) !void { - const tree = &handle.tree; - if (tree.errors.len != 0) { - out.print("no code actions: file does not parse\n", .{}) catch {}; - return; - } - var bundle = try astCheck(gpa, "", tree); - defer bundle.deinit(gpa); - - var builder: zls.code_actions.Builder = .{ - .arena = arena, - .analyser = analyser, - .handle = handle, - .offset_encoding = enc, - .only_kinds = null, - }; - builder.generateCodeAction(bundle) catch {}; - const at = offsets.locToRange(tree.source, .{ .start = off, .end = off }, enc); - builder.generateCodeActionsInRange(at) catch {}; - - for (builder.actions.items) |a| { - out.print("{s}\n", .{a.title}) catch return; - } - if (builder.actions.items.len == 0) out.print("no code actions\n", .{}) catch {}; -} - -// ---------------------------------------------------------------- format - -/// `=`. The seam returns ROWS, not edits — there is no channel through which a -/// backend can rewrite the buffer — so format reports rather than formats: one -/// row per line `zig fmt` would change, in the `+Lsp` buffer. That is the -/// honest shape of "format" behind a read-only seam, and it is still the -/// answer to the question the keypress asks. -fn formatQuery(arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !void { - var tree: Ast = try .parse(arena, req.source, .zig); - defer tree.deinit(arena); - if (tree.errors.len != 0) { - lsp.row(out, req.path, 0, 0, "cannot format: file does not parse"); - return; - } - var buf: std.ArrayList(u8) = .empty; - var w: std.Io.Writer.Allocating = .fromArrayList(arena, &buf); - tree.render(arena, &w.writer, .{}) catch return; - const formatted = w.written(); - if (std.mem.eql(u8, formatted, req.source)) { - lsp.row(out, req.path, 0, 0, "already formatted"); - return; - } - var a = std.mem.splitScalar(u8, req.source, '\n'); - var b = std.mem.splitScalar(u8, formatted, '\n'); - var ln: usize = 0; - var n: usize = 0; - while (n < max_rows) : (ln += 1) { - const x = a.next(); - const y = b.next(); - if (x == null and y == null) break; - const xs = x orelse ""; - const ys = y orelse ""; - if (std.mem.eql(u8, xs, ys)) continue; - n += 1; - lsp.row(out, req.path, ln, 0, try std.fmt.allocPrint(arena, "- {s} + {s}", .{ - std.mem.trim(u8, xs, " \t"), std.mem.trim(u8, ys, " \t"), - })); - } -} - -// ------------------------------------------------------------------ files - -/// Every `.zig` under `root`, skipping the directories a source walk has no -/// business in. Capped, because this runs on a keypress. -fn collectZigFiles(arena: std.mem.Allocator, io: std.Io, root: []const u8, out: *std.ArrayList([]const u8)) !void { - if (root.len == 0) return; - var dir = std.Io.Dir.cwd().openDir(io, root, .{ .iterate = true }) catch return; - defer dir.close(io); - var w = dir.walkSelectively(arena) catch return; - defer w.deinit(); - while (out.items.len < max_files) { - const e = (w.next(io) catch break) orelse break; - switch (e.kind) { - .directory => { - if (e.basename.len != 0 and e.basename[0] == '.') continue; - if (std.mem.eql(u8, e.basename, "zig-out")) continue; - if (std.mem.eql(u8, e.basename, "zig-cache")) continue; - w.enter(io, e) catch {}; - }, - .file => { - if (!std.mem.endsWith(u8, e.basename, ".zig")) continue; - const p = std.fs.path.join(arena, &.{ root, e.path }) catch continue; - try out.append(arena, p); - }, - else => {}, - } - } -} - -fn readFileZ(arena: std.mem.Allocator, io: std.Io, path: []const u8) ![:0]u8 { - return std.Io.Dir.cwd().readFileAllocOptions(io, path, arena, .limited(4 * 1024 * 1024), .of(u8), 0); -} diff --git a/src/main.zig b/src/main.zig index 61c0049e..436cf938 100644 --- a/src/main.zig +++ b/src/main.zig @@ -100,7 +100,7 @@ fn emscriptenMain(argc: c_int, argv: [*]?[*:0]u8) callconv(.c) c_int { // no argv in the browser: options stay default, state comes from the dump fn webMain() !void { - try @import("gui.zig").run(.{}); + try @import("gui/gui.zig").run(.{}); } fn nativeMain(init: std.process.Init) !void { @@ -153,8 +153,8 @@ fn nativeMain(init: std.process.Init) !void { } } switch (pardes.platform) { - .tty => try @import("tty.zig").run(init, opts), - .gui => try @import("gui.zig").run(init, opts), + .tty => try @import("tty/tty.zig").run(init, opts), + .gui => try @import("gui/gui.zig").run(init, opts), .web => unreachable, // web enters through webMain, never here } } diff --git a/src/output_pane.zig b/src/output_pane.zig new file mode 100644 index 00000000..4535495c --- /dev/null +++ b/src/output_pane.zig @@ -0,0 +1,72 @@ +//! Output panes: acme's +Errors, a file pane with no file behind it, holding +//! text the core produced itself (+Search results, +Help, the LSP answer +//! buffers). The ONLY thing that makes one different from a file pane is that +//! it cannot be saved — every mode, motion, chord and look works because it IS +//! a file pane, and that reuse is the point. +//! +//! Thin on purpose, for now. The `f.output` tests still scattered through +//! pardes.zig each ask a one-line question inside kind-agnostic code (does +//! this pane claim a column? can it be saved? is it a doc?) and pulling them +//! here would trade one line for a call. What belongs here is what an output +//! pane KNOWS about itself, and today that is just how one is opened. +const std = @import("std"); +const pardes = @import("pardes.zig"); +const Pardes = pardes.Pardes; +const Pane = pardes.Pane; +const file_pane = @import("file_pane.zig"); + +/// `content` is gpa-owned and adopted. The name carries `dir` so looks inside +/// the buffer resolve like anywhere else. +pub fn open(p: *Pardes, id: usize, dir: []const u8, name: []const u8, content: []u8) !*Pane { + const path = try std.fmt.allocPrint(p.gpa, "{s}/{s}", .{ std.mem.trimEnd(u8, dir, "/"), name }); + errdefer p.gpa.free(path); + const pane = try p.newDocPane(id); + pane.file = .{ .path = path, .content = content, .output = true }; + pane.cur_pinned = true; + return pane; +} + +/// The Help builtin: every builtin reachable from `prefix` (empty = all +/// of them) with the keys that run it, written into an output buffer +/// (acme's +Errors) — ordinary text, so the names in it are LIVE: middle- +/// click `Tutor` there and the tutor opens. Reuses the open +Help buffer +/// instead of piling panes up, and focus follows: you asked to read it. +pub fn openHelp(p: *Pardes, id: usize, prefix: []const u8) void { + const pane = p.panes[id] orelse return; + const arena = p.scratch.allocator(); + var out: std.ArrayList(u8) = .empty; + if (prefix.len == 0) { + out.appendSlice(arena, "pardes builtins - SPC then the keys,\nor execute the name anywhere\n\n") catch return; + } else { + out.appendSlice(arena, "pardes builtins under SPC") catch return; + for (prefix) |c| out.appendSlice(arena, &[_]u8{ ' ', c }) catch return; + out.appendSlice(arena, "\n\n") catch return; + } + for (pardes.leader_rows) |row| { + if (!std.mem.startsWith(u8, row.path, prefix)) continue; + out.appendSlice(arena, row.line) catch return; + out.append(arena, '\n') catch return; + } + const content = p.gpa.dupe(u8, out.items) catch return; + for (p.panes, 0..) |slot, i| { + const hp = slot orelse continue; + const hf = if (hp.file) |*f| f else continue; + if (!hf.output or !std.mem.endsWith(u8, hf.path, "/+Help")) continue; + file_pane.setContent(p, hf, content); + hf.scroll = 0; + hp.cur_row = 0; + hp.msel.active = false; + p.active = i; + return; + } + const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); + const free = p.freeSlot() orelse { + p.gpa.free(content); + return; + }; + const np = open(p, free, dir, "+Help", content) catch { + p.gpa.free(content); + return; + }; + p.placeDoc(id, free, np); +} diff --git a/src/pardes.zig b/src/pardes.zig index 07120954..f6168449 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -8,6 +8,10 @@ //! //! Platform divergence inside the core is the `platform` comptime tag, used //! the way the stdlib uses os.tag. Click-on-text semantics live in look.zig. +//! +//! src/ layout: the core lies flat at src/, and every SUBDIRECTORY is one +//! backend (tty/ gui/ lsp/) — so a file being in no directory at all is what +//! says it is core, and nothing needs a header to claim it. const std = @import("std"); const ghostty_vt = @import("ghostty-vt"); const uucode = @import("uucode"); @@ -15,12 +19,13 @@ const modal = @import("modal.zig"); const look = @import("look.zig"); const syntax = @import("syntax.zig"); const tracy = @import("tracy.zig"); +const term_pane = @import("term_pane.zig"); +const file_pane = @import("file_pane.zig"); +const output_pane = @import("output_pane.zig"); +const builtins = @import("builtins.zig"); pub const image = @import("image.zig"); pub const dump = @import("dump.zig"); -pub const lsp = @import("lsp.zig"); - -const SYNTAX_CONTEXT_BEFORE_ROWS: usize = 0; -const SYNTAX_CONTEXT_AFTER_ROWS: usize = 2; +pub const lsp = @import("lsp/lsp.zig"); pub const Platform = enum { tty, gui, web }; pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config").platform)); @@ -54,74 +59,15 @@ const file_pane_tail = " " ++ file_pane_builtins_str; // Builtins: executing the name (middle-click / Tab) runs it through the ONE // dispatcher (runBuiltin, reached from actOnSelection), no matter where the -// name appears. The enum FIELD NAME is the user-visible word — the one in the -// topbar, the one sitting in a tag, the one Help prints, the one you execute -// — so `std.meta.stringToEnum` IS the lookup and there is no name table to -// keep in sync. Everything else about a builtin (its leader key path, its -// Help line) is folded out of this enum at comptime, below. A name that is -// not a legal identifier would be spelled `@"..."`; zig fmt strips the -// quotes from the ones that are. -const Builtin = enum { - Kill, - Newcol, - Tutor, - Debug, - Colors, - NextColor, - Crt, - // the image pane's three renderer toggles. They used to be words the - // image tag printed and actOnSelection matched by hand; as builtins they - // are executable anywhere, pressable under SPC and listed by `SPC ?`, - // which is the whole reason the tag no longer carries them. Petscii picks - // glyph art over the host's pixels, Palette swaps the C64 palette for the - // terminal's own 16, Ascii adds the printable ASCII bitmaps to the - // matcher's glyph set. - Petscii, - Palette, - Ascii, - Dump, - Restore, - Save, - Find, - // Find's sibling: Find matches file NAMES under this pane's directory, - // Grep matches file CONTENTS under every pane's directory at once. - Grep, - Del, - Delcol, - Help, - // the window group. The DESTINATION is the name — a word, the way a tag - // holds Del or Save — because these names live in the same vocabulary as - // everything else here: `Wh` would be a leader key path leaking into the - // text you can middle-click. Plain English words are safe for exactly - // these five: focus is the cheapest thing to change by accident (nothing - // is edited, closed or written) and the way back is the opposite word. - Left, - Down, - Up, - Right, - Toggleterm, - // The language group, reached as `SPC l ` — see - // leader_path for why the prefix exists. They are builtins rather than - // bare keys for the same reason Save is one: the word is executable - // wherever it appears, so a middle-click on `Hover` in a tag does what - // `SPC l k` does. The five GOTOS are not here — helix binds them under `g` - // as motions, and a motion has no business being a word you can click. - Hover, - Rename, - CodeAction, - SelectRefs, - Symbols, - WsSymbols, - Diagnostics, - WsDiagnostics, - // Introspection. A language backend that answers nothing looks exactly - // like one that is broken — from the outside, `gd` doing nothing is both - // "there is no definition" and "the analyser threw and we swallowed it". - // These two are how you tell: Lspinfo says what the backend IS, Lspwhy - // says what it just DID and where it stopped. - Lspinfo, - Lspwhy, -}; +// name appears. One STRUCT per builtin in builtins.zig — name, comment and +// body in one place — and this enum is folded out of THAT FILE'S declarations +// at comptime, so the enum FIELD NAME is still the user-visible word (the one +// in the topbar, the one sitting in a tag, the one Help prints, the one you +// execute) and `std.meta.stringToEnum` is still the lookup with no name table +// to keep in sync. It lands here rather than in builtins.zig because a +// container cannot hold a decl folded out of its own decl list, and here it +// sits with the other two comptime folds (leader_rows, the topbar check). +const Builtin = builtins.Builtin(); /// SPC leader: ONE key path per builtin, the whole remapping surface. An /// EnumArray, so a new enum field is a compile error until it has a path. @@ -192,7 +138,7 @@ const leader_path = std.EnumArray(Builtin, []const u8).init(.{ /// for thirteen two-character paths would be ceremony. `line` is the Help /// rendering, padded here so Help is a filter over an array of lines. const Leader = struct { path: []const u8, cmd: Builtin, line: []const u8 }; -const leader_rows: [@typeInfo(Builtin).@"enum".fields.len]Leader = blk: { +pub const leader_rows: [@typeInfo(Builtin).@"enum".fields.len]Leader = blk: { // the insertion sort below is O(n²) comptime branches, and n grew with the // language group @setEvalBranchQuota(8000); @@ -243,7 +189,7 @@ const leader_rows: [@typeInfo(Builtin).@"enum".fields.len]Leader = blk: { // reason it earns the width. Appended rather than inserted so every existing // word keeps its column and no golden's click coordinates move. comptime { - @setEvalBranchQuota(8000); // the tokenizer walks the string one branch per byte, stringToEnum builds a map per name + @setEvalBranchQuota(20000); // the tokenizer walks the string one branch per byte, stringToEnum builds a map per name, and both sit behind the decl walk that folds Builtin var it = std.mem.tokenizeScalar(u8, topbar_str, ' '); while (it.next()) |w| { if (std.meta.stringToEnum(Builtin, w) == null) @compileError("topbar word is not a builtin: " ++ w); @@ -260,10 +206,6 @@ fn wordAtCol(str: []const u8, col: u16) []const u8 { return str[lo..hi]; } -fn fileLineCount(content: []const u8) usize { - return std.mem.count(u8, content, "\n") + 1; -} - /// `a` IS `b` or lives under it — how Grep drops a pane's directory when /// another pane's directory already covers it fn pathUnder(a: []const u8, b: []const u8) bool { @@ -854,7 +796,7 @@ pub const Pane = struct { return p.cwd[0..p.cwd_len]; } - fn isTerminal(pane: *const Pane) bool { + pub fn isTerminal(pane: *const Pane) bool { return pane.file == null and pane.image == null; } @@ -862,7 +804,7 @@ pub const Pane = struct { /// `rows` shell rows it covers, so everything below it slides by the /// difference — the identity on files and on terminals nobody has typed /// a newline into, which is why the rest of the row math can stay naive. - fn surfRow(pane: *const Pane, g: i32) i32 { + pub fn surfRow(pane: *const Pane, g: i32) i32 { const o = pane.ovl orelse return g; if (g <= o.row) return g; const lines: i32 = @intCast(modal.lineCount(o.text)); @@ -871,7 +813,7 @@ pub const Pane = struct { } /// the inverse; every surface row inside the edit buffer maps to its anchor - fn gridRow(pane: *const Pane, s: i32) i32 { + pub fn gridRow(pane: *const Pane, s: i32) i32 { const o = pane.ovl orelse return s; if (s <= o.row) return s; const lines: i32 = @intCast(modal.lineCount(o.text)); @@ -880,7 +822,7 @@ pub const Pane = struct { } /// current scroll offset: file top line, or the scrollback offset - fn scroll(pane: *Pane) i32 { + pub fn scroll(pane: *Pane) i32 { if (pane.file) |f| return @intCast(f.scroll); return pane.surfRow(@intCast(pane.vt.screens.active.pages.scrollbar().offset)); } @@ -901,7 +843,7 @@ pub const Pane = struct { } } - fn ensureCursorVisible(pane: *Pane) void { + pub fn ensureCursorVisible(pane: *Pane) void { // scrolloff margin, shrunk on short panes so the band stays non-empty const margin: i32 = @min(scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); const off = pane.scroll(); @@ -962,7 +904,7 @@ const Drag = union(enum) { select: struct { id: usize, button: Mouse.Button, chorded: bool = false, ctrl: bool = false }, }; -const Rect = struct { x: u16, y: u16, w: u16, h: u16 }; +pub const Rect = struct { x: u16, y: u16, w: u16, h: u16 }; pub const Options = struct { tty_only: bool = false, @@ -1080,7 +1022,7 @@ pub const Pardes = struct { _ = if (look.isImagePath(path)) try p.openImageView(0, path, !p.kitty_ok) else - try p.openFileView(0, path, opts.file_line); + try file_pane.open(p, 0, path, opts.file_line); const sh = try p.newPane(1); sh.greet = true; p.ncol = 2; @@ -1157,7 +1099,7 @@ pub const Pardes = struct { return topbar_str; } - fn deinitPane(p: *Pardes, pane: *Pane) void { + pub fn deinitPane(p: *Pardes, pane: *Pane) void { if (pane.image) |*iv| { p.gpa.free(iv.path); if (iv.rgba.len > 0) p.gpa.free(iv.rgba); @@ -1188,11 +1130,11 @@ pub const Pardes = struct { /// Allocate a pane slot with a live terminal emulator and queue the spawn /// effect (optionally in a directory); the shell answers by forking a pty /// and wiring reads back as Event.output for this pane id. - fn newPane(p: *Pardes, id: usize) !*Pane { + pub fn newPane(p: *Pardes, id: usize) !*Pane { return p.newShell(id, ""); } - fn newShell(p: *Pardes, id: usize, cwd: []const u8) !*Pane { + pub fn newShell(p: *Pardes, id: usize, cwd: []const u8) !*Pane { std.debug.assert(p.panes[id] == null); if (cwd.len > 256) return error.PathTooLong; // spawn effect cwd is a Buf(256) const pane = try p.gpa.create(Pane); @@ -1209,8 +1151,8 @@ pub const Pardes = struct { pane.stream = pane.vt.vtStream(); // answer terminal queries (DSR/DA/kitty) back into the pty, else // crossterm apps (nushell, helix, fzf) block on the reply forever. - pane.stream.handler.effects.write_pty = ptyReport; - pane.stream.handler.effects.device_attributes = ptyDeviceAttrs; + pane.stream.handler.effects.write_pty = term_pane.ptyReport; + pane.stream.handler.effects.device_attributes = term_pane.ptyDeviceAttrs; p.panes[id] = pane; p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(cwd) } }); return pane; @@ -1218,7 +1160,7 @@ pub const Pardes = struct { /// a doc pane (file/image): no pty, no spawn; a stub 1x1 emulator only /// because the shared machinery touches its allocator-owned bits. - fn newDocPane(p: *Pardes, id: usize) !*Pane { + pub fn newDocPane(p: *Pardes, id: usize) !*Pane { std.debug.assert(p.panes[id] == null); const pane = try p.gpa.create(Pane); pane.* = .{ @@ -1232,14 +1174,14 @@ pub const Pardes = struct { return pane; } - fn freeSlot(p: *Pardes) ?usize { + pub fn freeSlot(p: *Pardes) ?usize { return for (p.panes, 0..) |slot, i| { if (slot == null) break i; } else null; } /// chunk arbitrary-length bytes into fixed-size write effects (order kept) - fn emitWrite(p: *Pardes, id: usize, bytes: []const u8) void { + pub fn emitWrite(p: *Pardes, id: usize, bytes: []const u8) void { var off: usize = 0; while (off < bytes.len) { const n = @min(bytes.len - off, 64); @@ -1257,7 +1199,7 @@ pub const Pardes = struct { pane.cwd_len = @intCast(n); } - fn emit(p: *Pardes, e: Effect) void { + pub fn emit(p: *Pardes, e: Effect) void { p.effects.append(p.gpa, e) catch {}; } @@ -1895,7 +1837,7 @@ pub const Pardes = struct { p.leader_on = false; // only "still a prefix" below re-arms it if (key.ctrl or key.alt or key.cp < 0x20 or key.cp > 0x7e) return; const c: u8 = @intCast(key.cp); - if (c == '?') return p.openHelp(p.active, p.leader_keys[0..p.leader_n]); + if (c == '?') return output_pane.openHelp(p, p.active, p.leader_keys[0..p.leader_n]); if (p.leader_n >= p.leader_keys.len) return; p.leader_keys[p.leader_n] = c; p.leader_n += 1; @@ -1951,7 +1893,7 @@ pub const Pardes = struct { /// move focus to the nearest pane in `dir` of `from` (overlap-aware /// nearest edge). `from` is the pane the builtin ran on, which is the /// active one for a key but the CLICKED one for a name executed in a tag. - fn focusDir(p: *Pardes, from: usize, dir: enum { left, right, up, down }) void { + pub fn focusDir(p: *Pardes, from: usize, dir: enum { left, right, up, down }) void { const a = p.rects[from]; var best: ?usize = null; var best_d: i32 = 0; @@ -2101,32 +2043,6 @@ pub const Pardes = struct { row0: i32, // absolute row of lines[0] }; - /// A terminal's shell rows as the surface sees them: the WHOLE - /// history+active grid, prompt rows blanked (OSC 133), absolute grid rows - /// from 0. The raw material the motion surface is composed from — the - /// edit buffer is NOT applied here, so it is also what seeding the buffer - /// reads. Scratch-arena backed. - /// ghostty's dump trims the grid's trailing blank rows; ONE of them is - /// kept back, the row the cursor sits on below the last line of output. - /// That row is a file's final newline: without it the surface would have - /// one line fewer than the same text in a document, and every motion and - /// linewise edit at the bottom would diverge. - /// ponytail: O(scrollback) dump+scan per keystroke — window it around the - /// viewport if a multi-MB scrollback ever makes navigation lag. - fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 { - const arena = p.scratch.allocator(); - const full = try pane.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} }); - var pit = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); - var ls: std.ArrayList([]const u8) = .empty; - var it = std.mem.splitAny(u8, full, "\n"); - while (it.next()) |raw| { - const is_prompt = if (pit.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false; - try ls.append(arena, if (is_prompt) "" else raw); - } - try ls.append(arena, ""); - return ls.items; - } - /// The lines the cursor moves over, absolute rows. File: all content lines. /// Terminal: the whole history+active grid with the edit buffer's lines /// standing in for the rows it covers, so motions ride the scrollback and @@ -2141,7 +2057,7 @@ pub const Pardes = struct { while (it.next()) |ln| try ls.append(arena, ln); return .{ .lines = ls.items, .row0 = 0 }; } - const rows = try p.shellRows(pane); + const rows = try term_pane.shellRows(p, pane); // the buffer may sit below the dump's last row (the grid's trailing // blanks are trimmed off), so run the loop to whichever ends later var last = rows.len; @@ -2741,18 +2657,18 @@ pub const Pardes = struct { /// of pardes carrying a second piece of pane state for two commands that /// differ in nothing but where the rows come from. const search_marker = " /"; - const find_marker = " Find /"; - const grep_marker = " Grep /"; + pub const find_marker = " Find /"; + pub const grep_marker = " Grep /"; // the two language queries that need a word typed: same tag input, same // Enter, and the marker is still what says which one is armed - const rename_marker = " Rename /"; - const symbol_marker = " WsSymbols /"; + pub const rename_marker = " Rename /"; + pub const symbol_marker = " WsSymbols /"; const Search = enum { text, find, grep }; /// `/` (and the Find builtin) on any pane: type the pattern into the tag /// tail after `marker` — the existing modal one-line editor, visible while /// typing, nothing that disappears. Enter/Esc are intercepted in handleKey. - fn startSearch(p: *Pardes, pane: *Pane, marker: []const u8) void { + pub fn startSearch(p: *Pardes, pane: *Pane, marker: []const u8) void { if (!pane.tag_init) { pane.tag_tail.appendSlice(p.gpa, curTail(pane)) catch return; pane.tag_init = true; @@ -2804,7 +2720,7 @@ pub const Pardes = struct { /// walked like `grep -R`. Rows are `path:LINE:COL text`, the path /// relative to THIS pane's directory (absolute for a hit outside it). /// No matches = an empty buffer. - fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search) void { + pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search) void { const pane = p.panes[id] orelse return; const pat = std.mem.trim(u8, pat_raw, " \t\r\n"); if (pat.len == 0) return; @@ -2872,7 +2788,7 @@ pub const Pardes = struct { p.gpa.free(content); return; }; - const np = p.openOutputView(free, dir, "+Search", content) catch { + const np = output_pane.open(p, free, dir, "+Search", content) catch { p.gpa.free(content); return; }; @@ -2925,7 +2841,7 @@ pub const Pardes = struct { /// output buffer is our own text, neither of which has a language behind /// it. Unsupported kinds never get here (the keymap drops them), so a /// backend that answers nothing simply never opens a buffer. - fn lspRequest(p: *Pardes, id: usize, kind: lsp.Kind, arg: []const u8) void { + pub fn lspRequest(p: *Pardes, id: usize, kind: lsp.Kind, arg: []const u8) void { if (!lsp.supports.contains(kind)) return; const pane = p.panes[id] orelse return; // `status` is about the BACKEND, not about a document, so it answers @@ -2985,7 +2901,7 @@ pub const Pardes = struct { p.gpa.free(content); return; }; - const np = p.openOutputView(free, dir, w.kind.bufferName(), content) catch { + const np = output_pane.open(p, free, dir, w.kind.bufferName(), content) catch { p.gpa.free(content); return; }; @@ -3078,7 +2994,7 @@ pub const Pardes = struct { const up = old.row - want_lo; // shell rows to prepend const down = want_hi - (old.row + lines - 1); // surface rows past its end if (fresh or up > 0 or down > 0) { - const rows = p.shellRows(pane) catch return null; + const rows = term_pane.shellRows(p, pane) catch return null; var buf: std.ArrayList(u8) = .empty; defer buf.deinit(p.gpa); const row0 = old.row - @max(0, up); @@ -3128,7 +3044,7 @@ pub const Pardes = struct { /// install a rewritten editable text (frees the old one) fn setEditText(p: *Pardes, pane: *Pane, new: []u8) void { - if (pane.file) |*f| return p.setFileContent(f, new); + if (pane.file) |*f| return file_pane.setContent(p, f, new); if (pane.ovl) |*o| { p.gpa.free(o.text); o.text = new; @@ -4161,12 +4077,6 @@ pub const Pardes = struct { // ---- dumb undo/redo: whole-state snapshots, one per edit op ---- - fn setFileContent(p: *Pardes, f: *File, new: []u8) void { - p.gpa.free(f.content); - f.content = new; - f.syntax_dirty = true; - } - /// pull the cursor back inside `text` after a rewrite; `row0` is the /// absolute surface row of its first line (0 for a file) fn clampCursor(pane: *Pane, text: []const u8, row0: i32) void { @@ -4204,7 +4114,7 @@ pub const Pardes = struct { o.rows == cur.rows and std.mem.eql(u8, o.text, cur.text) else pane.ovl == null; if (same) return; } - const snap = p.snapTerm(pane) orelse return; + const snap = term_pane.snap(p, pane) orelse return; pane.ed_undo.append(p.gpa, snap) catch { if (snap.ovl) |o| p.gpa.free(o.text); return; @@ -4232,17 +4142,17 @@ pub const Pardes = struct { return; }; const prev = f.undo.pop() orelse return; - p.setFileContent(f, prev.content); - restoreFileSnap(pane, f, prev); + file_pane.setContent(p, f, prev.content); + file_pane.restoreSnap(pane, f, prev); return; } if (pane.ed_undo.items.len == 0) return; - const cur = p.snapTerm(pane) orelse return; + const cur = term_pane.snap(p, pane) orelse return; pane.ed_redo.append(p.gpa, cur) catch { if (cur.ovl) |o| p.gpa.free(o.text); return; }; - p.restoreTerm(pane, pane.ed_undo.pop() orelse return); + term_pane.restoreSnap(p, pane, pane.ed_undo.pop() orelse return); } fn doRedo(p: *Pardes, pane: *Pane) void { @@ -4259,50 +4169,17 @@ pub const Pardes = struct { return; }; const next = f.redo.pop() orelse return; - p.setFileContent(f, next.content); - restoreFileSnap(pane, f, next); + file_pane.setContent(p, f, next.content); + file_pane.restoreSnap(pane, f, next); return; } if (pane.ed_redo.items.len == 0) return; - const cur = p.snapTerm(pane) orelse return; + const cur = term_pane.snap(p, pane) orelse return; pane.ed_undo.append(p.gpa, cur) catch { if (cur.ovl) |o| p.gpa.free(o.text); return; }; - p.restoreTerm(pane, pane.ed_redo.pop() orelse return); - } - - /// undo/redo restores the selection recorded with the snapshot (helix - /// keeps selections in its history transactions) - fn restoreFileSnap(pane: *Pane, f: *File, snap: FileSnap) void { - const n = modal.lineCount(f.content); - const row: usize = @min(@as(usize, @intCast(@max(0, snap.cur_row))), if (n == 0) 0 else n - 1); - const llen = modal.lineSlice(f.content, row).len; - pane.cur_row = @intCast(row); - pane.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, snap.cur_col))), llen)); - pane.vsel = snap.vsel; - pane.msel.active = false; - pane.cur_pinned = true; - pane.sticky_col = -1; - pane.ensureCursorVisible(); - } - - /// the snapshot takes ownership of a COPY of the edit buffer's text - fn snapTerm(p: *Pardes, pane: *Pane) ?EditSnap { - var ovl: ?Ovl = null; - if (pane.ovl) |o| ovl = .{ .row = o.row, .rows = o.rows, .text = p.gpa.dupe(u8, o.text) catch return null }; - return .{ .ovl = ovl, .cur_row = pane.cur_row, .cur_col = pane.cur_col, .vsel = pane.vsel }; - } - - fn restoreTerm(p: *Pardes, pane: *Pane, snap: EditSnap) void { - if (pane.ovl) |o| p.gpa.free(o.text); - pane.ovl = snap.ovl; - pane.cur_row = snap.cur_row; - pane.cur_col = snap.cur_col; - pane.cur_pinned = true; - pane.vsel = snap.vsel; - pane.msel.active = false; - pane.ensureCursorVisible(); + term_pane.restoreSnap(p, pane, pane.ed_redo.pop() orelse return); } fn handleMouse(p: *Pardes, m: Mouse) void { @@ -4715,7 +4592,7 @@ pub const Pardes = struct { const at = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }; p.pushUndo(pane); const new = modal.insertAt(p.gpa, f.content, at, y) catch return; - p.setFileContent(f, new); + file_pane.setContent(p, f, new); pane.vsel = .{ .active = y.len > 1, .row = @intCast(at.row), .col = @intCast(at.col), .explicit = false }; const end = modal.advanceBy(at, y); if (end.col > 0) { @@ -4773,7 +4650,7 @@ pub const Pardes = struct { // ---- layout surgery ---- - fn layoutFindTerm(p: *Pardes, id: usize) ?struct { col: usize, idx: usize } { + pub fn layoutFindTerm(p: *Pardes, id: usize) ?struct { col: usize, idx: usize } { for (0..p.ncol) |c| { for (0..p.col_n[c]) |k| { if (p.col_terms[c][k] == id) return .{ .col = c, .idx = k }; @@ -4791,7 +4668,7 @@ pub const Pardes = struct { /// Remove from the layout. An emptied column hands its width to a neighbor /// (else every surviving column reflows sideways) before shifting down. - fn layoutRemove(p: *Pardes, id: usize) void { + pub fn layoutRemove(p: *Pardes, id: usize) void { const f = p.layoutFindTerm(id) orelse return; const c = f.col; var k = f.idx; @@ -4809,7 +4686,7 @@ pub const Pardes = struct { } } - fn layoutAppendColumn(p: *Pardes, id: usize) void { + pub fn layoutAppendColumn(p: *Pardes, id: usize) void { p.layoutInsertColumn(p.ncol, id); } @@ -4845,7 +4722,7 @@ pub const Pardes = struct { /// the topmost) so the rest of the column keeps its sizes bit-identical — /// the deletion mirror of splitBelow. Call while `id` is still in the /// layout, with rects current. - fn absorbVWeight(p: *Pardes, id: usize) void { + pub fn absorbVWeight(p: *Pardes, id: usize) void { const f = p.layoutFindTerm(id) orelse return; if (p.col_n[f.col] <= 1) return; p.snapColWeights(f.col); @@ -4907,7 +4784,7 @@ pub const Pardes = struct { if (nw.file) |f| if (f.output) { // trimmed: every row ends in a newline, and the empty line after // the last one is not a result - const want: f32 = @floatFromInt(BOX_H + fileLineCount(std.mem.trimEnd(u8, f.content, "\n"))); + const want: f32 = @floatFromInt(BOX_H + file_pane.lineCount(std.mem.trimEnd(u8, f.content, "\n"))); if (want < nw.vweight) { src.vweight += nw.vweight - want; nw.vweight = want; @@ -4968,52 +4845,9 @@ pub const Pardes = struct { return free; } - /// executing at a prompt with typed text below it: pad the output area - /// with newlines so the command's output doesn't overwrite the buffer - fn padOutputBelowEdits(p: *Pardes, id: usize) void { - const pane = p.panes[id] orelse return; - const o = pane.ovl orelse return; - if (!pane.isTerminal()) return; - if (!pane.vt.cursorIsAtPrompt()) return; - // the buffer's LAST surface row: its lines may outnumber the shell - // rows it covers, and it is the bottom one output must clear - const max_row = o.row + @as(i32, @intCast(modal.lineCount(o.text))) - 1; - const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); - const cursor_abs = pane.surfRow(goff + @as(i32, @intCast(pane.vt.screens.active.cursor.y))); - const pad = std.math.clamp(max_row - cursor_abs, 0, @as(i32, pane.rows)); - var i: i32 = 0; - while (i < pad) : (i += 1) p.emitWrite(id, "\r"); - } - // ---- doc panes ---- - fn openFileView(p: *Pardes, id: usize, path: []const u8, line: usize) !*Pane { - const content = try look.readFile(p.gpa, path); - errdefer p.gpa.free(content); - const path_copy = try p.gpa.dupe(u8, path); - errdefer p.gpa.free(path_copy); - const pane = try p.newDocPane(id); - const total = fileLineCount(content); - const scroll: usize = if (line > 0 and line <= total) line - 1 else 0; - pane.file = .{ .path = path_copy, .content = content, .scroll = scroll }; - pane.cur_pinned = true; - pane.cur_row = @intCast(scroll); - return pane; - } - - /// An output buffer (acme's +Errors): a file pane holding text the core - /// produced, with no file behind it. `content` is gpa-owned and adopted. - /// The name carries `dir` so looks inside it resolve like anywhere else. - fn openOutputView(p: *Pardes, id: usize, dir: []const u8, name: []const u8, content: []u8) !*Pane { - const path = try std.fmt.allocPrint(p.gpa, "{s}/{s}", .{ std.mem.trimEnd(u8, dir, "/"), name }); - errdefer p.gpa.free(path); - const pane = try p.newDocPane(id); - pane.file = .{ .path = path, .content = content, .output = true }; - pane.cur_pinned = true; - return pane; - } - - fn openTutorView(p: *Pardes, id: usize) !*Pane { + pub fn openTutorView(p: *Pardes, id: usize) !*Pane { const content = try p.gpa.dupe(u8, tutor_text); errdefer p.gpa.free(content); const path = try p.gpa.dupe(u8, "/Tutor"); @@ -5026,7 +4860,7 @@ pub const Pardes = struct { /// TEST-ONLY, called by test/hxdiff.zig (the helix differential harness): /// swap the tty_only boot pane for a file pane holding `content` verbatim - /// — openFileView minus the disk read (cases carry their buffer inline). + /// — file_pane.open minus the disk read (cases carry their buffer inline). /// Unreachable from any shell; keep it dumb. pub fn hxOpenFileContent(p: *Pardes, content: []const u8) !*Pane { const copy = try p.gpa.dupe(u8, content); @@ -5082,7 +4916,7 @@ pub const Pardes = struct { p.active = id; if (line == 0) return; if (pane.file) |*f| { - if (line > fileLineCount(f.content)) return; + if (line > file_pane.lineCount(f.content)) return; const next = (line - 1) -| pane.rows / 2; // center, clamp at top if (next != f.scroll) { f.scroll = next; @@ -5200,7 +5034,7 @@ pub const Pardes = struct { // focus an existing pane on this path (rescrolled), else open if (p.focusPaneByPath(target.path, target.line, target.col)) return; const free = p.freeSlot() orelse return; - const nt = p.openFileView(free, target.path, target.line) catch return; + const nt = file_pane.open(p, free, target.path, target.line) catch return; if (target.col > 0) nt.cur_col = @intCast(target.col - 1); p.placeDoc(id, free, nt); // center the target line: the pane's real body height only @@ -5249,7 +5083,7 @@ pub const Pardes = struct { else id; if (dst) |di| { - p.padOutputBelowEdits(di); + term_pane.padOutputBelowEdits(p, di); p.emitWrite(di, cmd); // a chord argument (kept left selection) rides as a trailing CLI // argument @@ -5271,203 +5105,12 @@ pub const Pardes = struct { /// leader passes "" and null: a key path names a builtin, never an /// argument. The topbar builtins are global; the pane-scoped ones (Save, /// Del, Delcol, the image toggles, and the window group, which moves focus - /// relative to `id`) act on `id`. + /// relative to `id`) act on `id`. The null-pane check is the one guard + /// every builtin used to share, so it stays here rather than in each. fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]const u8) void { const pane = p.panes[id] orelse return; - switch (b) { - .Debug => p.show_debug = !p.show_debug, - .Colors => p.colors_on = !p.colors_on, - .NextColor => p.theme_idx = (p.theme_idx + 1) % themes.len, - .Crt => p.crt_on = !p.crt_on, - // the image toggles act on the pane they run in and are inert - // anywhere else, the way Save is on a terminal. Flipping the field - // is the whole toggle: drawImage re-matches the glyph grid when it - // sees grid_mode/grid_ascii disagree with the live ones. - .Petscii => if (pane.image) |*iv| { - iv.petscii = !iv.petscii; - }, - .Palette => if (pane.image) |*iv| { - iv.pmode = if (iv.pmode == .commodore) .terminal else .commodore; - }, - .Ascii => if (pane.image) |*iv| { - iv.ascii = !iv.ascii; - }, - .Dump => p.dumpState() catch {}, - .Help => p.openHelp(id, ""), - .Restore => { - var it = std.mem.tokenizeAny(u8, txt, " \t"); - _ = it.next(); // the word "Restore" - const path = it.next() orelse (p.last_dump orelse return); - if (path.len > p.restore_buf.len) return; - @memcpy(p.restore_buf[0..path.len], path); - p.restore_req = p.restore_buf[0..path.len]; - }, - .Kill => { - p.quit = true; - p.emit(.quit); - }, - // The language builtins are all one call: ask, and let the answer - // land in lspResponse. Nothing here blocks or knows what a backend - // is — swapping backends changes lsp.query and not one line below. - .Hover => p.lspRequest(id, .hover, ""), - .CodeAction => p.lspRequest(id, .code_action, ""), - .SelectRefs => p.lspRequest(id, .select_refs, ""), - .Symbols => p.lspRequest(id, .document_symbols, ""), - .Diagnostics => p.lspRequest(id, .diagnostics, ""), - .WsDiagnostics => p.lspRequest(id, .workspace_diagnostics, ""), - .Lspinfo => p.lspRequest(id, .status, ""), - .Lspwhy => p.lspRequest(id, .explain, ""), - // The two that need a word from the user, handled exactly the way - // Find and Grep are: an argument means run it now (a selection - // chorded onto the name), no argument arms the tag input and Enter - // submits (submitSearch). - .Rename, .WsSymbols => { - const kind: lsp.Kind = if (b == .Rename) .rename else .workspace_symbols; - const a = std.mem.trim(u8, arg orelse "", " \t\r\n"); - if (a.len > 0) return p.lspRequest(id, kind, a); - p.startSearch(pane, if (b == .Rename) rename_marker else symbol_marker); - }, - .Save => { - // an output buffer has no file behind it — nothing to write - if (pane.file) |f| if (!f.output) p.emit(.{ .save_file = .{ .pane = @intCast(id) } }); - }, - // The two builtins that ASK for something — Find walks file NAMES - // under this pane's directory, Grep file CONTENTS under every - // pane's. With an argument there is nothing to ask: it IS the - // pattern, so the walk runs now (this is what a `Grep` executed - // with a selection chorded to it means). Without one they arm the - // same tag input `/` does, and Enter runs it (submitSearch). - .Find, .Grep => { - const kind: Search = if (b == .Find) .find else .grep; - const pat = std.mem.trim(u8, arg orelse "", " \t\r\n"); - if (pat.len > 0) return p.runSearch(id, pat, kind); - p.startSearch(pane, if (b == .Find) find_marker else grep_marker); - }, - .Newcol => { - const free = p.freeSlot() orelse return; - if (p.ncol >= MAX_COLS) return; - const nt = p.newShell(free, "") catch return; - nt.greet = true; - p.layoutAppendColumn(free); - p.active = free; - }, - .Del => { - p.absorbVWeight(id); - p.layoutRemove(id); - p.deinitPane(pane); - p.panes[id] = null; - if (p.active == id) p.active = p.prevFocus(id) orelse { - p.quit = true; - p.emit(.quit); - return; - }; - }, - .Delcol => { - const f = p.layoutFindTerm(id) orelse return; - var ids: [MAX_PANES]usize = undefined; - const nids = p.col_n[f.col]; - for (0..nids) |k| ids[k] = p.col_terms[f.col][k]; - for (ids[0..nids]) |tid| { - if (p.panes[tid]) |tt| { - p.layoutRemove(tid); - p.deinitPane(tt); - p.panes[tid] = null; - } - } - if (p.panes[p.active] == null) p.active = p.prevFocus(p.active) orelse { - p.quit = true; - p.emit(.quit); - return; - }; - }, - .Tutor => { - const free = p.freeSlot() orelse return; - const nt = p.openTutorView(free) catch return; - p.placeDoc(id, free, nt); // a doc like any other - }, - .Left => p.focusDir(id, .left), - .Down => p.focusDir(id, .down), - .Up => p.focusDir(id, .up), - .Right => p.focusDir(id, .right), - // The file<->terminal hop. "Latest" is already recorded: focus_hist - // is the MRU sync() rebuilds every update (most recent last), the - // same list a closing pane hands focus back through — so this walks - // it instead of keeping a second one. Which side is which: only a - // shell is a terminal; a file, an image and an output buffer - // (+Search/+Help) are all DOCS you read, so isTerminal is the whole - // test. Landing pushes this pane to the top of that same history, - // which is why the hop back is the same key. - .Toggleterm => { - const want_term = !pane.isTerminal(); - var t: ?usize = null; - // any live pane of the other kind: a pane you have never - // focused (the file you started with) is in no history at all - for (p.panes, 0..) |slot, k| { - const op = slot orelse continue; - if (k != id and op.isTerminal() == want_term) t = k; - } - // ...but the most recently focused one wins - var i = p.nfocus; - while (i > 0) { - i -= 1; - const hid = p.focus_hist[i]; - const hp = p.panes[hid] orelse continue; - if (hid != id and hp.isTerminal() == want_term) { - t = hid; - break; - } - } - if (t) |target| { - p.active = target; - p.panes[target].?.pending = 0; - } - }, - } - } - - /// The Help builtin: every builtin reachable from `prefix` (empty = all - /// of them) with the keys that run it, written into an output buffer - /// (acme's +Errors) — ordinary text, so the names in it are LIVE: middle- - /// click `Tutor` there and the tutor opens. Reuses the open +Help buffer - /// instead of piling panes up, and focus follows: you asked to read it. - fn openHelp(p: *Pardes, id: usize, prefix: []const u8) void { - const pane = p.panes[id] orelse return; - const arena = p.scratch.allocator(); - var out: std.ArrayList(u8) = .empty; - if (prefix.len == 0) { - out.appendSlice(arena, "pardes builtins - SPC then the keys,\nor execute the name anywhere\n\n") catch return; - } else { - out.appendSlice(arena, "pardes builtins under SPC") catch return; - for (prefix) |c| out.appendSlice(arena, &[_]u8{ ' ', c }) catch return; - out.appendSlice(arena, "\n\n") catch return; - } - for (leader_rows) |row| { - if (!std.mem.startsWith(u8, row.path, prefix)) continue; - out.appendSlice(arena, row.line) catch return; - out.append(arena, '\n') catch return; - } - const content = p.gpa.dupe(u8, out.items) catch return; - for (p.panes, 0..) |slot, i| { - const hp = slot orelse continue; - const hf = if (hp.file) |*f| f else continue; - if (!hf.output or !std.mem.endsWith(u8, hf.path, "/+Help")) continue; - p.setFileContent(hf, content); - hf.scroll = 0; - hp.cur_row = 0; - hp.msel.active = false; - p.active = i; - return; - } - const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); - const free = p.freeSlot() orelse { - p.gpa.free(content); - return; - }; - const np = p.openOutputView(free, dir, "+Help", content) catch { - p.gpa.free(content); - return; - }; - p.placeDoc(id, free, np); + const c: builtins.Ctx = .{ .p = p, .pane = pane, .id = id, .txt = txt, .arg = arg }; + inline for (comptime builtins.all(), 0..) |B, i| if (@intFromEnum(b) == i) return B.run(c); } /// place a fresh doc pane. An OUTPUT buffer (+Search/+Help) is NOT a @@ -5481,7 +5124,7 @@ pub const Pardes = struct { /// instead gets a column of its own on the left (acme: files left, shells /// right) — nothing is displaced, the other columns just narrow. A full /// column bar, or a result list whose source died, stacks into the leftmost. - fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void { + pub fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void { const doc = if (nt.file) |f| !f.output else true; // an image is a doc var src_id: ?usize = null; if (p.panes[from_id]) |src| if (if (src.file) |f| !f.output else src.image != null) { @@ -5521,7 +5164,7 @@ pub const Pardes = struct { // ---- dump / load (acme-style: `pardes -l state.zon` restores a session) ---- - fn dumpState(p: *Pardes) !void { + pub fn dumpState(p: *Pardes) !void { const arena = p.scratch.allocator(); var slot_to_pane: [MAX_PANES]?usize = @splat(null); var panes: std.ArrayList(dump.Pane) = .empty; @@ -5665,8 +5308,8 @@ pub const Pardes = struct { .rows = @max(1, src.rows), }; pane.stream = pane.vt.vtStream(); - pane.stream.handler.effects.write_pty = ptyReport; - pane.stream.handler.effects.device_attributes = ptyDeviceAttrs; + pane.stream.handler.effects.write_pty = term_pane.ptyReport; + pane.stream.handler.effects.device_attributes = term_pane.ptyDeviceAttrs; p.panes[i] = pane; if (t.stream_b64.len > 0) { const bytes = try dump.decodeBytes(p.scratch.allocator(), t.stream_b64); @@ -5749,7 +5392,7 @@ pub const Pardes = struct { /// where focus falls when the active pane closes: the most recently /// focused pane still alive (else any live one). Null = nothing left. - fn prevFocus(p: *Pardes, closing: usize) ?usize { + pub fn prevFocus(p: *Pardes, closing: usize) ?usize { var i = p.nfocus; while (i > 0) { i -= 1; @@ -5865,46 +5508,14 @@ pub const Pardes = struct { } } - fn theme(p: *const Pardes) *const Theme { + pub fn theme(p: *const Pardes) *const Theme { return &themes[p.theme_idx]; } // ---- render: build the canonical surface ---- - /// re-highlight the visible window of any file whose syntax went stale - /// (edit, scroll, load) — visible-range-first so big files stay snappy - fn refreshDirtyHighlights(p: *Pardes) void { - const tz = tracy.zone(@src(), "refreshDirtyHighlights"); - defer tz.end(); - for (p.panes) |slot| { - const pane = slot orelse continue; - if (pane.file == null) continue; - const f = &pane.file.?; - if (!f.syntax_dirty) continue; - if (!p.colors_on) { - if (f.highlights.len > 0) p.gpa.free(f.highlights); - f.highlights = &.{}; - f.highlight_start = 0; - f.syntax_dirty = false; - continue; - } - const start_row = f.scroll -| SYNTAX_CONTEXT_BEFORE_ROWS; - const start = modal.lineStartOffset(f.content, start_row); - const end_row = start_row + SYNTAX_CONTEXT_BEFORE_ROWS + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS; - const end = @max(start, modal.lineStartOffset(f.content, end_row)); - const new_highlights = syntax.highlightFileRange(p.gpa, f.path, f.content, start, end) catch { - f.syntax_dirty = false; - continue; - }; - if (f.highlights.len > 0) p.gpa.free(f.highlights); - f.highlights = new_highlights; - f.highlight_start = if (f.highlights.len > 0) start else 0; - f.syntax_dirty = false; - } - } - pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { - p.refreshDirtyHighlights(); + file_pane.refreshHighlights(p); const s = &p.surface; const ncells = @as(usize, p.screen_w) * p.screen_h; if (s.cells.len != ncells) { @@ -5991,7 +5602,7 @@ pub const Pardes = struct { var sb_total: usize = undefined; if (at.file) |f| { sb_off = f.scroll; - sb_total = fileLineCount(f.content); + sb_total = file_pane.lineCount(f.content); } else { const sb = at.vt.screens.active.pages.scrollbar(); sb_off = sb.offset; @@ -6116,85 +5727,17 @@ pub const Pardes = struct { _ = s.print(tx, r.y + BOX_H + i, tw, line, body_style); } - // file line-number gutter: mute the first PREFIX_W columns. Cheap - // chrome, not gated on colors_on; selection/cursor passes still win. - // The cursor row's number takes the tag style (same row math as the - // cursor pass below) so the eye finds the current line. - if (pane.file != null) { - const goff = pane.scroll(); - const gcur = pane.vt.screens.active.cursor; - const gcrow = if (pane.cur_pinned) pane.cur_row else @as(i32, @intCast(gcur.y)) + goff; - const cur_vr: i32 = if (active and !pane.tag_edit) gcrow - goff else -1; - var vr: u16 = 0; - while (vr < body_h) : (vr += 1) { - const on_cursor = @as(i32, vr) == cur_vr; - var c: u16 = 0; - while (c < PREFIX_W and c < tw) : (c += 1) { - const cell = s.at(tx + c, r.y + BOX_H + vr); - cell.default = false; // paints blank gutter rows too - if (on_cursor) { - cell.style.fg = .{ .rgb = th.tag_fg }; - cell.style.bg = .{ .rgb = th.tag_bg }; - } else cell.style.fg = .{ .rgb = th.lineno }; - } - } + // the two file-only passes and the terminal-only one: each is the + // whole reason its pane kind renders differently at all, so each lives + // with its kind. Order is load-bearing — gutter, then syntax over the + // content, then the emulator's own colors — and the selection/cursor + // passes below still win over all three. + if (pane.file) |*f| { + file_pane.drawGutter(p, pane, r, tx, tw, body_h, active); + if (p.colors_on) file_pane.recolorSyntax(p, pane, f, r, tx, tw, body_h); } - // syntax colors (file panes): recolor each content cell from its - // tree-sitter style byte; content starts after the lineno gutter - if (p.colors_on) if (pane.file) |*f| { - if (f.highlights.len > 0) { - const tz_recolor = tracy.zone(@src(), "synRecolor"); - defer tz_recolor.end(); - var flines = std.mem.splitScalar(u8, f.content, '\n'); - var skip: usize = 0; - while (skip < f.scroll) : (skip += 1) { - if (flines.next() == null) break; - } - const hs: usize = @intCast(@max(0, pane.hscroll)); - var vr: u16 = 0; - while (vr < body_h) : (vr += 1) { - const line = flines.next() orelse break; - const base = @intFromPtr(line.ptr) - @intFromPtr(f.content.ptr); - var c: usize = 0; - while (hs + c < line.len and PREFIX_W + c < tw) : (c += 1) { - const idx = base + hs + c; - if (idx < f.highlight_start) continue; - const hidx = idx - f.highlight_start; - if (hidx >= f.highlights.len) continue; - const ss = p.synStyle(@enumFromInt(f.highlights[hidx])) orelse continue; - const cell = s.at(tx + @as(u16, @intCast(PREFIX_W + c)), r.y + BOX_H + vr); - if (cell.default) continue; - cell.style.fg = .{ .rgb = ss.fg }; - cell.style.bold = ss.bold; - } - } - } - }; - // tty colors: recolor each body cell from the emulator's own style so - // shell output keeps its ansi colors (gated on the Colors toggle). - // Rows the edit buffer stands in for are OURS, not the emulator's: - // they keep the plain body style, and the rows under them are read - // from the shell row the surface actually shows there. const off = pane.scroll(); - if (p.colors_on and pane.isTerminal()) { - const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); - var vr: u16 = 0; - while (vr < body_h and vr < pane.rows) : (vr += 1) { - const grid = pane.gridRow(off + @as(i32, vr)) - goff; - if (pane.mode != .tty) if (pane.ovl) |o| { - if (off + @as(i32, vr) >= o.row and off + @as(i32, vr) < o.row + @as(i32, @intCast(modal.lineCount(o.text)))) continue; - }; - if (grid < 0) continue; - var c: u16 = 0; - while (c < tw) : (c += 1) { - const ci = pane.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(c), .y = @intCast(grid) } }) orelse continue; - if (ci.cell.wide == .spacer_tail) continue; - const cell = s.at(tx + c, r.y + BOX_H + vr); - if (cell.default) continue; - cell.style = p.cellStyle(ci); - } - } - } + if (p.colors_on and pane.isTerminal()) term_pane.recolorAnsi(p, pane, r, tx, tw, body_h, off); // mouse selections (pane-local coords), one pass per button — later // buttons win on overlap. A left .done stays highlighted after release; @@ -6282,7 +5825,7 @@ pub const Pardes = struct { if (r.h > BOX_H) { s.fill(r.x, r.y + BOX_H, GUTTER, r.h - BOX_H, .{ .bg = .{ .rgb = th.scroll_track } }); const sb: struct { total: usize, offset: usize, len: usize } = if (pane.file) |f| .{ - .total = fileLineCount(f.content), + .total = file_pane.lineCount(f.content), .offset = f.scroll, .len = pane.rows, } else blk: { @@ -6390,158 +5933,7 @@ pub const Pardes = struct { @memset(buf, '\n'); return buf; } - if (pane.file) |f| { - var out: std.ArrayList(u8) = .empty; - var flines = std.mem.splitScalar(u8, f.content, '\n'); - var skipped: usize = 0; - while (skipped < f.scroll) : (skipped += 1) { - if (flines.next() == null) break; - } - var i: usize = 0; - while (i < pane.rows) : (i += 1) { - if (i > 0) try out.append(arena, '\n'); - const abs: i32 = @as(i32, @intCast(f.scroll)) + @as(i32, @intCast(i)); - const row_start = out.items.len; - if (flines.next()) |text| { - var lbuf: [16]u8 = undefined; - // unsigned: {d} prints a leading '+' for signed ints - const lineno: usize = @intCast(abs + 1); - const prefix = std.fmt.bufPrint(&lbuf, "{d: >4} ", .{lineno}) catch " "; - var base: std.ArrayList(u8) = .empty; - try base.appendSlice(arena, prefix); - try base.appendSlice(arena, text); - try out.appendSlice(arena, base.items); - } - // horizontal scroll: cut hscroll columns after the gutter - if (pane.hscroll > 0) { - const cut_at = row_start + PREFIX_W; - if (out.items.len > cut_at) { - const cut = @min(@as(usize, @intCast(pane.hscroll)), out.items.len - cut_at); - out.replaceRangeAssumeCapacity(cut_at, cut, &.{}); - } - } - } - return out.items; - } - const raw = try pane.vt.plainString(arena); - // the viewport's shell rows, prompt rows blanked outside tty mode - var prompts = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .viewport = .{} }, null); - var vp: std.ArrayList([]const u8) = .empty; - var lines = std.mem.splitScalar(u8, raw, '\n'); - while (lines.next()) |ln| { - const is_prompt = if (pane.mode != .tty) - if (prompts.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false - else - false; - try vp.append(arena, if (is_prompt) "" else ln); - } - const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); - const off = pane.scroll(); - var g: i32 = pane.gridRow(off); - // the buffer can start above the viewport: drop the lines scrolled past - var skip: usize = if (pane.ovl) |o| @intCast(@max(0, off - pane.surfRow(o.row))) else 0; - var out: std.ArrayList(u8) = .empty; - var n: usize = 0; - while (n < pane.rows) { - if (pane.mode != .tty) if (pane.ovl) |o| if (g == o.row) { - var bit = std.mem.splitScalar(u8, o.text, '\n'); - var k: usize = 0; - while (bit.next()) |ln| : (k += 1) { - if (k < skip) continue; - if (n >= pane.rows) break; - if (n > 0) try out.append(arena, '\n'); - try out.appendSlice(arena, ln); - n += 1; - } - skip = 0; - g += o.rows; - continue; - }; - if (n > 0) try out.append(arena, '\n'); - const vi = g - goff; - if (vi >= 0 and @as(usize, @intCast(vi)) < vp.items.len) try out.appendSlice(arena, vp.items[@intCast(vi)]); - n += 1; - g += 1; - } - return out.items; - } - - fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell) CellStyle { - const style = ci.style(); - var cs: CellStyle = .{ - .fg = p.ghostColor(style.fg_color, false), - .bg = p.ghostColor(style.bg_color, true), - .bold = style.flags.bold, - .dim = style.flags.faint, - .italic = style.flags.italic, - .blink = style.flags.blink, - .reverse = style.flags.inverse, - .invisible = style.flags.invisible, - .strikethrough = style.flags.strikethrough, - .ul = switch (style.flags.underline) { - .none => .off, - .single => .single, - .double => .double, - .curly => .curly, - .dotted => .dotted, - .dashed => .dashed, - }, - }; - switch (ci.cell.content_tag) { - .bg_color_palette => cs.bg = p.palColor(ci.cell.content.color_palette.data), - .bg_color_rgb => { - const rgb = ci.cell.content.color_rgb; - cs.bg = .{ .rgb = .{ rgb.r, rgb.g, rgb.b } }; - }, - else => {}, - } - return cs; - } - - const SynStyle = struct { fg: [3]u8, bold: bool }; - - fn synStyle(p: *Pardes, sy: syntax.Syn) ?SynStyle { - return switch (sy) { - .none => null, - .keyword => .{ .fg = p.theme().kw, .bold = true }, - .string => .{ .fg = p.theme().str, .bold = false }, - .number => .{ .fg = p.theme().num, .bold = false }, - .comment => .{ .fg = p.theme().comment, .bold = true }, - }; - } - - fn palColor(p: *Pardes, idx: u8) Color { - if (p.theme().palette) |pal| if (idx < 16) return .{ .rgb = pal[idx] }; - return .{ .index = idx }; - } - - fn ghostColor(p: *Pardes, color: ghostty_vt.Style.Color, is_bg: bool) Color { - return switch (color) { - .none => blk: { - const t = if (is_bg) p.theme().bg else p.theme().fg; - break :blk if (t) |c| .{ .rgb = c } else .default; - }, - .palette => |idx| p.palColor(idx), - .rgb => |rgb| .{ .rgb = .{ rgb.r, rgb.g, rgb.b } }, - }; + if (pane.file) |*f| return file_pane.bodyText(arena, pane, f); + return term_pane.bodyText(arena, pane); } }; - -// ghostty calls this with a reply (cursor-position report, DA, ...) to send -// back to the child as if it typed it. The handler's `terminal` is our Pane.vt -// field; recover the Pane and stash the bytes — sync() drains them into write -// effects (the callback has no path to the effect queue). -fn ptyReport(handler: *ghostty_vt.TerminalStream.Handler, data: [:0]const u8) void { - const pane: *Pane = @fieldParentPtr("vt", handler.terminal); - const room = pane.reply.len - pane.reply_len; - const n = @min(room, data.len); - @memcpy(pane.reply[pane.reply_len..][0..n], data[0..n]); - pane.reply_len += @intCast(n); -} - -const DeviceAttrs = @typeInfo(@typeInfo(@typeInfo( - @FieldType(ghostty_vt.TerminalStream.Handler.Effects, "device_attributes"), -).optional.child).pointer.child).@"fn".return_type.?; -fn ptyDeviceAttrs(_: *ghostty_vt.TerminalStream.Handler) DeviceAttrs { - return .{}; -} diff --git a/src/term_pane.zig b/src/term_pane.zig new file mode 100644 index 00000000..1c03afa7 --- /dev/null +++ b/src/term_pane.zig @@ -0,0 +1,222 @@ +//! Terminal panes: everything a Pane does BECAUSE it owns a ghostty-vt +//! emulator — reading the grid back out as text (for motions and for the +//! body), translating the emulator's own cell styles into ours, the pty +//! replies ghostty hands back through a callback, and the edit-buffer undo +//! snapshots that only exist because a terminal's "content" is a live grid +//! rather than a []u8. +//! +//! What is NOT here: the edit buffer's SEMANTICS (Pane.ovl, surfRow/gridRow) +//! live on Pane in pardes.zig, because the motion, insert and undo machinery +//! is shared with file panes verbatim — a terminal in insert mode behaving +//! exactly like a text file is the whole design, and splitting the row math +//! away from the code that uses it would only hide that. +const std = @import("std"); +const ghostty_vt = @import("ghostty-vt"); +const pardes = @import("pardes.zig"); +const Pardes = pardes.Pardes; +const Pane = pardes.Pane; +const EditSnap = pardes.EditSnap; +const Ovl = pardes.Ovl; +const modal = @import("modal.zig"); + +/// A terminal's shell rows as the surface sees them: the WHOLE +/// history+active grid, prompt rows blanked (OSC 133), absolute grid rows +/// from 0. The raw material the motion surface is composed from — the +/// edit buffer is NOT applied here, so it is also what seeding the buffer +/// reads. Scratch-arena backed. +/// ghostty's dump trims the grid's trailing blank rows; ONE of them is +/// kept back, the row the cursor sits on below the last line of output. +/// That row is a file's final newline: without it the surface would have +/// one line fewer than the same text in a document, and every motion and +/// linewise edit at the bottom would diverge. +/// ponytail: O(scrollback) dump+scan per keystroke — window it around the +/// viewport if a multi-MB scrollback ever makes navigation lag. +pub fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 { + const arena = p.scratch.allocator(); + const full = try pane.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} }); + var pit = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); + var ls: std.ArrayList([]const u8) = .empty; + var it = std.mem.splitAny(u8, full, "\n"); + while (it.next()) |raw| { + const is_prompt = if (pit.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false; + try ls.append(arena, if (is_prompt) "" else raw); + } + try ls.append(arena, ""); + return ls.items; +} + +/// The body a terminal renders: the viewport's shell rows (prompt rows blanked +/// outside tty mode) with the edit buffer's lines standing in for the rows it +/// covers, so what you see is what the motions move over. +pub fn bodyText(arena: std.mem.Allocator, pane: *Pane) ![]const u8 { + const raw = try pane.vt.plainString(arena); + var prompts = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .viewport = .{} }, null); + var vp: std.ArrayList([]const u8) = .empty; + var lines = std.mem.splitScalar(u8, raw, '\n'); + while (lines.next()) |ln| { + const is_prompt = if (pane.mode != .tty) + if (prompts.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false + else + false; + try vp.append(arena, if (is_prompt) "" else ln); + } + const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); + const off = pane.scroll(); + var g: i32 = pane.gridRow(off); + // the buffer can start above the viewport: drop the lines scrolled past + var skip: usize = if (pane.ovl) |o| @intCast(@max(0, off - pane.surfRow(o.row))) else 0; + var out: std.ArrayList(u8) = .empty; + var n: usize = 0; + while (n < pane.rows) { + if (pane.mode != .tty) if (pane.ovl) |o| if (g == o.row) { + var bit = std.mem.splitScalar(u8, o.text, '\n'); + var k: usize = 0; + while (bit.next()) |ln| : (k += 1) { + if (k < skip) continue; + if (n >= pane.rows) break; + if (n > 0) try out.append(arena, '\n'); + try out.appendSlice(arena, ln); + n += 1; + } + skip = 0; + g += o.rows; + continue; + }; + if (n > 0) try out.append(arena, '\n'); + const vi = g - goff; + if (vi >= 0 and @as(usize, @intCast(vi)) < vp.items.len) try out.appendSlice(arena, vp.items[@intCast(vi)]); + n += 1; + g += 1; + } + return out.items; +} + +/// tty colors: recolor each body cell from the emulator's own style so shell +/// output keeps its ansi colors (gated on the Colors toggle by the caller). +/// Rows the edit buffer stands in for are OURS, not the emulator's: they keep +/// the plain body style, and the rows under them are read from the shell row +/// the surface actually shows there. +pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, off: i32) void { + const s = &p.surface; + const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); + var vr: u16 = 0; + while (vr < body_h and vr < pane.rows) : (vr += 1) { + const grid = pane.gridRow(off + @as(i32, vr)) - goff; + if (pane.mode != .tty) if (pane.ovl) |o| { + if (off + @as(i32, vr) >= o.row and off + @as(i32, vr) < o.row + @as(i32, @intCast(modal.lineCount(o.text)))) continue; + }; + if (grid < 0) continue; + var c: u16 = 0; + while (c < tw) : (c += 1) { + const ci = pane.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(c), .y = @intCast(grid) } }) orelse continue; + if (ci.cell.wide == .spacer_tail) continue; + const cell = s.at(tx + c, r.y + pardes.BOX_H + vr); + if (cell.default) continue; + cell.style = cellStyle(p, ci); + } + } +} + +pub fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell) pardes.CellStyle { + const style = ci.style(); + var cs: pardes.CellStyle = .{ + .fg = ghostColor(p, style.fg_color, false), + .bg = ghostColor(p, style.bg_color, true), + .bold = style.flags.bold, + .dim = style.flags.faint, + .italic = style.flags.italic, + .blink = style.flags.blink, + .reverse = style.flags.inverse, + .invisible = style.flags.invisible, + .strikethrough = style.flags.strikethrough, + .ul = switch (style.flags.underline) { + .none => .off, + .single => .single, + .double => .double, + .curly => .curly, + .dotted => .dotted, + .dashed => .dashed, + }, + }; + switch (ci.cell.content_tag) { + .bg_color_palette => cs.bg = palColor(p, ci.cell.content.color_palette.data), + .bg_color_rgb => { + const rgb = ci.cell.content.color_rgb; + cs.bg = .{ .rgb = .{ rgb.r, rgb.g, rgb.b } }; + }, + else => {}, + } + return cs; +} + +pub fn palColor(p: *Pardes, idx: u8) pardes.Color { + if (p.theme().palette) |pal| if (idx < 16) return .{ .rgb = pal[idx] }; + return .{ .index = idx }; +} + +pub fn ghostColor(p: *Pardes, color: ghostty_vt.Style.Color, is_bg: bool) pardes.Color { + return switch (color) { + .none => blk: { + const t = if (is_bg) p.theme().bg else p.theme().fg; + break :blk if (t) |c| .{ .rgb = c } else .default; + }, + .palette => |idx| palColor(p, idx), + .rgb => |rgb| .{ .rgb = .{ rgb.r, rgb.g, rgb.b } }, + }; +} + +/// executing at a prompt with typed text below it: pad the output area +/// with newlines so the command's output doesn't overwrite the buffer +pub fn padOutputBelowEdits(p: *Pardes, id: usize) void { + const pane = p.panes[id] orelse return; + const o = pane.ovl orelse return; + if (!pane.isTerminal()) return; + if (!pane.vt.cursorIsAtPrompt()) return; + // the buffer's LAST surface row: its lines may outnumber the shell + // rows it covers, and it is the bottom one output must clear + const max_row = o.row + @as(i32, @intCast(modal.lineCount(o.text))) - 1; + const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); + const cursor_abs = pane.surfRow(goff + @as(i32, @intCast(pane.vt.screens.active.cursor.y))); + const pad = std.math.clamp(max_row - cursor_abs, 0, @as(i32, pane.rows)); + var i: i32 = 0; + while (i < pad) : (i += 1) p.emitWrite(id, "\r"); +} + +/// the snapshot takes ownership of a COPY of the edit buffer's text +pub fn snap(p: *Pardes, pane: *Pane) ?EditSnap { + var ovl: ?Ovl = null; + if (pane.ovl) |o| ovl = .{ .row = o.row, .rows = o.rows, .text = p.gpa.dupe(u8, o.text) catch return null }; + return .{ .ovl = ovl, .cur_row = pane.cur_row, .cur_col = pane.cur_col, .vsel = pane.vsel }; +} + +/// undo/redo restores the selection recorded with the snapshot (helix +/// keeps selections in its history transactions) +pub fn restoreSnap(p: *Pardes, pane: *Pane, s: EditSnap) void { + if (pane.ovl) |o| p.gpa.free(o.text); + pane.ovl = s.ovl; + pane.cur_row = s.cur_row; + pane.cur_col = s.cur_col; + pane.cur_pinned = true; + pane.vsel = s.vsel; + pane.msel.active = false; + pane.ensureCursorVisible(); +} + +// ghostty calls this with a reply (cursor-position report, DA, ...) to send +// back to the child as if it typed it. The handler's `terminal` is our Pane.vt +// field; recover the Pane and stash the bytes — sync() drains them into write +// effects (the callback has no path to the effect queue). +pub fn ptyReport(handler: *ghostty_vt.TerminalStream.Handler, data: [:0]const u8) void { + const pane: *Pane = @fieldParentPtr("vt", handler.terminal); + const room = pane.reply.len - pane.reply_len; + const n = @min(room, data.len); + @memcpy(pane.reply[pane.reply_len..][0..n], data[0..n]); + pane.reply_len += @intCast(n); +} + +const DeviceAttrs = @typeInfo(@typeInfo(@typeInfo( + @FieldType(ghostty_vt.TerminalStream.Handler.Effects, "device_attributes"), +).optional.child).pointer.child).@"fn".return_type.?; +pub fn ptyDeviceAttrs(_: *ghostty_vt.TerminalStream.Handler) DeviceAttrs { + return .{}; +} diff --git a/src/tty.zig b/src/tty.zig deleted file mode 100644 index 0bb7ef63..00000000 --- a/src/tty.zig +++ /dev/null @@ -1,624 +0,0 @@ -//! 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); - } -} 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); + } +} -- cgit v1.3