12cb9419
build: the engine is its own module, and no client row links ghostty-vt
a73x 2026-09-04 18:04
Commit message
CLAUDE.md
| Old | New | ||
|---|---|---|---|
| @@ -73,7 +73,7 @@ a symbol by its FILE stem (`wall_pump.askOn`) — a file, not a module. | |||
| 73 | 73 | ||
| 74 | | Folder | Row — its child files | | 74 | | Folder | Row — its child files | |
| 75 | |---|---| | 75 | |---|---| |
| 76 | | `src/engine/` | `term`(`term.zig`) — `protocol` `engine` `delta` `replica` | | 76 | | `src/engine/` | `term`(`term.zig`) — `protocol` `replica` `grid` · `engine`(`engine.zig`) — `delta` — the daemon's ghostty-vt; no client row imports it outside a test | |
| 77 | | `src/server/` | `daemon`(`server.zig`) — `server_agent` `server_sessions` `cmd` `shellint` `quic_server` `upgrade` `server_test_*` · `pty` | | 77 | | `src/server/` | `daemon`(`server.zig`) — `server_agent` `server_sessions` `cmd` `shellint` `quic_server` `upgrade` `server_test_*` · `pty` | |
| 78 | | `src/client/` | `client` — `client_core` `hosts` `handoff` `layout` `keymap` `askpass` · `webhub` · `wasm_core` `client_core_wasm_check` (wasm roots the build wires outside the table) | | 78 | | `src/client/` | `client` — `client_core` `hosts` `handoff` `layout` `keymap` `askpass` · `webhub` · `wasm_core` `client_core_wasm_check` (wasm roots the build wires outside the table) | |
| 79 | | `src/tui/` | `wall`(`wallview.zig`) — `interact` `paint` `select` `predict` `wall_host` `wall_picker` `wall_pump` `wall_layout` `wall_test_*` | | 79 | | `src/tui/` | `wall`(`wallview.zig`) — `interact` `paint` `select` `predict` `wall_host` `wall_picker` `wall_pump` `wall_layout` `wall_test_*` | |
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -140,17 +140,28 @@ const ModSpec = struct { | |||
| 140 | 140 | ||
| 141 | const mod_table = [_]ModSpec{ | 141 | const mod_table = [_]ModSpec{ |
| 142 | // ---- the leaves: they import nothing internal in production ---- | 142 | // ---- the leaves: they import nothing internal in production ---- |
| 143 | // The terminal component, one row over four files: the wire contract, | 143 | // The wire component, one row over three files: the wire contract, the |
| 144 | // the authoritative engine, the daemon-side delta minting and the one | 144 | // client-side grid a payload decodes into and the one replay core, |
| 145 | // replay core, reached as `term.protocol`, `.engine`, `.delta`, | 145 | // reached as `term.protocol`, `.grid`, `.replica`. They are one owner — |
| 146 | // `.replica`. They are one owner — protocol.zig owns the bytes a delta | 146 | // protocol.zig owns the bytes a row is made of and grid.zig owns the |
| 147 | // payload is made of and delta.zig owns which rows go into one — and | 147 | // rows — and only this root is a seam, so a second module claiming any |
| 148 | // only this root is a seam, so a second module claiming any of the four | 148 | // of the three is a file-in-multiple-modules compile error. It imports |
| 149 | // is a file-in-multiple-modules compile error. It imports nothing | 149 | // nothing internal and links no terminal emulator, which is what lets |
| 150 | // internal, which is what lets the tracker and the replay core be | 150 | // every client hold a replica without ghostty-vt in the binary, and what |
| 151 | // driven by a test holding an engine and no socket; and the whole | 151 | // lets `mux_core.wasm` compile the whole component. |
| 152 | // component is platform-free, so `mux_core.wasm` compiles it. | 152 | // `link_libc` for its TESTS, not its code: two of protocol.zig's tests |
| 153 | .{ .name = "term", .path = "src/engine/term.zig", .wasm = true }, | 153 | // need a socket whose peer refuses to read, and std.posix has no |
| 154 | // socketpair on the pinned 0.15.2, so they call std.c's. The row used to | ||
| 155 | // get libc for free through ghostty-vt; dropping the emulator made the | ||
| 156 | // dependency visible rather than new. | ||
| 157 | .{ .name = "term", .path = "src/engine/term.zig", .link_libc = true, .wasm = true }, | ||
| 158 | // The authoritative emulator and the daemon-side delta minting, reached | ||
| 159 | // as `Engine` and `engine.delta`. The ONE row that links ghostty-vt: a | ||
| 160 | // client parses no VT, so this sits above `term` rather than inside it, | ||
| 161 | // and the client rows below carry it as a TEST import only — for the | ||
| 162 | // tests that author a screen by feeding VT to an engine and mirroring it | ||
| 163 | // into a grid. | ||
| 164 | .{ .name = "engine", .path = "src/engine/engine.zig", .imports = &.{"term"} }, | ||
| 154 | // The platform layer, one row per side (docs/superpowers/specs/ | 165 | // The platform layer, one row per side (docs/superpowers/specs/ |
| 155 | // 2026-09-03-macos-port-design.md). Leaves: they import nothing of ours, | 166 | // 2026-09-03-macos-port-design.md). Leaves: they import nothing of ours, |
| 156 | // and the raw OS spellings are meant to end up here rather than in the | 167 | // and the raw OS spellings are meant to end up here rather than in the |
| @@ -217,9 +228,10 @@ const mod_table = [_]ModSpec{ | |||
| 217 | .{ .name = "link", .path = "src/link.zig", .link_libc = true, .imports = &.{ "term", "quic" }, .quic_tests = true }, | 228 | .{ .name = "link", .path = "src/link.zig", .link_libc = true, .imports = &.{ "term", "quic" }, .quic_tests = true }, |
| 218 | // Replays a captured client stdout stream and prints the final grid in | 229 | // Replays a captured client stdout stream and prints the final grid in |
| 219 | // `mux d dump`'s formats — the client half of the M11 render-vs-dump | 230 | // `mux d dump`'s formats — the client half of the M11 render-vs-dump |
| 220 | // convergence check. Imports term so both sides of the diff go through | 231 | // convergence check. It plays the TERMINAL the client painted onto |
| 221 | // the same ghostty-vt and the same formatter. | 232 | // rather than a client, so it imports the engine, and both sides of the |
| 222 | .{ .name = "render", .path = "test/render.zig", .imports = &.{"term"} }, | 233 | // diff go through the same ghostty-vt and the same formatter. |
| 234 | .{ .name = "render", .path = "test/render.zig", .imports = &.{"engine"} }, | ||
| 223 | // The pty-driving e2e fixture: real client on a pty slave, scripted | 235 | // The pty-driving e2e fixture: real client on a pty slave, scripted |
| 224 | // from stdin (M12). Imports pty so the product's own module is the one | 236 | // from stdin (M12). Imports pty so the product's own module is the one |
| 225 | // under it. | 237 | // under it. |
| @@ -235,7 +247,7 @@ const mod_table = [_]ModSpec{ | |||
| 235 | // daemon itself when nobody handed it a --key — and for the shim | 247 | // daemon itself when nobody handed it a --key — and for the shim |
| 236 | // directory shell integration writes under the same 0700 policy. | 248 | // directory shell integration writes under the same 0700 policy. |
| 237 | // `pty` stays a row of its own: the ptyclient fixture consumes it. | 249 | // `pty` stays a row of its own: the ptyclient fixture consumes it. |
| 238 | .{ .name = "daemon", .path = "src/server/server.zig", .link_libc = true, .imports = &.{ "term", "pty", "sockpath", "serve", "quic", "xdg", "proxy", "server_os" }, .test_imports = &.{ "testtmp", "dial", "link" }, .quic_tests = true }, | 250 | .{ .name = "daemon", .path = "src/server/server.zig", .link_libc = true, .imports = &.{ "term", "engine", "pty", "sockpath", "serve", "quic", "xdg", "proxy", "server_os" }, .test_imports = &.{ "testtmp", "dial", "link" }, .quic_tests = true }, |
| 239 | // The agent-facing client. It speaks frames and owns no terminal, which | 251 | // The agent-facing client. It speaks frames and owns no terminal, which |
| 240 | // is the whole point — it attaches at 0x0 and never claims the grid. | 252 | // is the whole point — it attaches at 0x0 and never claims the grid. |
| 241 | // The transport modules are the CLI client's, minus everything that | 253 | // The transport modules are the CLI client's, minus everything that |
| @@ -245,7 +257,7 @@ const mod_table = [_]ModSpec{ | |||
| 245 | // nothing to draw: a fact of muxa.zig itself, which the one-row component | 257 | // nothing to draw: a fact of muxa.zig itself, which the one-row component |
| 246 | // no longer refuses on its behalf. | 258 | // no longer refuses on its behalf. |
| 247 | .{ .name = "agent", .path = "src/cli/muxa.zig", .link_libc = true, .imports = &.{ "term", "sockpath", "quic", "xdg", "cliflags", "dial", "link" }, .quic_tests = true }, | 259 | .{ .name = "agent", .path = "src/cli/muxa.zig", .link_libc = true, .imports = &.{ "term", "sockpath", "quic", "xdg", "cliflags", "dial", "link" }, .quic_tests = true }, |
| 248 | .{ .name = "wsclient", .path = "test/wsclient.zig", .link_libc = true, .imports = &.{ "term", "script" } }, | 260 | .{ .name = "wsclient", .path = "test/wsclient.zig", .link_libc = true, .imports = &.{ "term", "script" }, .test_imports = &.{"engine"} }, |
| 249 | // Dialling, and what a chord means. The client is the only thing that | 261 | // Dialling, and what a chord means. The client is the only thing that |
| 250 | // predicts — the overlay is a local display decision and never becomes | 262 | // predicts — the overlay is a local display decision and never becomes |
| 251 | // state anybody else can see — but the predicting itself is interact's | 263 | // state anybody else can see — but the predicting itself is interact's |
| @@ -280,9 +292,12 @@ const mod_table = [_]ModSpec{ | |||
| 280 | // of those files is a file-in-multiple-modules compile error. `term` is | 292 | // of those files is a file-in-multiple-modules compile error. `term` is |
| 281 | // the children's as much as the root's: `term.replica` is the keyboard | 293 | // the children's as much as the root's: `term.replica` is the keyboard |
| 282 | // loop's alone, which the root never spells, and the painter takes | 294 | // loop's alone, which the root never spells, and the painter takes |
| 283 | // `term.engine` and `term.protocol`; the decoder and the key table they | 295 | // `term.grid` and `term.protocol`; the decoder and the key table they |
| 284 | // also want reach them through `client`'s seams. | 296 | // also want reach them through `client`'s seams. `engine` is a TEST |
| 285 | .{ .name = "wall", .path = "src/tui/wallview.zig", .link_libc = true, .imports = &.{ "term", "client", "proxy", "spawn", "client_os", "sockpath" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, | 297 | // grant only — the painter's and the loop's tests author a screen by |
| 298 | // feeding VT to an engine and mirroring it into a grid, and no | ||
| 299 | // production line here parses VT at all. | ||
| 300 | .{ .name = "wall", .path = "src/tui/wallview.zig", .link_libc = true, .imports = &.{ "term", "client", "proxy", "spawn", "client_os", "sockpath" }, .test_imports = &.{ "testtmp", "engine" }, .quic_tests = true }, | ||
| 286 | // ---- the one binary ---- | 301 | // ---- the one binary ---- |
| 287 | // Four words, one image — and one row: the daemon's entrypoint, the | 302 | // Four words, one image — and one row: the daemon's entrypoint, the |
| 288 | // client's and the hub's are CHILD FILES of the dispatcher, so a second | 303 | // client's and the hub's are CHILD FILES of the dispatcher, so a second |
| @@ -797,11 +812,11 @@ fn docGate(b: *std.Build, target: std.Build.ResolvedTarget, check_step: *std.Bui | |||
| 797 | /// of all: it carries every argument parser but muxa's, its mains being | 812 | /// of all: it carries every argument parser but muxa's, its mains being |
| 798 | /// child files — a test that is never built is not a test (decisions.md). | 813 | /// child files — a test that is never built is not a test (decisions.md). |
| 799 | const test_order = [_][]const u8{ | 814 | const test_order = [_][]const u8{ |
| 800 | "script", "cliflags", "testtmp", "server_os", "client_os", "spawn", | 815 | "script", "cliflags", "testtmp", "server_os", "client_os", "spawn", |
| 801 | "dial", "link", "quic", "webhub", "agent", "term", | 816 | "dial", "link", "quic", "webhub", "agent", "term", |
| 802 | "rawmode", "delaypipe", "render", "wsclient", "ptyclient", "pty", | 817 | "engine", "rawmode", "delaypipe", "render", "wsclient", "ptyclient", |
| 803 | "sockpath", "serve", "xdg", "proxy", "wall", "client", | 818 | "pty", "sockpath", "serve", "xdg", "proxy", "wall", |
| 804 | "daemon", "mux", | 819 | "client", "daemon", "mux", |
| 805 | }; | 820 | }; |
| 806 | 821 | ||
| 807 | comptime { | 822 | comptime { |
| @@ -897,7 +912,6 @@ pub fn build(b: *std.Build) void { | |||
| 897 | // that wiring actually uses (an unused local is a compile error). | 912 | // that wiring actually uses (an unused local is a compile error). |
| 898 | // Dep edges (ghostty) and build_options stay outside the table's | 913 | // Dep edges (ghostty) and build_options stay outside the table's |
| 899 | // jurisdiction, explicit. | 914 | // jurisdiction, explicit. |
| 900 | const term_mod = mods[comptime idxOf("term")]; | ||
| 901 | const mux_mod = mods[comptime idxOf("mux")]; | 915 | const mux_mod = mods[comptime idxOf("mux")]; |
| 902 | const agent_mod = mods[comptime idxOf("agent")]; | 916 | const agent_mod = mods[comptime idxOf("agent")]; |
| 903 | const rawmode_mod = mods[comptime idxOf("rawmode")]; | 917 | const rawmode_mod = mods[comptime idxOf("rawmode")]; |
| @@ -906,11 +920,12 @@ pub fn build(b: *std.Build) void { | |||
| 906 | const ptyclient_mod = mods[comptime idxOf("ptyclient")]; | 920 | const ptyclient_mod = mods[comptime idxOf("ptyclient")]; |
| 907 | const wsclient_mod = mods[comptime idxOf("wsclient")]; | 921 | const wsclient_mod = mods[comptime idxOf("wsclient")]; |
| 908 | 922 | ||
| 909 | // The dep is named by engine.zig, a file of the term module: an import | 923 | // The dep is named by engine.zig, the engine module's root: an import |
| 910 | // name is resolved in the module that owns the spelling file, so the | 924 | // name is resolved in the module that owns the spelling file, so the |
| 911 | // handle belongs on the component's root and nowhere else. | 925 | // handle belongs on that row and nowhere else. `term` used to carry it, |
| 926 | // which put a terminal emulator in every binary that held a replica. | ||
| 912 | if (ghostty_dep) |dep| { | 927 | if (ghostty_dep) |dep| { |
| 913 | term_mod.addImport("ghostty-vt", dep.module("ghostty-vt")); | 928 | mods[comptime idxOf("engine")].addImport("ghostty-vt", dep.module("ghostty-vt")); |
| 914 | } | 929 | } |
| 915 | // ONE module object, shared: the two rows link into one binary, and a | 930 | // ONE module object, shared: the two rows link into one binary, and a |
| 916 | // second instance of the same root file in one compilation is "file | 931 | // second instance of the same root file in one compilation is "file |
| @@ -953,8 +968,8 @@ pub fn build(b: *std.Build) void { | |||
| 953 | 968 | ||
| 954 | // ---- The wasm core (M-web Task 4) ---- | 969 | // ---- The wasm core (M-web Task 4) ---- |
| 955 | // A SECOND resolved target: modules are target-bound, so the wasm-clean | 970 | // A SECOND resolved target: modules are target-bound, so the wasm-clean |
| 956 | // row — the term component — and the ghostty dependency are | 971 | // row — the term component — is instantiated again against wasm32. It |
| 957 | // instantiated again against wasm32. Always ReleaseSmall — the artifact | 972 | // is the only dependency the core has. Always ReleaseSmall — the artifact |
| 958 | // is @embedFile'd into the one binary, and its Debug build is 3.7MB | 973 | // is @embedFile'd into the one binary, and its Debug build is 3.7MB |
| 959 | // against ReleaseSmall's 345KB (spike-measured). | 974 | // against ReleaseSmall's 345KB (spike-measured). |
| 960 | // Safety checks are the price; the native test suite runs the same | 975 | // Safety checks are the price; the native test suite runs the same |
| @@ -963,10 +978,6 @@ pub fn build(b: *std.Build) void { | |||
| 963 | .cpu_arch = .wasm32, | 978 | .cpu_arch = .wasm32, |
| 964 | .os_tag = .freestanding, | 979 | .os_tag = .freestanding, |
| 965 | }); | 980 | }); |
| 966 | const ghostty_wasm_dep = b.lazyDependency("ghostty", .{ | ||
| 967 | .target = wasm_target, | ||
| 968 | .optimize = .ReleaseSmall, | ||
| 969 | }); | ||
| 970 | // Rows with .wasm get wasm32 twins, imports rewired from the SAME table | 981 | // Rows with .wasm get wasm32 twins, imports rewired from the SAME table |
| 971 | // rows — one source of truth for both instantiations. wasm_core itself | 982 | // rows — one source of truth for both instantiations. wasm_core itself |
| 972 | // stays explicit: it is wasm-only, never in the native test loop. | 983 | // stays explicit: it is wasm-only, never in the native test loop. |
| @@ -982,10 +993,10 @@ pub fn build(b: *std.Build) void { | |||
| 982 | } | 993 | } |
| 983 | } | 994 | } |
| 984 | } | 995 | } |
| 996 | // No ghostty-vt twin at all: `term` links no emulator, so the browser | ||
| 997 | // core decodes cells and the wasm build has one fewer dependency to | ||
| 998 | // fetch and compile. | ||
| 985 | const term_wasm_mod = wasm_mods[comptime idxOf("term")].?; | 999 | const term_wasm_mod = wasm_mods[comptime idxOf("term")].?; |
| 986 | if (ghostty_wasm_dep) |dep| { | ||
| 987 | term_wasm_mod.addImport("ghostty-vt", dep.module("ghostty-vt")); | ||
| 988 | } | ||
| 989 | const wasm_core_mod = wasmMod(b, wasm_target, "src/client/wasm_core.zig"); | 1000 | const wasm_core_mod = wasmMod(b, wasm_target, "src/client/wasm_core.zig"); |
| 990 | wasm_core_mod.addImport("term", term_wasm_mod); | 1001 | wasm_core_mod.addImport("term", term_wasm_mod); |
| 991 | // Compile a tiny, never-embedded canary that calls the semantic decoder | 1002 | // Compile a tiny, never-embedded canary that calls the semantic decoder |
src/engine/delta.zig
| Old | New | ||
|---|---|---|---|
| @@ -5,7 +5,7 @@ | |||
| 5 | //! nothing but an engine. | 5 | //! nothing but an engine. |
| 6 | const std = @import("std"); | 6 | const std = @import("std"); |
| 7 | const Engine = @import("engine.zig").Engine; | 7 | const Engine = @import("engine.zig").Engine; |
| 8 | const proto = @import("protocol.zig"); | 8 | const proto = @import("term").protocol; |
| 9 | 9 | ||
| 10 | const Wyhash = std.hash.Wyhash; | 10 | const Wyhash = std.hash.Wyhash; |
| 11 | 11 | ||
| @@ -230,7 +230,7 @@ pub fn buildSnapshot(alloc: std.mem.Allocator, eng: *Engine, prefix: proto.Snaps | |||
| 230 | // Tests. A delta payload is rows of cells now, so the assertions decode it the | 230 | // Tests. A delta payload is rows of cells now, so the assertions decode it the |
| 231 | // way a client does — through grid.decodeRow — rather than searching bytes. | 231 | // way a client does — through grid.decodeRow — rather than searching bytes. |
| 232 | 232 | ||
| 233 | const grid = @import("grid.zig"); | 233 | const grid = @import("term").grid; |
| 234 | 234 | ||
| 235 | /// Apply every row of a delta payload into `g` and return the plain text, so | 235 | /// Apply every row of a delta payload into `g` and return the plain text, so |
| 236 | /// a test can say what the far side would be SHOWING after the frame. | 236 | /// a test can say what the far side would be SHOWING after the frame. |
src/engine/engine.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,13 +1,22 @@ | |||
| 1 | //! Authoritative headless terminal engine. Wraps ghostty-vt's Terminal | 1 | //! Authoritative headless terminal engine. Wraps ghostty-vt's Terminal |
| 2 | //! and TerminalStream behind the small surface mux needs. | 2 | //! and TerminalStream behind the small surface mux needs. |
| 3 | //! | ||
| 4 | //! This is the root of the `engine` module and the ONE file under src/ that | ||
| 5 | //! imports ghostty-vt. It sits above `term` rather than inside it so that a | ||
| 6 | //! client — the CLI wall, the browser core, the fixtures — can hold a | ||
| 7 | //! replica of a session without linking a terminal emulator. `delta.zig` is | ||
| 8 | //! its child: deciding which rows a client is missing needs the engine, and | ||
| 9 | //! nothing else does. | ||
| 3 | // folder rule 4 exemption: ghostty-vt's grid is fed and read in VT bytes — this module IS the VT, and its selection formatter emits SGR. | 10 | // folder rule 4 exemption: ghostty-vt's grid is fed and read in VT bytes — this module IS the VT, and its selection formatter emits SGR. |
| 4 | const std = @import("std"); | 11 | const std = @import("std"); |
| 5 | const vt = @import("ghostty-vt"); | 12 | const vt = @import("ghostty-vt"); |
| 6 | const proto = @import("protocol.zig"); | 13 | const proto = @import("term").protocol; |
| 7 | /// The client-side grid this engine is the oracle for. A direct file import | 14 | /// The client-side grid this engine is the oracle for. |
| 8 | /// rather than the `term` root, so `grid.zig` never pulls ghostty into the | 15 | const Grid = @import("term").grid.Grid; |
| 9 | /// wasm build by importing back. | 16 | |
| 10 | const Grid = @import("grid.zig").Grid; | 17 | /// The daemon's row-diffing, re-exported so `engine` is the whole of what a |
| 18 | /// daemon needs from this folder. | ||
| 19 | pub const delta = @import("delta.zig"); | ||
| 11 | 20 | ||
| 12 | /// MuxHandler's own `vt` method shadows the `vt` import inside its body, | 21 | /// MuxHandler's own `vt` method shadows the `vt` import inside its body, |
| 13 | /// so the dep types it names are spelled through these aliases. | 22 | /// so the dep types it names are spelled through these aliases. |
| @@ -282,16 +291,6 @@ pub const Engine = struct { | |||
| 282 | return self.viewportRows(0, @intCast(self.term.rows - 1)); | 291 | return self.viewportRows(0, @intCast(self.term.rows - 1)); |
| 283 | } | 292 | } |
| 284 | 293 | ||
| 285 | /// A selection spanning columns [x0, x1] of ONE viewport row. One row at a | ||
| 286 | /// time on purpose: a selection is a text RANGE, which is what a copy wants | ||
| 287 | /// and the opposite of what a per-row highlight does. | ||
| 288 | fn viewportSpan(self: *Engine, y: u16, x0: u16, x1: u16) ?vt.Selection { | ||
| 289 | const screen = self.term.screens.active; | ||
| 290 | const tl = screen.pages.pin(.{ .viewport = .{ .x = x0, .y = y } }) orelse return null; | ||
| 291 | const br = screen.pages.pin(.{ .viewport = .{ .x = x1, .y = y } }) orelse return null; | ||
| 292 | return vt.Selection.init(tl, br, false); | ||
| 293 | } | ||
| 294 | |||
| 295 | /// No palette/mode side effects, so a host terminal keeps its theme. | 294 | /// No palette/mode side effects, so a host terminal keeps its theme. |
| 296 | /// Null writes nothing. | 295 | /// Null writes nothing. |
| 297 | fn writeSelection( | 296 | fn writeSelection( |
| @@ -325,7 +324,7 @@ pub const Engine = struct { | |||
| 325 | } | 324 | } |
| 326 | 325 | ||
| 327 | /// Viewport only, SGR preserved, no palette/mode side effects. History | 326 | /// Viewport only, SGR preserved, no palette/mode side effects. History |
| 328 | /// stays daemon-side, fetched by `dumpScrollback`. | 327 | /// stays daemon-side, fetched by `encodeScrollback`. |
| 329 | pub fn dumpVt(self: *Engine, alloc: std.mem.Allocator) ![]u8 { | 328 | pub fn dumpVt(self: *Engine, alloc: std.mem.Allocator) ![]u8 { |
| 330 | return self.formatSelection(alloc, "", self.viewportSelection()); | 329 | return self.formatSelection(alloc, "", self.viewportSelection()); |
| 331 | } | 330 | } |
| @@ -339,107 +338,6 @@ pub const Engine = struct { | |||
| 339 | return self.formatSelection(alloc, "", self.viewportRows(y0, @intCast(self.term.rows - 1))); | 338 | return self.formatSelection(alloc, "", self.viewportRows(y0, @intCast(self.term.rows - 1))); |
| 340 | } | 339 | } |
| 341 | 340 | ||
| 342 | /// One viewport row (0-based), self-contained: leading SGR reset, no | ||
| 343 | /// trailing newline. Delta payloads are built from these. | ||
| 344 | pub fn dumpVtRow(self: *Engine, alloc: std.mem.Allocator, y: u16) ![]u8 { | ||
| 345 | std.debug.assert(y < self.term.rows); | ||
| 346 | return self.formatSelection(alloc, "\x1b[0m", self.viewportRows(y, y)); | ||
| 347 | } | ||
| 348 | |||
| 349 | /// Where a row is painted: `col_off` is the screen column its grid column | ||
| 350 | /// zero lands on, `cols` how many the pane shows. Neither is optional — the | ||
| 351 | /// dumps address columns with CHA, which is screen-absolute. | ||
| 352 | pub const RowView = struct { col_off: u16, cols: u16 }; | ||
| 353 | |||
| 354 | /// The last grid column of row `y` that fits in `view`, or null when not one | ||
| 355 | /// character does. Inwards, unlike `snapWide`: a pane edge is a wall, and | ||
| 356 | /// half a wide glyph past it is a column stolen from the neighbour. | ||
| 357 | pub fn clipCol(self: *Engine, y: u16, view: RowView) ?u16 { | ||
| 358 | if (view.cols == 0 or self.term.cols == 0) return null; | ||
| 359 | var hi: u16 = @min(view.cols - 1, @as(u16, @intCast(self.term.cols - 1))); | ||
| 360 | const screen = self.term.screens.active; | ||
| 361 | if (screen.pages.pin(.{ .viewport = .{ .x = hi, .y = y } })) |p| { | ||
| 362 | if (p.rowAndCell().cell.wide == .wide) { | ||
| 363 | if (hi == 0) return null; | ||
| 364 | hi -= 1; | ||
| 365 | } | ||
| 366 | } | ||
| 367 | return hi; | ||
| 368 | } | ||
| 369 | |||
| 370 | /// `dumpVtRow` bounded to the pane's own columns. | ||
| 371 | pub fn dumpVtRowClipped(self: *Engine, alloc: std.mem.Allocator, y: u16, view: RowView) ![]u8 { | ||
| 372 | std.debug.assert(y < self.term.rows); | ||
| 373 | const hi = self.clipCol(y, view) orelse return alloc.dupe(u8, "\x1b[0m"); | ||
| 374 | return self.formatSelection(alloc, "\x1b[0m", self.viewportSpan(y, 0, hi)); | ||
| 375 | } | ||
| 376 | |||
| 377 | /// Widen a column span to whole characters. A wide cell is two columns and a | ||
| 378 | /// drag stops where the hand stopped, so a span may cut one in half — | ||
| 379 | /// ghostty then emits the WHOLE character, which in `dumpVtRowSpan`'s three | ||
| 380 | /// pieces emits a straddling one TWICE. Outwards: half a character under the | ||
| 381 | /// pointer means the character is under the pointer. | ||
| 382 | pub fn snapWide(self: *Engine, y: u16, from: u16, to: u16) struct { from: u16, to: u16 } { | ||
| 383 | const screen = self.term.screens.active; | ||
| 384 | const last: u16 = @intCast(self.term.cols - 1); | ||
| 385 | var lo = from; | ||
| 386 | var hi = to; | ||
| 387 | if (lo > 0) { | ||
| 388 | if (screen.pages.pin(.{ .viewport = .{ .x = lo, .y = y } })) |p| { | ||
| 389 | if (p.rowAndCell().cell.wide == .spacer_tail) lo -= 1; | ||
| 390 | } | ||
| 391 | } | ||
| 392 | if (hi < last) { | ||
| 393 | if (screen.pages.pin(.{ .viewport = .{ .x = hi, .y = y } })) |p| { | ||
| 394 | if (p.rowAndCell().cell.wide == .wide) hi += 1; | ||
| 395 | } | ||
| 396 | } | ||
| 397 | return .{ .from = lo, .to = hi }; | ||
| 398 | } | ||
| 399 | |||
| 400 | /// `dumpVtRow`, with grid columns [from, to] painted inverted. No VT | ||
| 401 | /// sequence inverts part of a row already on screen, so a highlighted row is | ||
| 402 | /// re-emitted rather than decorated. | ||
| 403 | /// | ||
| 404 | /// Three pieces joined by CHA rather than by counting characters: the | ||
| 405 | /// formatter trims trailing whitespace, so the head's byte length says | ||
| 406 | /// nothing about where it left the cursor. CHA is SCREEN-absolute, hence | ||
| 407 | /// `view.col_off`. The span is emitted PLAIN, or a cell that kept its colour | ||
| 408 | /// reads as a hole; the inversion closes with a full reset, which is what | ||
| 409 | /// makes the tail's from-default assumption true. | ||
| 410 | pub fn dumpVtRowSpan(self: *Engine, alloc: std.mem.Allocator, y: u16, from: u16, to: u16, view: RowView) ![]u8 { | ||
| 411 | std.debug.assert(y < self.term.rows); | ||
| 412 | std.debug.assert(from <= to); | ||
| 413 | std.debug.assert(to < self.term.cols); | ||
| 414 | const last = self.clipCol(y, view) orelse return alloc.dupe(u8, "\x1b[0m"); | ||
| 415 | // The whole highlight fell outside the pane: what is left of the row | ||
| 416 | // inside it carries no inversion at all. | ||
| 417 | if (from > last) return self.dumpVtRowClipped(alloc, y, view); | ||
| 418 | const snapped = self.snapWide(y, from, @min(to, last)); | ||
| 419 | const lo = snapped.from; | ||
| 420 | const hi = snapped.to; | ||
| 421 | |||
| 422 | var aw: std.Io.Writer.Allocating = .init(alloc); | ||
| 423 | defer aw.deinit(); | ||
| 424 | try aw.writer.writeAll("\x1b[0m"); | ||
| 425 | if (lo > 0) try self.writeSelection(&aw.writer, .vt, self.viewportSpan(y, 0, lo - 1)); | ||
| 426 | try aw.writer.print("\x1b[{d}G\x1b[0m\x1b[7m", .{lo + 1 + view.col_off}); | ||
| 427 | // `trim = false`: a drag past the end of a short line selects the | ||
| 428 | // blanks after it, and a highlight that stopped at the last glyph | ||
| 429 | // would be narrower than the one the hand made. | ||
| 430 | try self.writeSelection( | ||
| 431 | &aw.writer, | ||
| 432 | .{ .emit = .plain, .trim = false }, | ||
| 433 | self.viewportSpan(y, lo, hi), | ||
| 434 | ); | ||
| 435 | try aw.writer.writeAll("\x1b[0m"); | ||
| 436 | if (hi < last) { | ||
| 437 | try aw.writer.print("\x1b[{d}G", .{hi + 2 + view.col_off}); | ||
| 438 | try self.writeSelection(&aw.writer, .vt, self.viewportSpan(y, hi + 1, last)); | ||
| 439 | } | ||
| 440 | return try aw.toOwnedSlice(); | ||
| 441 | } | ||
| 442 | |||
| 443 | /// Full terminal state as a canonical VT byte sequence — the Snapshot | 341 | /// Full terminal state as a canonical VT byte sequence — the Snapshot |
| 444 | /// payload body. Feeding it into a fresh engine of the same size | 342 | /// payload body. Feeding it into a fresh engine of the same size |
| 445 | /// reconstructs the state. | 343 | /// reconstructs the state. |
| @@ -541,27 +439,6 @@ pub const Engine = struct { | |||
| 541 | return @intCast(pt.screen.y); | 439 | return @intCast(pt.screen.y); |
| 542 | } | 440 | } |
| 543 | 441 | ||
| 544 | /// Styled dump of screen-space rows [start, start+count) on the active | ||
| 545 | /// screen (row 0 = oldest retained history row). Ranges are clamped to | ||
| 546 | /// what exists. Begins with an SGR reset so chunks are self-contained. | ||
| 547 | pub fn dumpScrollback(self: *Engine, alloc: std.mem.Allocator, start: u32, count: u16) ![]u8 { | ||
| 548 | const screen = self.term.screens.active; | ||
| 549 | const total: u32 = self.historyRows() + self.term.rows; | ||
| 550 | const first = @min(start, total -| 1); | ||
| 551 | const last = @min(first + count -| 1, total -| 1); | ||
| 552 | |||
| 553 | const sel: ?vt.Selection = sel: { | ||
| 554 | const tl = screen.pages.pin(.{ .screen = .{ .x = 0, .y = first } }) orelse | ||
| 555 | break :sel null; | ||
| 556 | const br = screen.pages.pin(.{ .screen = .{ | ||
| 557 | .x = @intCast(self.term.cols - 1), | ||
| 558 | .y = last, | ||
| 559 | } }) orelse break :sel null; | ||
| 560 | break :sel vt.Selection.init(tl, br, false); | ||
| 561 | }; | ||
| 562 | return self.formatSelection(alloc, "\x1b[0m", sel); | ||
| 563 | } | ||
| 564 | |||
| 565 | pub const EncodedRows = struct { first: u32, count: u16, bytes: []u8 }; | 442 | pub const EncodedRows = struct { first: u32, count: u16, bytes: []u8 }; |
| 566 | 443 | ||
| 567 | fn packColor(col: anytype) u32 { | 444 | fn packColor(col: anytype) u32 { |
| @@ -1258,32 +1135,6 @@ test "Engine: historyRows counts scrolled-off lines, zero on alt screen" { | |||
| 1258 | try std.testing.expectEqual(@as(u32, 77), e.historyRows()); | 1135 | try std.testing.expectEqual(@as(u32, 77), e.historyRows()); |
| 1259 | } | 1136 | } |
| 1260 | 1137 | ||
| 1261 | test "Engine: dumpScrollback serves styled history rows by screen-space range" { | ||
| 1262 | const alloc = std.testing.allocator; | ||
| 1263 | var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 1264 | defer e.deinit(); | ||
| 1265 | |||
| 1266 | var i: usize = 1; | ||
| 1267 | while (i <= 100) : (i += 1) { | ||
| 1268 | var line: [48]u8 = undefined; | ||
| 1269 | e.feed(std.fmt.bufPrint(&line, "\x1b[3{d}mline-{d}\x1b[0m\r\n", .{ i % 8, i }) catch unreachable); | ||
| 1270 | } | ||
| 1271 | |||
| 1272 | // Rows 0..23 in screen space are the oldest 24 rows: line-1..line-24. | ||
| 1273 | const chunk = try e.dumpScrollback(alloc, 0, 24); | ||
| 1274 | defer alloc.free(chunk); | ||
| 1275 | try std.testing.expect(std.mem.indexOf(u8, chunk, "line-1\x1b") != null); | ||
| 1276 | try std.testing.expect(std.mem.indexOf(u8, chunk, "line-24") != null); | ||
| 1277 | try std.testing.expect(std.mem.indexOf(u8, chunk, "line-25") == null); | ||
| 1278 | // Styled: SGR survives (the formatter canonicalizes 31 -> 38;5;1). | ||
| 1279 | try std.testing.expect(std.mem.indexOf(u8, chunk, "\x1b[38;5;1m") != null); | ||
| 1280 | |||
| 1281 | // A range reaching past the end clamps instead of erroring. | ||
| 1282 | const tail = try e.dumpScrollback(alloc, 77 + 20, 24); | ||
| 1283 | defer alloc.free(tail); | ||
| 1284 | try std.testing.expect(std.mem.indexOf(u8, tail, "line-100") != null); | ||
| 1285 | } | ||
| 1286 | |||
| 1287 | test "Engine: dumps and snapshots cover the viewport, never scrollback" { | 1138 | test "Engine: dumps and snapshots cover the viewport, never scrollback" { |
| 1288 | const alloc = std.testing.allocator; | 1139 | const alloc = std.testing.allocator; |
| 1289 | var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | 1140 | var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); |
| @@ -1312,145 +1163,6 @@ test "Engine: dumps and snapshots cover the viewport, never scrollback" { | |||
| 1312 | try std.testing.expect(std.mem.indexOf(u8, plain, "line-100") != null); | 1163 | try std.testing.expect(std.mem.indexOf(u8, plain, "line-100") != null); |
| 1313 | } | 1164 | } |
| 1314 | 1165 | ||
| 1315 | test "Engine: dumpVtRow dumps one styled viewport row, self-contained" { | ||
| 1316 | const alloc = std.testing.allocator; | ||
| 1317 | var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 1318 | defer e.deinit(); | ||
| 1319 | |||
| 1320 | e.feed("plain row\r\n\x1b[1;31mred row\x1b[0m\r\nthird"); | ||
| 1321 | |||
| 1322 | const r0 = try e.dumpVtRow(alloc, 0); | ||
| 1323 | defer alloc.free(r0); | ||
| 1324 | try std.testing.expect(std.mem.indexOf(u8, r0, "plain row") != null); | ||
| 1325 | try std.testing.expect(std.mem.indexOf(u8, r0, "red row") == null); | ||
| 1326 | try std.testing.expect(std.mem.startsWith(u8, r0, "\x1b[0m")); | ||
| 1327 | |||
| 1328 | const r1 = try e.dumpVtRow(alloc, 1); | ||
| 1329 | defer alloc.free(r1); | ||
| 1330 | // Golden bytes: explicit reset, the formatter's own reset, then bold + | ||
| 1331 | // red (canonicalized 31 -> 38;5;1), and no row terminator. | ||
| 1332 | try std.testing.expectEqualStrings("\x1b[0m\x1b[0m\x1b[1m\x1b[38;5;1mred row\x1b[0m", r1); | ||
| 1333 | try std.testing.expect(std.mem.indexOfAny(u8, r1, "\r\n") == null); | ||
| 1334 | |||
| 1335 | // A row past the content is empty (just the reset prefix). | ||
| 1336 | const r9 = try e.dumpVtRow(alloc, 9); | ||
| 1337 | defer alloc.free(r9); | ||
| 1338 | try std.testing.expectEqualStrings("\x1b[0m", r9); | ||
| 1339 | } | ||
| 1340 | |||
| 1341 | test "Engine: dumpVtRowSpan inverts the columns asked for and no others" { | ||
| 1342 | const alloc = std.testing.allocator; | ||
| 1343 | var e = try Engine.init(alloc, .{ .cols = 20, .rows = 4 }); | ||
| 1344 | defer e.deinit(); | ||
| 1345 | e.feed("abcdefghij"); | ||
| 1346 | |||
| 1347 | const row = try e.dumpVtRowSpan(alloc, 0, 2, 5, .{ .col_off = 0, .cols = @intCast(e.term.cols) }); | ||
| 1348 | defer alloc.free(row); | ||
| 1349 | // Self-contained, exactly like `dumpVtRow`: the painters emit this | ||
| 1350 | // after a `\x1b[2K` and nothing else sets the row's state. | ||
| 1351 | try std.testing.expect(std.mem.startsWith(u8, row, "\x1b[0m")); | ||
| 1352 | // The three pieces, in order, each addressed by COLUMN rather than | ||
| 1353 | // reached by counting characters. | ||
| 1354 | const inv = std.mem.indexOf(u8, row, "\x1b[7m").?; | ||
| 1355 | try std.testing.expect(std.mem.indexOf(u8, row, "ab").? < inv); | ||
| 1356 | try std.testing.expect(std.mem.indexOf(u8, row, "cdef").? > inv); | ||
| 1357 | try std.testing.expect(std.mem.indexOf(u8, row, "ghij").? > inv); | ||
| 1358 | try std.testing.expect(std.mem.indexOf(u8, row, "\x1b[3G") != null); | ||
| 1359 | try std.testing.expect(std.mem.indexOf(u8, row, "\x1b[7G") != null); | ||
| 1360 | // The inversion ends before the tail does. | ||
| 1361 | try std.testing.expect(std.mem.indexOf(u8, row, "\x1b[0m\x1b[7G").? < | ||
| 1362 | std.mem.indexOf(u8, row, "ghij").?); | ||
| 1363 | // One row, no terminator — the caller placed the cursor. | ||
| 1364 | try std.testing.expect(std.mem.indexOfAny(u8, row, "\r\n") == null); | ||
| 1365 | } | ||
| 1366 | |||
| 1367 | test "Engine: dumpVtRowSpan neutralises the row's own styling inside the span" { | ||
| 1368 | const alloc = std.testing.allocator; | ||
| 1369 | var e = try Engine.init(alloc, .{ .cols = 20, .rows = 4 }); | ||
| 1370 | defer e.deinit(); | ||
| 1371 | // Columns 0-1 plain, 2-5 bold red, 6-7 plain again. | ||
| 1372 | e.feed("aa\x1b[1;31mRRRR\x1b[0mbb"); | ||
| 1373 | |||
| 1374 | // A span cutting THROUGH the styled run: two of its cells inside the | ||
| 1375 | // inversion, one on either side. | ||
| 1376 | const row = try e.dumpVtRowSpan(alloc, 0, 3, 4, .{ .col_off = 0, .cols = @intCast(e.term.cols) }); | ||
| 1377 | defer alloc.free(row); | ||
| 1378 | const inv = std.mem.indexOf(u8, row, "\x1b[7m").? + "\x1b[7m".len; | ||
| 1379 | const close = std.mem.indexOfPos(u8, row, inv, "\x1b[0m").?; | ||
| 1380 | // Inside: the cells' text and NOT one byte of their SGR. A styled cell | ||
| 1381 | // that kept its own colour under the inversion would read as a | ||
| 1382 | // differently-coloured hole in the highlight. | ||
| 1383 | try std.testing.expectEqualStrings("RR", row[inv..close]); | ||
| 1384 | // Outside: the same run still carries its colour, on both sides. | ||
| 1385 | try std.testing.expect(std.mem.indexOf(u8, row[0..inv], "\x1b[38;5;1m") != null); | ||
| 1386 | try std.testing.expect(std.mem.indexOf(u8, row[close..], "\x1b[38;5;1m") != null); | ||
| 1387 | try std.testing.expect(std.mem.indexOf(u8, row[close..], "bb") != null); | ||
| 1388 | } | ||
| 1389 | |||
| 1390 | test "Engine: dumpVtRowSpan over a whole row has no unhighlighted piece" { | ||
| 1391 | const alloc = std.testing.allocator; | ||
| 1392 | var e = try Engine.init(alloc, .{ .cols = 10, .rows = 2 }); | ||
| 1393 | defer e.deinit(); | ||
| 1394 | e.feed("whole"); | ||
| 1395 | |||
| 1396 | const row = try e.dumpVtRowSpan(alloc, 0, 0, 9, .{ .col_off = 0, .cols = @intCast(e.term.cols) }); | ||
| 1397 | defer alloc.free(row); | ||
| 1398 | // Golden bytes: no head, no tail, and no CHA back out to a tail that | ||
| 1399 | // is not there. The trailing five columns of this row were never | ||
| 1400 | // written, so there are no cells to invert past the text — see the | ||
| 1401 | // blank-cell test for the half of that rule which is not obvious. | ||
| 1402 | try std.testing.expectEqualStrings("\x1b[0m\x1b[1G\x1b[0m\x1b[7mwhole\x1b[0m", row); | ||
| 1403 | } | ||
| 1404 | |||
| 1405 | test "Engine: dumpVtRowSpan leaves wide cells to the machinery that owns them" { | ||
| 1406 | const alloc = std.testing.allocator; | ||
| 1407 | var e = try Engine.init(alloc, .{ .cols = 20, .rows = 2 }); | ||
| 1408 | defer e.deinit(); | ||
| 1409 | // Each glyph is two columns wide, so 漢 is columns 0-1 and 字 is 2-3. | ||
| 1410 | e.feed("漢字tail"); | ||
| 1411 | |||
| 1412 | // Half of 字, which ghostty's Selection resolves to the whole glyph — | ||
| 1413 | // the reason this reuses it rather than walking cells here. | ||
| 1414 | const row = try e.dumpVtRowSpan(alloc, 0, 3, 3, .{ .col_off = 0, .cols = @intCast(e.term.cols) }); | ||
| 1415 | defer alloc.free(row); | ||
| 1416 | const inv = std.mem.indexOf(u8, row, "\x1b[7m").? + "\x1b[7m".len; | ||
| 1417 | const close = std.mem.indexOfPos(u8, row, inv, "\x1b[0m").?; | ||
| 1418 | try std.testing.expectEqualStrings("字", row[inv..close]); | ||
| 1419 | try std.testing.expect(std.mem.indexOf(u8, row[0..inv], "漢") != null); | ||
| 1420 | try std.testing.expect(std.mem.indexOf(u8, row[close..], "tail") != null); | ||
| 1421 | } | ||
| 1422 | |||
| 1423 | test "Engine: dumpVtRowSpan highlights blank cells, and stops where the row does" { | ||
| 1424 | const alloc = std.testing.allocator; | ||
| 1425 | var e = try Engine.init(alloc, .{ .cols = 12, .rows = 2 }); | ||
| 1426 | defer e.deinit(); | ||
| 1427 | e.feed("ab cd"); | ||
| 1428 | |||
| 1429 | // A drag across the gap in a line: the formatter trims trailing | ||
| 1430 | // whitespace BY DEFAULT, so without `trim = false` the inversion would | ||
| 1431 | // stop at the `b` and show a selection narrower than the hand made. | ||
| 1432 | const gap = try e.dumpVtRowSpan(alloc, 0, 0, 5, .{ .col_off = 0, .cols = @intCast(e.term.cols) }); | ||
| 1433 | defer alloc.free(gap); | ||
| 1434 | const inv = std.mem.indexOf(u8, gap, "\x1b[7m").? + "\x1b[7m".len; | ||
| 1435 | const close = std.mem.indexOfPos(u8, gap, inv, "\x1b[0m").?; | ||
| 1436 | try std.testing.expectEqualStrings("ab ", gap[inv..close]); | ||
| 1437 | |||
| 1438 | // The other half, measured rather than assumed: past the last cell the | ||
| 1439 | // row HAS, there is nothing to invert — a screen row is only as wide | ||
| 1440 | // as what was written into it. So a drag off the end of a line shows a | ||
| 1441 | // highlight that ends at the text, whatever column the pointer reached. | ||
| 1442 | var short = try Engine.init(alloc, .{ .cols = 12, .rows = 2 }); | ||
| 1443 | defer short.deinit(); | ||
| 1444 | short.feed("ab"); | ||
| 1445 | const past = try short.dumpVtRowSpan(alloc, 0, 0, 5, .{ .col_off = 0, .cols = @intCast(short.term.cols) }); | ||
| 1446 | defer alloc.free(past); | ||
| 1447 | try std.testing.expectEqualStrings("\x1b[0m\x1b[1G\x1b[0m\x1b[7mab\x1b[0m\x1b[7G", past); | ||
| 1448 | // ...and a row with nothing in it at all inverts nothing. | ||
| 1449 | const blank = try short.dumpVtRowSpan(alloc, 1, 0, 5, .{ .col_off = 0, .cols = @intCast(short.term.cols) }); | ||
| 1450 | defer alloc.free(blank); | ||
| 1451 | try std.testing.expectEqualStrings("\x1b[0m\x1b[1G\x1b[0m\x1b[7m\x1b[0m\x1b[7G", blank); | ||
| 1452 | } | ||
| 1453 | |||
| 1454 | test "Engine: onAltScreen reflects 1049 switches" { | 1166 | test "Engine: onAltScreen reflects 1049 switches" { |
| 1455 | const alloc = std.testing.allocator; | 1167 | const alloc = std.testing.allocator; |
| 1456 | var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | 1168 | var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); |
| @@ -1744,61 +1456,10 @@ test "Engine: clearing side events frees their payloads" { | |||
| 1744 | // codegen) that have nothing to do with mux's own code. | 1456 | // codegen) that have nothing to do with mux's own code. |
| 1745 | test { | 1457 | test { |
| 1746 | std.testing.refAllDecls(@This()); | 1458 | std.testing.refAllDecls(@This()); |
| 1747 | } | 1459 | // The child file's own tests. `refAllDecls` names a decl without |
| 1748 | 1460 | // reaching its tests, so without this line every test in delta.zig | |
| 1749 | test "engine: a span boundary inside a wide cell does not shift the row" { | 1461 | // passes by not running. |
| 1750 | const alloc = std.testing.allocator; | 1462 | _ = delta; |
| 1751 | var a = try Engine.init(alloc, .{ .cols = 20, .rows = 2 }); | ||
| 1752 | defer a.deinit(); | ||
| 1753 | // Two wide cells with narrow text either side: every boundary in the | ||
| 1754 | // middle of the row lands either on a wide cell's first half or on its | ||
| 1755 | // spacer tail, which is the distinction under test. | ||
| 1756 | a.feed("ab\u{6f22}\u{5b57}cd"); | ||
| 1757 | |||
| 1758 | try expectSpansAgree(alloc, a, 20); | ||
| 1759 | |||
| 1760 | // The third `Wide` variant: a wide character that does not fit leaves a | ||
| 1761 | // SPACER HEAD in the last column and moves to the next row. The formatter | ||
| 1762 | // skips a row whose span starts on one, so a boundary there blanks it. | ||
| 1763 | var c = try Engine.init(alloc, .{ .cols = 20, .rows = 3 }); | ||
| 1764 | defer c.deinit(); | ||
| 1765 | c.feed("aaaaaaaaaaaaaaaaaaa\u{6f22}"); | ||
| 1766 | try expectSpansAgree(alloc, c, 20); | ||
| 1767 | } | ||
| 1768 | |||
| 1769 | /// Every span of row 0 must repaint that row exactly as it stands: a span | ||
| 1770 | /// changes which columns are INVERTED and nothing else, so the row's plain text | ||
| 1771 | /// through a fresh engine comes back identical whatever the boundaries were. | ||
| 1772 | fn expectSpansAgree(alloc: std.mem.Allocator, eng: *Engine, cols: u16) !void { | ||
| 1773 | const full = try eng.dumpPlain(alloc); | ||
| 1774 | defer alloc.free(full); | ||
| 1775 | const want = firstLine(full); | ||
| 1776 | |||
| 1777 | var bad: usize = 0; | ||
| 1778 | var from: u16 = 0; | ||
| 1779 | while (from < cols) : (from += 1) { | ||
| 1780 | var to: u16 = from; | ||
| 1781 | while (to < cols) : (to += 1) { | ||
| 1782 | const span = try eng.dumpVtRowSpan(alloc, 0, from, to, .{ .col_off = 0, .cols = @intCast(eng.term.cols) }); | ||
| 1783 | defer alloc.free(span); | ||
| 1784 | var b = try Engine.init(alloc, .{ .cols = cols, .rows = @intCast(eng.term.rows) }); | ||
| 1785 | defer b.deinit(); | ||
| 1786 | b.feed("\x1b[1;1H\x1b[2K"); | ||
| 1787 | b.feed(span); | ||
| 1788 | const got_full = try b.dumpPlain(alloc); | ||
| 1789 | defer alloc.free(got_full); | ||
| 1790 | const got = firstLine(got_full); | ||
| 1791 | if (!std.mem.eql(u8, want, got)) { | ||
| 1792 | bad += 1; | ||
| 1793 | std.debug.print("from={d} to={d}\n want |{s}|\n got |{s}|\n", .{ from, to, want, got }); | ||
| 1794 | } | ||
| 1795 | } | ||
| 1796 | } | ||
| 1797 | try std.testing.expectEqual(@as(usize, 0), bad); | ||
| 1798 | } | ||
| 1799 | |||
| 1800 | fn firstLine(text: []const u8) []const u8 { | ||
| 1801 | return text[0 .. std.mem.indexOfScalar(u8, text, '\n') orelse text.len]; | ||
| 1802 | } | 1463 | } |
| 1803 | 1464 | ||
| 1804 | test "mode 2048: setting in-band size reports answers with the size at once" { | 1465 | test "mode 2048: setting in-band size reports answers with the size at once" { |
| @@ -1911,7 +1572,7 @@ test "encodeViewportRow: an empty row is ncells 0 and nothing else" { | |||
| 1911 | try std.testing.expectEqualSlices(u8, &[_]u8{ 0, 0 }, row); | 1572 | try std.testing.expectEqualSlices(u8, &[_]u8{ 0, 0 }, row); |
| 1912 | } | 1573 | } |
| 1913 | 1574 | ||
| 1914 | test "encodeScrollback: clamps like dumpScrollback and returns dense rows" { | 1575 | test "encodeScrollback: clamps a range past the end and returns dense rows" { |
| 1915 | const alloc = std.testing.allocator; | 1576 | const alloc = std.testing.allocator; |
| 1916 | var e = try Engine.init(alloc, .{ .cols = 8, .rows = 2, .max_scrollback = 10 }); | 1577 | var e = try Engine.init(alloc, .{ .cols = 8, .rows = 2, .max_scrollback = 10 }); |
| 1917 | defer e.deinit(); | 1578 | defer e.deinit(); |
| @@ -1930,17 +1591,6 @@ test "encodeScrollback: clamps like dumpScrollback and returns dense rows" { | |||
| 1930 | try std.testing.expectEqual(@as(u16, 1), tail.count); | 1591 | try std.testing.expectEqual(@as(u16, 1), tail.count); |
| 1931 | } | 1592 | } |
| 1932 | 1593 | ||
| 1933 | fn vtBytes(alloc: std.mem.Allocator, e: *Engine) !usize { | ||
| 1934 | var n: usize = 0; | ||
| 1935 | var y: u16 = 0; | ||
| 1936 | while (y < e.term.rows) : (y += 1) { | ||
| 1937 | const b = try e.dumpVtRow(alloc, y); | ||
| 1938 | defer alloc.free(b); | ||
| 1939 | n += b.len; | ||
| 1940 | } | ||
| 1941 | return n; | ||
| 1942 | } | ||
| 1943 | |||
| 1944 | fn cellBytes(alloc: std.mem.Allocator, e: *Engine) !usize { | 1594 | fn cellBytes(alloc: std.mem.Allocator, e: *Engine) !usize { |
| 1945 | var n: usize = 0; | 1595 | var n: usize = 0; |
| 1946 | var y: u16 = 0; | 1596 | var y: u16 = 0; |
| @@ -1952,7 +1602,11 @@ fn cellBytes(alloc: std.mem.Allocator, e: *Engine) !usize { | |||
| 1952 | return n; | 1602 | return n; |
| 1953 | } | 1603 | } |
| 1954 | 1604 | ||
| 1955 | test "cells: wire size vs VT rows (measurement; the spec's gate reads this)" { | 1605 | // The VT half of this measurement is gone with the VT row dumps it called. |
| 1606 | // Its figures, and the ratios the spec's gate was decided on, were recorded | ||
| 1607 | // in docs/decisions.md on 2026-09-04; what stays is the cell size itself, so | ||
| 1608 | // a change to the encoder that doubles a screen still shows up in a run. | ||
| 1609 | test "cells: wire size per screen (measurement; the VT comparison it was decided against is in decisions.md, 2026-09-04)" { | ||
| 1956 | const alloc = std.testing.allocator; | 1610 | const alloc = std.testing.allocator; |
| 1957 | const Screen = struct { name: []const u8, feed: []const u8 }; | 1611 | const Screen = struct { name: []const u8, feed: []const u8 }; |
| 1958 | const prose = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor in\r\n" ** 24; | 1612 | const prose = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor in\r\n" ** 24; |
| @@ -1991,9 +1645,8 @@ test "cells: wire size vs VT rows (measurement; the spec's gate reads this)" { | |||
| 1991 | var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | 1645 | var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); |
| 1992 | defer e.deinit(); | 1646 | defer e.deinit(); |
| 1993 | e.feed(s.feed); | 1647 | e.feed(s.feed); |
| 1994 | const v = try vtBytes(alloc, e); | ||
| 1995 | const c = try cellBytes(alloc, e); | 1648 | const c = try cellBytes(alloc, e); |
| 1996 | std.debug.print("\ncells-measure {s}: vt={d} cells={d} ratio={d:.2}\n", .{ s.name, v, c, @as(f64, @floatFromInt(c)) / @as(f64, @floatFromInt(v)) }); | 1649 | std.debug.print("\ncells-measure {s}: cells={d}\n", .{ s.name, c }); |
| 1997 | } | 1650 | } |
| 1998 | } | 1651 | } |
| 1999 | 1652 | ||
| @@ -2001,9 +1654,9 @@ test "cells: wire size vs VT rows (measurement; the spec's gate reads this)" { | |||
| 2001 | /// the form a client can hold. ghostty dumps with `trim = false`, so a space | 1654 | /// the form a client can hold. ghostty dumps with `trim = false`, so a space |
| 2002 | /// a program actually wrote at the end of a row survives into | 1655 | /// a program actually wrote at the end of a row survives into |
| 2003 | /// `Engine.dumpPlain`; no replica ever held one, because the VT formatter | 1656 | /// `Engine.dumpPlain`; no replica ever held one, because the VT formatter |
| 2004 | /// that fed the old wire trims trailing whitespace (see `dumpVtRowSpan`) and | 1657 | /// that fed the old wire trimmed trailing whitespace, and the e2e convergence |
| 2005 | /// the e2e convergence diff strips it as a formatting difference between two | 1658 | /// diff strips it as a formatting difference between two correct grids. The |
| 2006 | /// correct grids. The grid trims per row for the same reason, so the oracle | 1659 | /// grid trims per row for the same reason, so the oracle |
| 2007 | /// compares through the same trim rather than pretending the two dumps agree | 1660 | /// compares through the same trim rather than pretending the two dumps agree |
| 2008 | /// on bytes nothing downstream distinguishes. | 1661 | /// on bytes nothing downstream distinguishes. |
| 2009 | fn trimRowTails(alloc: std.mem.Allocator, s: []const u8) ![]const u8 { | 1662 | fn trimRowTails(alloc: std.mem.Allocator, s: []const u8) ![]const u8 { |
| @@ -2051,36 +1704,3 @@ test "grid oracle: the grid's dumpPlain and cursor agree with the engine's for e | |||
| 2051 | try std.testing.expectEqual(e.cursorPos().y, g.cursor.y); | 1704 | try std.testing.expectEqual(e.cursorPos().y, g.cursor.y); |
| 2052 | } | 1705 | } |
| 2053 | } | 1706 | } |
| 2054 | |||
| 2055 | test "grid oracle: clipCol and snapWide agree with the engine's on rows with wide glyphs" { | ||
| 2056 | const alloc = std.testing.allocator; | ||
| 2057 | var e = try Engine.init(alloc, .{ .cols = 10, .rows = 2 }); | ||
| 2058 | defer e.deinit(); | ||
| 2059 | // Two rows whose wide glyphs sit in different columns, so an answer read | ||
| 2060 | // off row 0 is wrong for row 1 and the sweep says which row it asked. | ||
| 2061 | e.feed("\u{6f22}a\u{5b57}b\r\na\u{6f22}b\u{5b57}"); | ||
| 2062 | const g = try Grid.init(alloc, 1, 1); | ||
| 2063 | defer g.deinit(); | ||
| 2064 | try e.mirrorInto(g); | ||
| 2065 | // A pane that starts at screen column 7: both sides answer in GRID | ||
| 2066 | // columns, so the offset must not reach the answer. | ||
| 2067 | const col_off: u16 = 7; | ||
| 2068 | var y: u16 = 0; | ||
| 2069 | while (y < 2) : (y += 1) { | ||
| 2070 | var cols: u16 = 0; | ||
| 2071 | while (cols <= 10) : (cols += 1) { | ||
| 2072 | const view = Engine.RowView{ .col_off = col_off, .cols = cols }; | ||
| 2073 | try std.testing.expectEqual(e.clipCol(y, view), g.clipCol(y, .{ .col_off = col_off, .cols = cols })); | ||
| 2074 | } | ||
| 2075 | var from: u16 = 0; | ||
| 2076 | while (from < 6) : (from += 1) { | ||
| 2077 | var to: u16 = from; | ||
| 2078 | while (to < 6) : (to += 1) { | ||
| 2079 | const a = e.snapWide(y, from, to); | ||
| 2080 | const b = g.snapWide(y, from, to); | ||
| 2081 | try std.testing.expectEqual(a.from, b.from); | ||
| 2082 | try std.testing.expectEqual(a.to, b.to); | ||
| 2083 | } | ||
| 2084 | } | ||
| 2085 | } | ||
| 2086 | } | ||
src/engine/term.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,18 +1,17 @@ | |||
| 1 | //! The terminal component: the wire contract, the authoritative engine, | 1 | //! The wire contract, the client-side grid it decodes into, and the one |
| 2 | //! the delta minting that feeds replicas, and the one replay core. One | 2 | //! replay core. One table row — every file below is one owner, and only this |
| 3 | //! table row — every file below is one owner, and only this root is a seam, | 3 | //! root is a seam, so a second module claiming any of them is a |
| 4 | //! so a second module claiming any of them is a file-in-multiple-modules | 4 | //! file-in-multiple-modules compile error. |
| 5 | //! compile error. | 5 | //! |
| 6 | //! It links no terminal emulator. That is what lets the browser core and | ||
| 7 | //! every client binary hold a replica without ghostty-vt in them; the | ||
| 8 | //! emulator is the `engine` module's, which imports this one. | ||
| 6 | pub const protocol = @import("protocol.zig"); | 9 | pub const protocol = @import("protocol.zig"); |
| 7 | pub const engine = @import("engine.zig"); | ||
| 8 | pub const delta = @import("delta.zig"); | ||
| 9 | pub const replica = @import("replica.zig"); | 10 | pub const replica = @import("replica.zig"); |
| 10 | pub const grid = @import("grid.zig"); | 11 | pub const grid = @import("grid.zig"); |
| 11 | 12 | ||
| 12 | test { | 13 | test { |
| 13 | _ = protocol; | 14 | _ = protocol; |
| 14 | _ = engine; | ||
| 15 | _ = delta; | ||
| 16 | _ = replica; | 15 | _ = replica; |
| 17 | _ = grid; | 16 | _ = grid; |
| 18 | } | 17 | } |
src/server/cmd.zig
| Old | New | ||
|---|---|---|---|
| @@ -10,7 +10,7 @@ | |||
| 10 | //! prompt does nothing. | 10 | //! prompt does nothing. |
| 11 | const std = @import("std"); | 11 | const std = @import("std"); |
| 12 | const proto = @import("term").protocol; | 12 | const proto = @import("term").protocol; |
| 13 | const Engine = @import("term").engine.Engine; | 13 | const Engine = @import("engine").Engine; |
| 14 | 14 | ||
| 15 | pub const LiveCommand = struct { | 15 | pub const LiveCommand = struct { |
| 16 | phase: proto.CmdPhase = .at_prompt, | 16 | phase: proto.CmdPhase = .at_prompt, |
src/server/server.zig
| Old | New | ||
|---|---|---|---|
| @@ -5,10 +5,10 @@ | |||
| 5 | //! most recently active one (latest wins). Single-threaded: `pumpOnce` is one | 5 | //! most recently active one (latest wins). Single-threaded: `pumpOnce` is one |
| 6 | //! poll iteration, so tests can drive the loop. | 6 | //! poll iteration, so tests can drive the loop. |
| 7 | const std = @import("std"); | 7 | const std = @import("std"); |
| 8 | const Engine = @import("term").engine.Engine; | 8 | const Engine = @import("engine").Engine; |
| 9 | const Pty = @import("pty").Pty; | 9 | const Pty = @import("pty").Pty; |
| 10 | const proto = @import("term").protocol; | 10 | const proto = @import("term").protocol; |
| 11 | const delta_mod = @import("term").delta; | 11 | const delta_mod = @import("engine").delta; |
| 12 | const DeltaTracker = delta_mod.DeltaTracker; | 12 | const DeltaTracker = delta_mod.DeltaTracker; |
| 13 | const cmdmod = @import("cmd.zig"); | 13 | const cmdmod = @import("cmd.zig"); |
| 14 | const shellint = @import("shellint.zig"); | 14 | const shellint = @import("shellint.zig"); |
src/server/server_sessions.zig
| Old | New | ||
|---|---|---|---|
| @@ -11,7 +11,7 @@ | |||
| 11 | 11 | ||
| 12 | const std = @import("std"); | 12 | const std = @import("std"); |
| 13 | const proto = @import("term").protocol; | 13 | const proto = @import("term").protocol; |
| 14 | const Engine = @import("term").engine.Engine; | 14 | const Engine = @import("engine").Engine; |
| 15 | const Pty = @import("pty").Pty; | 15 | const Pty = @import("pty").Pty; |
| 16 | const srv_mod = @import("server.zig"); | 16 | const srv_mod = @import("server.zig"); |
| 17 | const Server = srv_mod.Server; | 17 | const Server = srv_mod.Server; |
src/server/server_test_clipboard.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,5 +1,5 @@ | |||
| 1 | const std = @import("std"); | 1 | const std = @import("std"); |
| 2 | const Engine = @import("term").engine.Engine; | 2 | const Engine = @import("engine").Engine; |
| 3 | const proto = @import("term").protocol; | 3 | const proto = @import("term").protocol; |
| 4 | const TmpDir = @import("testtmp").TmpDir; | 4 | const TmpDir = @import("testtmp").TmpDir; |
| 5 | const h = @import("server_test_harness.zig"); | 5 | const h = @import("server_test_harness.zig"); |
src/server/server_test_deliver.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,5 +1,5 @@ | |||
| 1 | const std = @import("std"); | 1 | const std = @import("std"); |
| 2 | const delta_mod = @import("term").delta; | 2 | const delta_mod = @import("engine").delta; |
| 3 | const proto = @import("term").protocol; | 3 | const proto = @import("term").protocol; |
| 4 | const h = @import("server_test_harness.zig"); | 4 | const h = @import("server_test_harness.zig"); |
| 5 | const dial = h.dial; | 5 | const dial = h.dial; |
src/server/server_test_harness.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,5 +1,5 @@ | |||
| 1 | const std = @import("std"); | 1 | const std = @import("std"); |
| 2 | const Engine = @import("term").engine.Engine; | 2 | const Engine = @import("engine").Engine; |
| 3 | const Grid = @import("term").grid.Grid; | 3 | const Grid = @import("term").grid.Grid; |
| 4 | pub const Pty = @import("pty").Pty; | 4 | pub const Pty = @import("pty").Pty; |
| 5 | const proto = @import("term").protocol; | 5 | const proto = @import("term").protocol; |
src/tui/paint.zig
| Old | New | ||
|---|---|---|---|
| @@ -380,7 +380,7 @@ pub fn renderScrollback( | |||
| 380 | // under test is AUTHORED through an engine and mirrored in — the same path the | 380 | // under test is AUTHORED through an engine and mirrored in — the same path the |
| 381 | // daemon's encoder and the replica's decoder take on the wire. | 381 | // daemon's encoder and the replica's decoder take on the wire. |
| 382 | 382 | ||
| 383 | const Engine = @import("term").engine.Engine; | 383 | const Engine = @import("engine").Engine; |
| 384 | 384 | ||
| 385 | /// A grid holding what an engine that size shows after `bytes`, cursor | 385 | /// A grid holding what an engine that size shows after `bytes`, cursor |
| 386 | /// included. The bridge between a screen a test wants to describe in VT and | 386 | /// included. The bridge between a screen a test wants to describe in VT and |
src/tui/wall_test_harness.zig
| Old | New | ||
|---|---|---|---|
| @@ -3,7 +3,7 @@ | |||
| 3 | const std = @import("std"); | 3 | const std = @import("std"); |
| 4 | const proto = @import("term").protocol; | 4 | const proto = @import("term").protocol; |
| 5 | const client = @import("client"); | 5 | const client = @import("client"); |
| 6 | const Engine = @import("term").engine.Engine; | 6 | const Engine = @import("engine").Engine; |
| 7 | const layout = @import("client").layout; | 7 | const layout = @import("client").layout; |
| 8 | const wall_host = @import("wall_host.zig"); | 8 | const wall_host = @import("wall_host.zig"); |
| 9 | const wall_picker = @import("wall_picker.zig"); | 9 | const wall_picker = @import("wall_picker.zig"); |
test/bans.sh
| Old | New | ||
|---|---|---|---|
| @@ -233,5 +233,39 @@ else | |||
| 233 | FAILED=1 | 233 | FAILED=1 |
| 234 | fi | 234 | fi |
| 235 | 235 | ||
| 236 | # The module split, which no folder rule can express: a folder rule reads | ||
| 237 | # bytes inside a file, and this is about which module a file belongs to. | ||
| 238 | # `checkGrantsUsed` refuses a grant nobody spends, but nothing refuses a | ||
| 239 | # grant somebody adds — a client row that reached for the engine again | ||
| 240 | # would build green and put ghostty-vt back in every client binary. | ||
| 241 | split_fail() { | ||
| 242 | echo "bans FAIL: $1" | ||
| 243 | FAILED=1 | ||
| 244 | } | ||
| 245 | |||
| 246 | # The term module is the wasm root and the client's whole view of the wire, | ||
| 247 | # so it spells no emulator. engine.zig is the one file allowed the | ||
| 248 | # dependency, and term.zig must not re-export it: a re-export would hand | ||
| 249 | # every term importer the engine back without touching the table. | ||
| 250 | if grep -l '"ghostty-vt"' src/engine/term.zig src/engine/protocol.zig \ | ||
| 251 | src/engine/replica.zig src/engine/grid.zig >/dev/null 2>&1; then | ||
| 252 | split_fail "a term child imports ghostty-vt" | ||
| 253 | fi | ||
| 254 | if grep -q 'engine.zig' src/engine/term.zig; then | ||
| 255 | split_fail "term.zig re-exports the engine" | ||
| 256 | fi | ||
| 257 | |||
| 258 | # No client row links the engine in production: the client parses no VT and | ||
| 259 | # authors screens through an engine only under test. The table writes one | ||
| 260 | # row per line, so a row's `.imports` list is on the line that names it, and | ||
| 261 | # `.test_imports` cannot match because the character before `imports` there | ||
| 262 | # is an underscore rather than a dot. | ||
| 263 | for row in client wall webhub term mux; do | ||
| 264 | if grep -E "\.name = \"$row\"" build.zig | grep -qE '\.imports = &\.[{][^}]*"engine"'; then | ||
| 265 | split_fail "module row $row imports engine outside its tests" | ||
| 266 | fi | ||
| 267 | done | ||
| 268 | [ "$FAILED" -eq 0 ] && echo "bans ok: the engine is the daemon's alone" | ||
| 269 | |||
| 236 | [ "$FAILED" -eq 0 ] || { echo "bans: FAILED"; exit 1; } | 270 | [ "$FAILED" -eq 0 ] || { echo "bans: FAILED"; exit 1; } |
| 237 | echo "bans: every folder rule bit, the test skip held, and no heredoc runs its own comments" | 271 | echo "bans: every folder rule bit, the test skip held, and no heredoc runs its own comments" |
test/render.zig
| Old | New | ||
|---|---|---|---|
| @@ -3,7 +3,7 @@ | |||
| 3 | //! text format, so the suite can diff what the client painted against | 3 | //! text format, so the suite can diff what the client painted against |
| 4 | //! what the daemon holds. Third helper beside rawmode/delaypipe. | 4 | //! what the daemon holds. Third helper beside rawmode/delaypipe. |
| 5 | const std = @import("std"); | 5 | const std = @import("std"); |
| 6 | const Engine = @import("term").engine.Engine; | 6 | const Engine = @import("engine").Engine; |
| 7 | 7 | ||
| 8 | const alt_exit = "\x1b[?1049l"; | 8 | const alt_exit = "\x1b[?1049l"; |
| 9 | 9 | ||
test/wsclient.zig
| Old | New | ||
|---|---|---|---|
| @@ -26,9 +26,7 @@ | |||
| 26 | //! Usage: wsclient --port N --tile IDX --out FILE --err FILE | 26 | //! Usage: wsclient --port N --tile IDX --out FILE --err FILE |
| 27 | //! [--origin STR] < script | 27 | //! [--origin STR] < script |
| 28 | const std = @import("std"); | 28 | const std = @import("std"); |
| 29 | const Engine = @import("term").engine.Engine; | ||
| 30 | const Grid = @import("term").grid.Grid; | 29 | const Grid = @import("term").grid.Grid; |
| 31 | const delta_mod = @import("term").delta; | ||
| 32 | const Replica = @import("term").replica.Replica; | 30 | const Replica = @import("term").replica.Replica; |
| 33 | const proto = @import("term").protocol; | 31 | const proto = @import("term").protocol; |
| 34 | // The script dialect this fixture and ptyclient both speak: the escape | 32 | // The script dialect this fixture and ptyclient both speak: the escape |
| @@ -592,6 +590,13 @@ pub fn main() !void { | |||
| 592 | } | 590 | } |
| 593 | 591 | ||
| 594 | // --------------------------------------------------------------------------- | 592 | // --------------------------------------------------------------------------- |
| 593 | // Tests. The fixture itself holds a replica and parses no VT, exactly like | ||
| 594 | // the browser it stands in for; only the tests below reach for the engine, | ||
| 595 | // to build the daemon's own frames rather than hand-write bytes the wire | ||
| 596 | // would have to be trusted to match. | ||
| 597 | |||
| 598 | const Engine = @import("engine").Engine; | ||
| 599 | const delta_mod = @import("engine").delta; | ||
| 595 | 600 | ||
| 596 | test "masked message: header layout and mask application, all three length forms" { | 601 | test "masked message: header layout and mask application, all three length forms" { |
| 597 | const alloc = std.testing.allocator; | 602 | const alloc = std.testing.allocator; |