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. --- build.zig | 6 +- docs/lsp-evaluation.md | 4 +- docs/lsp.md | 4 +- next-steps.txt | 2 +- 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 ++++++++++ test/deck/replay.zig | 2 +- test/lspbench.zig | 24 +- 24 files changed, 6260 insertions(+), 5935 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 diff --git a/build.zig b/build.zig index 87df6727..c11ccccd 100644 --- a/build.zig +++ b/build.zig @@ -122,7 +122,7 @@ pub fn build(b: *std.Build) void { if (target.result.os.tag == .freebsd or target.result.os.tag == .netbsd or target.result.os.tag == .openbsd) root_mod.linkSystemLibrary("util", .{}); - // The language backend (src/lsp_zls.zig) links ZLS as a MODULE — no zls + // The language backend (src/lsp/lsp_zls.zig) links ZLS as a MODULE — no zls // binary, no JSON-RPC, no protocol. Everything except the browser gets it; // emscripten does not, because the web shell has no threads and no-ops the // lsp effect anyway, so paying to compile an analyser it can never call @@ -639,7 +639,7 @@ pub fn build(b: *std.Build) void { const crt_test = b.addTest(.{ .root_module = b.createModule(.{ .target = target, .optimize = optimize, - .root_source_file = b.path("src/crt.zig"), + .root_source_file = b.path("src/gui/crt.zig"), }) }); unit_step.dependOn(&b.addRunArtifact(crt_test).step); // deck.zig is pure std too; replay.zig drives it with slices of real @@ -652,7 +652,7 @@ pub fn build(b: *std.Build) void { deck_test.root_module.addImport("deck", b.createModule(.{ .target = target, .optimize = optimize, - .root_source_file = b.path("src/deck.zig"), + .root_source_file = b.path("src/gui/deck.zig"), })); unit_step.dependOn(&b.addRunArtifact(deck_test).step); } diff --git a/docs/lsp-evaluation.md b/docs/lsp-evaluation.md index 5f660640..1756ec94 100644 --- a/docs/lsp-evaluation.md +++ b/docs/lsp-evaluation.md @@ -1,6 +1,6 @@ # Three language backends, measured -Same core, same seam (`src/lsp.zig`), same 17-probe harness (`zig build lspbench`) +Same core, same seam (`src/lsp/lsp.zig`), same 17-probe harness (`zig build lspbench`) over the same corpus. Three jj workspaces, three independent implementations, one function each. C landed; see **Decision** below. @@ -84,7 +84,7 @@ real binary through the helix keymap, and pass `hxdiff` (360) and `hxparity` ## Decision -**C landed.** It is what `src/lsp_zls.zig` is, and ZLS is now a fetched +**C landed.** It is what `src/lsp/lsp_zls.zig` is, and ZLS is now a fetched dependency (`build.zig.zon`, pinned to commit `3e0d0820` on the 0.16.x branch) so it sits in `zig-pkg/` like everything else and the build is reproducible from the .zon alone — no path dependency on a local checkout. diff --git a/docs/lsp.md b/docs/lsp.md index 2a4c8784..91d07231 100644 --- a/docs/lsp.md +++ b/docs/lsp.md @@ -5,7 +5,7 @@ Three things landed together, and only the first two are permanent: 1. **An async execution model.** The core stays a state machine; slow work goes to a worker and comes back as an event. 2. **A helix-exact keymap** for every LSP command. -3. **A seam** (`src/lsp.zig`) with exactly one function behind it, so competing +3. **A seam** (`src/lsp/lsp.zig`) with exactly one function behind it, so competing backends can be swapped, measured, and thrown away. ## The async model @@ -126,7 +126,7 @@ document — which matters precisely when the pane you are in is the problem. ## Writing a backend -`src/lsp.zig` is the seam. An implementation supplies three things and touches +`src/lsp/lsp.zig` is the seam. An implementation supplies three things and touches nothing else: ```zig diff --git a/next-steps.txt b/next-steps.txt index 3026bdb5..747dfd34 100644 --- a/next-steps.txt +++ b/next-steps.txt @@ -10,4 +10,4 @@ - There's some bad performance when dealing with specially large files or long lines; write a perf harness for this and optimize the performance. - The Look and Exec should be regular semantic entities, i.e. their only special feature should be the keys/shortcuts they're assigned so we could execute Look main.zig and it would call the regular look on it, but also we could Look some string like @`ls -la` (you can change the syntax, but this also depends on that syntax change so it's centralized and easy to change later). This basically will allow you to make some big refactorings and cut a lot of code (hopefully) and also allow some funny things like @`Look .` (basically allowing nested things like this for no good reason other than, it's elegant and it's fun!) - We'll make the focus history a meaningful and strong semantic entity: instead of being a stack of Panes, it'll be a stack of locations. This will allow us to implement vim's ctrl-i and ctrl-o that will just move the current focus up or down on that stack. there'll be also a new builtin that will toggle the focus of the latest panes, basically moving the current focus by changing the current pointer to the location on the stack. The Jumplist (new builtin) will be an Output pane with each line being a location that can be Looked, n/N should work. -- The main pardes.zig file should hold the logic that applies to all kinds of panes and backends. Maybe right now the pane specifiticies are too intermingled. Maintain the panes logics into their own files, right now tty.zig exists but I don't think output panes live by themselves nor file panes on their own files. Also, each builtin command should be implemented as a struct, the current logic on switching on the enum should use some comptime logic to build the tagged union/enum from those structs now. Also I think it's time to create a few directories on @src now since some files are specific to some backends and others are part of the core, it should be clear just by looking at the directory structure (don't add more dir layers, just new dirs under src). ++ The main pardes.zig file should hold the logic that applies to all kinds of panes and backends. Maybe right now the pane specifiticies are too intermingled. Maintain the panes logics into their own files, right now tty.zig exists but I don't think output panes live by themselves nor file panes on their own files. Also, each builtin command should be implemented as a struct, the current logic on switching on the enum should use some comptime logic to build the tagged union/enum from those structs now. Also I think it's time to create a few directories on @src now since some files are specific to some backends and others are part of the core, it should be clear just by looking at the directory structure (don't add more dir layers, just new dirs under 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); + } +} diff --git a/test/deck/replay.zig b/test/deck/replay.zig index 9cf829d6..475f7476 100644 --- a/test/deck/replay.zig +++ b/test/deck/replay.zig @@ -1,5 +1,5 @@ //! Replays slices of real Steam Deck recordings (deckcap .zon captures) -//! through src/deck.zig and checks the aggregate actions, so the deck +//! through src/gui/deck.zig and checks the aggregate actions, so the deck //! control mapping is exercised without the device. const std = @import("std"); diff --git a/test/lspbench.zig b/test/lspbench.zig index 9eddf2f0..ffea0e20 100644 --- a/test/lspbench.zig +++ b/test/lspbench.zig @@ -61,17 +61,17 @@ const anchors = [_]Anchor{ // a call to a function defined in the same file .{ .file = "src/pardes.zig", .needle = "modal.hxLineCount(text)", .at = 6, .kind = .definition, .expect = "modal.zig" }, // a plain local/decl reference within one file - .{ .file = "src/lsp.zig", .needle = "lineCol(source", .at = 0, .kind = .definition, .expect = "lsp.zig" }, + .{ .file = "src/lsp/lsp.zig", .needle = "lineCol(source", .at = 0, .kind = .definition, .expect = "lsp.zig" }, // a std reference: needs the zig lib dir, which is the first thing a // single-file backend cannot do - .{ .file = "src/lsp.zig", .needle = "std.mem.count(u8", .at = 8, .kind = .definition, .expect = "mem.zig" }, + .{ .file = "src/lsp/lsp.zig", .needle = "std.mem.count(u8", .at = 8, .kind = .definition, .expect = "mem.zig" }, // hover over the same symbol - .{ .file = "src/lsp.zig", .needle = "lineCol(source", .at = 0, .kind = .hover, .expect = "" }, + .{ .file = "src/lsp/lsp.zig", .needle = "lineCol(source", .at = 0, .kind = .hover, .expect = "" }, // the file's own symbols - .{ .file = "src/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .document_symbols, .expect = "Kind" }, + .{ .file = "src/lsp/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .document_symbols, .expect = "Kind" }, .{ .file = "src/modal.zig", .needle = "pub fn ", .at = 7, .kind = .document_symbols, .expect = "" }, // references to a symbol used in several places - .{ .file = "src/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .references, .expect = "" }, + .{ .file = "src/lsp/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .references, .expect = "" }, // Diagnostics and format probe a DELIBERATELY BROKEN fixture, never the // real source. On a clean corpus the correct answer is nothing, which is // byte-identical to "this backend has no diagnostics" — a blind spot that @@ -80,16 +80,16 @@ const anchors = [_]Anchor{ // and a misformatted fn (needs a formatter), so both probes have real work. .{ .file = "test/lspfixture/broken.zig", .needle = "unused_local", .at = 0, .kind = .diagnostics, .expect = "broken.zig" }, // the remaining kinds, probed once each so the matrix is complete - .{ .file = "src/lsp.zig", .needle = "lineCol(source", .at = 0, .kind = .declaration, .expect = "" }, + .{ .file = "src/lsp/lsp.zig", .needle = "lineCol(source", .at = 0, .kind = .declaration, .expect = "" }, // cursor on `out`, whose type is `*std.Io.Writer` — a type_definition probe // wants a NAME whose type must be resolved, not the type spelled out - .{ .file = "src/lsp.zig", .needle = "out: *std.Io.Writer", .at = 0, .kind = .type_definition, .expect = "" }, - .{ .file = "src/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .implementation, .expect = "" }, - .{ .file = "src/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .select_refs, .expect = "" }, + .{ .file = "src/lsp/lsp.zig", .needle = "out: *std.Io.Writer", .at = 0, .kind = .type_definition, .expect = "" }, + .{ .file = "src/lsp/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .implementation, .expect = "" }, + .{ .file = "src/lsp/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .select_refs, .expect = "" }, .{ .file = "test/lspfixture/broken.zig", .needle = "badly_spaced", .at = 0, .kind = .format, .expect = "" }, - .{ .file = "src/lsp.zig", .needle = "pub fn query", .at = 7, .kind = .code_action, .expect = "" }, - .{ .file = "src/lsp.zig", .needle = "pub fn query", .at = 7, .kind = .rename, .expect = "" }, - .{ .file = "src/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .workspace_symbols, .expect = "" }, + .{ .file = "src/lsp/lsp.zig", .needle = "pub fn query", .at = 7, .kind = .code_action, .expect = "" }, + .{ .file = "src/lsp/lsp.zig", .needle = "pub fn query", .at = 7, .kind = .rename, .expect = "" }, + .{ .file = "src/lsp/lsp.zig", .needle = "pub const Kind", .at = 11, .kind = .workspace_symbols, .expect = "" }, .{ .file = "test/lspfixture/broken.zig", .needle = "unused_local", .at = 0, .kind = .workspace_diagnostics, .expect = "broken.zig" }, }; -- cgit v1.3