From 05ca060fc91930f0e931f39dcf56729ec71543dd Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 15:26:39 -0300 Subject: Every helix-golf example is a differential case, one per step of its walkthrough The ten examples on helix-golf are imported as 118 cases: each numbered step of an example's walkthrough is the case whose keys are the command up to and including that step, so the first mismatch names the step where pardes and helix part. Six more restart at a step that begins with %, from helix's own text there, so a later step is tested even when an earlier one misses. zig build hxgolf compares all 124 with goldens from hx-harness. Co-Authored-By: Claude Opus 5.5 --- build.zig | 7 +++++++ 1 file changed, 7 insertions(+) (limited to 'build.zig') diff --git a/build.zig b/build.zig index 666e792d..334d3705 100644 --- a/build.zig +++ b/build.zig @@ -1097,6 +1097,13 @@ pub fn build(b: *std.Build) void { const hxparity_step = b.step("hxparity", "run the file-vs-pty editing parity suite (-- [--waivers w.jsonl] ...)"); hxparity_step.dependOn(&run_hxparity.step); + // every helix-golf example, one case per step of its walkthrough + const run_hxgolf = b.addRunArtifact(hxdiff); + run_hxgolf.has_side_effects = true; + run_hxgolf.addArgs(&.{ "--strict", "test/hxcases/golf.jsonl", "test/hxcases/golf-goldens.jsonl" }); + run_hxgolf.setCwd(b.path(".")); + b.step("hxgolf", "compare the helix-golf examples against their Helix results").dependOn(&run_hxgolf.step); + const lspbench = b.addExecutable(.{ .name = "pardes-lspbench", .root_module = b.createModule(.{ -- cgit v1.3 From cc66593d1432813c9af029ce3779ccba9ded8883 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:11:08 -0300 Subject: The helix-golf steps that walk search hits are waived, so hxgolf can pass reverse_golf_example from ""N on needs *, N and n to walk the search register; in pardes those keys are the acme look ring, by decision. The seven steps are pinned in golf-waivers.jsonl, so a new mismatch stands out. Co-Authored-By: Claude Opus 5.5 --- build.zig | 2 +- docs/helix-keys.md | 5 ++++- docs/selections.md | 5 ++++- test/hxcases/golf-waivers.jsonl | 7 +++++++ 4 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 test/hxcases/golf-waivers.jsonl (limited to 'build.zig') diff --git a/build.zig b/build.zig index 334d3705..f4a3b542 100644 --- a/build.zig +++ b/build.zig @@ -1100,7 +1100,7 @@ pub fn build(b: *std.Build) void { // every helix-golf example, one case per step of its walkthrough const run_hxgolf = b.addRunArtifact(hxdiff); run_hxgolf.has_side_effects = true; - run_hxgolf.addArgs(&.{ "--strict", "test/hxcases/golf.jsonl", "test/hxcases/golf-goldens.jsonl" }); + run_hxgolf.addArgs(&.{ "--strict", "test/hxcases/golf.jsonl", "test/hxcases/golf-goldens.jsonl", "test/hxcases/golf-waivers.jsonl" }); run_hxgolf.setCwd(b.path(".")); b.step("hxgolf", "compare the helix-golf examples against their Helix results").dependOn(&run_hxgolf.step); diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 35f62765..14a39239 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -417,7 +417,10 @@ Files (all in `test/hxcases/`): step that starts over with `%` also gets `-fromNN` cases replaying the rest of the command from helix's own text there, so a later step is still tested when an earlier one misses. The goldens are - `hx-harness test/hxcases/golf.jsonl`; `zig build hxgolf` runs them. All ten examples reproduce their published result in hx 25.07.1 under + `hx-harness test/hxcases/golf.jsonl`; `zig build hxgolf` runs them. + `golf-waivers.jsonl` pins the seven `reverse_golf_example` steps from `""N` + on: they walk the search register with `*`, `N` and `n`, which in pardes + are the acme look ring, by decision. All ten examples reproduce their published result in hx 25.07.1 under the site's own conditions (a file of the example's language, auto-pairs on). Under the harness two do not, and their goldens are the harness's anyway, which pardes follows: `csv_to_sql` needs auto-pairs (off in the diff --git a/docs/selections.md b/docs/selections.md index c08ccb14..35fa806f 100644 --- a/docs/selections.md +++ b/docs/selections.md @@ -94,7 +94,10 @@ the replay. for missing ones. `"` names the register (`Pardes.register` for the length of one command); `_ # . % /` are computed or special and `+`/`*` is the system clipboard. The acme chords and a paste into a terminal read - the default register joined by newlines. + the default register joined by newlines. The clipboard's read is an + answer from the shell that comes later, so `"+p` pastes once, at the + primary, and insert `Ctrl-r +` types what pardes last put on the + clipboard rather than asking the desktop. - `Alt-.` repeats the last `f`/`t`/`F`/`T`. There is no `.` and there are no macros. diff --git a/test/hxcases/golf-waivers.jsonl b/test/hxcases/golf-waivers.jsonl new file mode 100644 index 00000000..eb1f824b --- /dev/null +++ b/test/hxcases/golf-waivers.jsonl @@ -0,0 +1,7 @@ +{"name":"golf-reverse-golf-example-07","reason":"pardes n/N/* are the acme look ring, by decision: \"\"N, n and * do not walk the search register","reference":{"name":"golf-reverse-golf-example-07","text":"# snake_case to snake_case\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"select","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":11},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":11}},{"cursor":{"row":0,"col":16},"anchor":{"row":0,"col":25}}],"primary":0},"expected":{"name":"golf-reverse-golf-example-07","text":"# snake_case to snake_case\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"select","cursor":{"row":0,"col":16},"anchor":{"row":0,"col":25}}} +{"name":"golf-reverse-golf-example-08","reason":"pardes n/N/* are the acme look ring, by decision: \"\"N, n and * do not walk the search register","reference":{"name":"golf-reverse-golf-example-08","text":"# snake_case to snake_case\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"select","cursor":{"row":2,"col":24},"anchor":{"row":2,"col":32},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":11}},{"cursor":{"row":0,"col":16},"anchor":{"row":0,"col":25}},{"cursor":{"row":2,"col":24},"anchor":{"row":2,"col":32}}],"primary":2},"expected":{"name":"golf-reverse-golf-example-08","text":"# snake_case to snake_case\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"select","cursor":{"row":0,"col":16},"anchor":{"row":0,"col":25}}} +{"name":"golf-reverse-golf-example-09","reason":"pardes n/N/* are the acme look ring, by decision: \"\"N, n and * do not walk the search register","reference":{"name":"golf-reverse-golf-example-09","text":"# camelCase to snake_case\n\nRename all fields to be snake_case.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"select","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":10},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":10}},{"cursor":{"row":0,"col":15},"anchor":{"row":0,"col":24}},{"cursor":{"row":2,"col":24},"anchor":{"row":2,"col":33}}],"primary":0},"expected":{"name":"golf-reverse-golf-example-09","text":"# snake_case to snake_case\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"select","cursor":{"row":0,"col":16},"anchor":{"row":0,"col":25}}} +{"name":"golf-reverse-golf-example-10","reason":"pardes n/N/* are the acme look ring, by decision: \"\"N, n and * do not walk the search register","reference":{"name":"golf-reverse-golf-example-10","text":"# camelCase to snake_case\n\nRename all fields to be snake_case.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"select","cursor":{"row":14,"col":3},"anchor":{"row":0,"col":0}},"expected":{"name":"golf-reverse-golf-example-10","text":"# snake_case to snake_case\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"select","cursor":{"row":14,"col":3},"anchor":{"row":0,"col":0}}} +{"name":"golf-reverse-golf-example-11","reason":"pardes n/N/* are the acme look ring, by decision: \"\"N, n and * do not walk the search register","reference":{"name":"golf-reverse-golf-example-11","text":"# camelCase to snake_case\n\nRename all fields to be snake_case.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"select","cursor":{"row":6,"col":3},"anchor":{"row":6,"col":0},"sels":[{"cursor":{"row":6,"col":3},"anchor":{"row":6,"col":0}},{"cursor":{"row":12,"col":3},"anchor":{"row":12,"col":0}}],"primary":0},"expected":{"name":"golf-reverse-golf-example-11","text":"# snake_case to snake_case\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"select","cursor":{"row":6,"col":3},"anchor":{"row":6,"col":0},"sels":[{"cursor":{"row":6,"col":3},"anchor":{"row":6,"col":0}},{"cursor":{"row":12,"col":3},"anchor":{"row":12,"col":0}}],"primary":0}} +{"name":"golf-reverse-golf-example-12","reason":"pardes n/N/* are the acme look ring, by decision: \"\"N, n and * do not walk the search register","reference":{"name":"golf-reverse-golf-example-12","text":"# camelCase to snake_case\n\nRename all fields to be snake_case.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"select","cursor":{"row":7,"col":41},"anchor":{"row":6,"col":0},"sels":[{"cursor":{"row":7,"col":41},"anchor":{"row":6,"col":0}},{"cursor":{"row":13,"col":39},"anchor":{"row":12,"col":0}}],"primary":0},"expected":{"name":"golf-reverse-golf-example-12","text":"# snake_case to snake_case\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"select","cursor":{"row":7,"col":41},"anchor":{"row":6,"col":0},"sels":[{"cursor":{"row":7,"col":41},"anchor":{"row":6,"col":0}},{"cursor":{"row":13,"col":39},"anchor":{"row":12,"col":0}}],"primary":0}} +{"name":"golf-reverse-golf-example","reason":"pardes n/N/* are the acme look ring, by decision: \"\"N, n and * do not walk the search register","reference":{"name":"golf-reverse-golf-example","text":"# camelCase to snake_case\n\nRename all fields to be snake_case.\n\n## Before\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n\n## After\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n","mode":"select","cursor":{"row":13,"col":41},"anchor":{"row":12,"col":0},"sels":[{"cursor":{"row":7,"col":39},"anchor":{"row":6,"col":0}},{"cursor":{"row":13,"col":41},"anchor":{"row":12,"col":0}}],"primary":1},"expected":{"name":"golf-reverse-golf-example","text":"# snake_case to snake_case\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n\n## After\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n","mode":"select","cursor":{"row":13,"col":41},"anchor":{"row":12,"col":0},"sels":[{"cursor":{"row":7,"col":39},"anchor":{"row":6,"col":0}},{"cursor":{"row":13,"col":41},"anchor":{"row":12,"col":0}}],"primary":1}} -- cgit v1.3 From 360b72ade0acfa655a3740b638a0cc8e23238a05 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 14:59:21 -0300 Subject: The GUI draws in tiers from the regions; pane chrome moves with its pane Stage 8 of docs/render-pipeline.md. The frame is drawn in groups: tier 0, one per track in paint order (tiers 2 and 3), tier 4 (notices, then guides and the debug box), tier 5 (bar cursors), each cells, images, decor. Decor (rules, rails, thumbs, grip marks, spines, the workspace and column rules, the bottom band, notice rules, bar cursors) is whole-pixel rects through the new decor.frag over ui.vert, so it carries its track's transition and clip. A closing pane's chrome comes from Surface.previous_regions. Deleted: taglineBaseRgb, topbarPaneBorderHeight, bottomTaglinePresent, frameChromeBg, cellBackgroundIs and the rail inference, paneGripCell's scan, transient_on, PaintPlan. One cover map (coverFrame) is marked from the layers and regions once a frame. New regions column, guide and debug; Surface.chrome is the palette, carried in wire v8 (not shipped yet), so an attached GUI draws the same chrome (test). A per-instance clip replaces the vertical transition's scissor. Goldens: 01-12 byte-identical. 13-17 differ only past the grid: the image pass left its scissor at the grid's size, cutting rule ends, rail feet and the bottom band in the leftover pixels whenever a picture was on screen. 16-debug now shows the debug box, which a context-row layer had hidden. 18-mid-transition is new: virtual clock (PARDES_TEST_CLOCK in the GUI), PanelSlide Newcol with the picture, frame 6 of 12. Also: the GUI sleeps when idle instead of polling every 16 ms, and a minimized or occluded window sleeps through animation. Tracy 'gui frame build' at 200x60: 992/1015 us median before, 989/987 us after. Shared files touched: pardes.zig (one export), detached/client.zig, detached/server.zig, detached/wire.zig (all additive), gui.zig. Not touched: Messages.zig, mouse.zig, tagline.zig, colors.zig, tty.zig, host_io.zig, dump.zig, exec.zig, panes.zig. --- build.zig | 1 + docs/render-pipeline.md | 14 +- shaders/decor.frag.glsl | 24 + shaders/prebuilt/decor.frag.glsl | 24 + shaders/prebuilt/decor.frag.spv | Bin 0 -> 2100 bytes shaders/prebuilt/ui.frag.glsl | 3 + shaders/prebuilt/ui.frag.spv | Bin 4668 -> 5700 bytes shaders/prebuilt/ui.vert.glsl | 6 + shaders/prebuilt/ui.vert.spv | Bin 5468 -> 5612 bytes shaders/ui.frag.glsl | 3 + shaders/ui.vert.glsl | 6 + src/Presentation.zig | 5 + src/detached/client.zig | 4 +- src/detached/server.zig | 1 + src/detached/wire.zig | 41 +- src/draw.zig | 63 +- src/gui/gui.zig | 1476 +++++++++++++++++++------------------- src/pardes.zig | 1 + src/surface.zig | 40 +- test/gui-goldens.txt | 11 +- test/gui_golden.py | 18 +- 21 files changed, 978 insertions(+), 763 deletions(-) create mode 100644 shaders/decor.frag.glsl create mode 100644 shaders/prebuilt/decor.frag.glsl create mode 100644 shaders/prebuilt/decor.frag.spv (limited to 'build.zig') diff --git a/build.zig b/build.zig index 666e792d..803f773d 100644 --- a/build.zig +++ b/build.zig @@ -47,6 +47,7 @@ pub const TreeSitterGrammars = enum { disabled, zig, minimal, full }; const gui_shaders = [_][]const u8{ "ui.vert", "ui.frag", + "decor.frag", "overlay.vert", "overlay.frag", "image.vert", "image.frag", "crt.vert", "crt.frag", diff --git a/docs/render-pipeline.md b/docs/render-pipeline.md index a8b3f019..69645c96 100644 --- a/docs/render-pipeline.md +++ b/docs/render-pipeline.md @@ -810,7 +810,7 @@ stripped. No visual change until stage 9 unless stated. | 5 | JOIN: paint tags/notices/headers once into layers, copy into grid; the grid wins where the copies disagree, and each disagreement is listed for a later decision | goldens are the oracle; wide-grapheme re-clip at the edge (§3.2) | | 6 | body layer joined the same way (paint once, copy visible rows) | riskiest join; A/B the old double paint in a temporary test with tag_bottom on and off, then delete it | | 7 | Layer merge (TagLayer + BodyLayer), wire v8, web accessors | after the other agent lands; touches mouse hit paths; breaks the macOS shell's layer ABI (accepted: macOS build ignored for now); done: one `Layer` (src/Layer.zig) with `rows` (0 = no layer) and a `cursor{x, y}`; a tag of N rows is ONE layer of N grid rows (`tagHit` answers the row as `line`, `bodyHit` keeps its meaning), so the per-line layer bases are gone. Wire v8 ships rows, cursor y and the region list in the one bump; v7 and v9 peers are refused in both directions (tests). web: `tag_layer_value` 11 = rows, 12 = cursor y, and app.mjs lays every row. macOS: its Zig side compiles against `Layer`, but pardes.h still sees one row per tag layer (a taller tag shows its first row there) | -| 8 | GUI draws from regions: role tiers with track groups in tiers 2-3, page cover, hard-edged snapped decor for rules/rails/grips (pane chrome in the pane's tier), per-instance clip; delete inference functions, `transient_on` and `mark_hover` (breaks macOS glass hover; accepted, macOS ignored for now) | stage-0 PPM goldens byte-identical (possible only because hard decor is snapped and not anti-aliased); pane chrome now also shows during transitions, which is the one allowed visible delta, listed | +| 8 | GUI draws from regions: role tiers with track groups in tiers 2-3, page cover, hard-edged snapped decor for rules/rails/grips (pane chrome in the pane's tier), per-instance clip; delete inference functions, `transient_on` and `mark_hover` (breaks macOS glass hover; accepted, macOS ignored for now) | stage-0 PPM goldens byte-identical (possible only because hard decor is snapped and not anti-aliased); pane chrome now also shows during transitions, which is the one allowed visible delta, listed Done: the frame is drawn in groups (makeGroups): tier 0, one group per track in paint order (tiers 2 and 3), tier 4 as two groups (the notices; then the guides and the debug box, which the core paints over them), tier 5 (bar cursors), each drawing cells, images, decor. Decor is every rule, rail, thumb, grip mark, spine, the workspace and column rules, the bottom band, notice rules and bar cursors: whole-pixel rects drawn by `decor.frag` over `ui.vert` as cell instances (colour in the ground, coverage in fg.r, blended like the overlay), so each carries its track's transition and clip; a closing pane's comes from the last frame's regions (`Surface.previous_regions`). Only `solid` exists yet: `decor.vert` and the other kinds (§5.1) come with the first effect that needs them. Deleted: taglineBaseRgb, topbarPaneBorderHeight, bottomTaglinePresent, frameChromeBg, cellBackgroundIs and the rail inference, paneGripCell's scan, transient_on, PaintPlan; one cover map (coverFrame: layer, grip and offset, focus, anchor, floating) is marked from layers and regions once a frame. New regions: `column` (spines, anchors, the focused column's tint), `guide`, `debug`; `Surface.chrome` is the palette, on the wire in v8 (not yet shipped, so no bump), and an attached GUI draws the same chrome (test). Per-instance clip (`CellInstance.clip_*`) replaces the vertical transition's scissor. Goldens: 01-12 byte-identical; 13-17 differ only in pixels past the grid (with a picture on screen the image pass left the scissor at the grid's size, cutting every rule end, rail foot and band that runs into the leftover pixels; they now run to the edge as in every scene without one); 16-debug shows the debug box (it was drawn from the grid under the source's context-row layer, so the GUI never showed it there); 18-mid-transition is new (virtual clock, PanelSlide Newcol with the picture, frame 6 of 12: chrome moves with its pane). Other deltas, outside the goldens: a guide over a tag or a context-row body now shows; with WindowOpacity < 100 a layer's bar cursor is ink like the grid's; an attached GUI gains the focus tint, notice rules, spines and the theme's page and caret colours. Deferrals fixed: the GUI sleeps when idle (SDL and queue events wake it; a smooth scroll, a gamepad, the test feed, a shell's kill deadline, a present to retry still poll), and a minimized or occluded window sleeps through animation. Tracy, `gui frame build`, 200x60, terminal output plus scrolling, ReleaseFast, two interleaved runs of ~180 frames: before 992/1015 µs median, after 989/987 µs | | 9 | post chain: glslang, Shadertoy prefix, ping-pong, ShaderAnimation, redraw levels A/B (§5.5); bundled CRT without barrel; delete scene_effects/crt.zig/crt.frag | first visual change (the CRT look) | | 10+ | `fx` bookmark: G1–G3 → feel review → G4 bundled → G5 lapis theme → P2s; tty T1–T2 → feel review → removals of audited effects (after user decision) → T3–T5 | each effect its own change, default off | @@ -845,13 +845,21 @@ goldens; ssh/tty byte budgets; wire version bump breaks mixed-version attach. 6. Revisit at the first visual stage (from stage 5): the notice layer lost its hover word to keep the grid's behaviour. Notice words are Look/Exec targets, so bring the hover affordance back in BOTH the grid and the - layer then. + layer then. Carried forward from stage 8 (a GUI-only stage) to the hover + change after it, which also retires `mark_hover`: the affordance is a + core paint every shell and snapshot sees. 7. Revisit at stage 8 (from stage 6): a body without context rows has no layer (paint once, straight onto the grid). If the GUI is to read every body from a layer, give every body one then and measure the copy. The context-row path also still builds the body's text twice (a pre-pass with body_rows 0 decides the context rows, renderBody builds it again at - the layer's rows); the paint is single. Measure both there. + the layer's rows); the paint is single. Measure both there. Decided at + stage 8, measured (Tracy, 200x60, TreeContext on a scrolled source, + ReleaseFast): the pre-pass text build is 5 µs median and the layer's + copies 10 µs per context-row body per frame. Kept as they are: nothing in + the GUI reads a layerless body from anything but the grid, which is + exact, so a layer for every body would be ~10 µs a body a frame for no + pixel. ## 15. Questions for the user diff --git a/shaders/decor.frag.glsl b/shaders/decor.frag.glsl new file mode 100644 index 00000000..4c2bd6d8 --- /dev/null +++ b/shaders/decor.frag.glsl @@ -0,0 +1,24 @@ +#version 450 + +// Decor: a flat, hard-edged fill (a rule, a rail, a grip mark, a bar cursor) +// that ui.vert.glsl places like a cell, its track's transition included. Its +// colour rides in the cell's ground (a_bg) and its coverage in a_fg.r. The +// pipeline blends it as the overlay pipeline does (backgroundLayerBlend), so +// a fading rule shows through, and the result is that pipeline's exactly. +// Ink (a cursor) is drawn with the opacity uniform at 1. + +layout(std140, set = 3, binding = 0) uniform UiUniforms { + float background_opacity; +} u_ui; + +layout(location = 1) in vec3 v_fg; +layout(location = 2) in vec3 v_bg; +layout(location = 5) flat in vec4 v_clip; + +layout(location = 0) out vec4 o_col; + +void main() { + if (v_clip.z >= v_clip.x && (gl_FragCoord.x < v_clip.x || gl_FragCoord.x >= v_clip.z || + gl_FragCoord.y < v_clip.y || gl_FragCoord.y >= v_clip.w)) discard; + o_col = vec4(v_bg * clamp(u_ui.background_opacity, 0.0, 1.0), v_fg.r); +} diff --git a/shaders/prebuilt/decor.frag.glsl b/shaders/prebuilt/decor.frag.glsl new file mode 100644 index 00000000..4c2bd6d8 --- /dev/null +++ b/shaders/prebuilt/decor.frag.glsl @@ -0,0 +1,24 @@ +#version 450 + +// Decor: a flat, hard-edged fill (a rule, a rail, a grip mark, a bar cursor) +// that ui.vert.glsl places like a cell, its track's transition included. Its +// colour rides in the cell's ground (a_bg) and its coverage in a_fg.r. The +// pipeline blends it as the overlay pipeline does (backgroundLayerBlend), so +// a fading rule shows through, and the result is that pipeline's exactly. +// Ink (a cursor) is drawn with the opacity uniform at 1. + +layout(std140, set = 3, binding = 0) uniform UiUniforms { + float background_opacity; +} u_ui; + +layout(location = 1) in vec3 v_fg; +layout(location = 2) in vec3 v_bg; +layout(location = 5) flat in vec4 v_clip; + +layout(location = 0) out vec4 o_col; + +void main() { + if (v_clip.z >= v_clip.x && (gl_FragCoord.x < v_clip.x || gl_FragCoord.x >= v_clip.z || + gl_FragCoord.y < v_clip.y || gl_FragCoord.y >= v_clip.w)) discard; + o_col = vec4(v_bg * clamp(u_ui.background_opacity, 0.0, 1.0), v_fg.r); +} diff --git a/shaders/prebuilt/decor.frag.spv b/shaders/prebuilt/decor.frag.spv new file mode 100644 index 00000000..7e162f5a Binary files /dev/null and b/shaders/prebuilt/decor.frag.spv differ diff --git a/shaders/prebuilt/ui.frag.glsl b/shaders/prebuilt/ui.frag.glsl index 3c366602..0b099db3 100644 --- a/shaders/prebuilt/ui.frag.glsl +++ b/shaders/prebuilt/ui.frag.glsl @@ -17,6 +17,7 @@ layout(location = 1) in vec3 v_fg; layout(location = 2) in vec3 v_bg; layout(location = 3) in vec2 v_cell_uv; layout(location = 4) flat in uvec4 v_effect; +layout(location = 5) flat in vec4 v_clip; layout(location = 0) out vec4 o_col; @@ -31,6 +32,8 @@ float cellNoise(uint serial, uint col, uint row) { } void main() { + if (v_clip.z >= v_clip.x && (gl_FragCoord.x < v_clip.x || gl_FragCoord.x >= v_clip.z || + gl_FragCoord.y < v_clip.y || gl_FragCoord.y >= v_clip.w)) discard; float a = texture(u_atlas, v_uv).r; bool oldLayer = (v_effect.x & 0x80000000u) != 0u; // A block cursor is foreground chrome even though its fill occupies the diff --git a/shaders/prebuilt/ui.frag.spv b/shaders/prebuilt/ui.frag.spv index 120438e7..033c10ab 100644 Binary files a/shaders/prebuilt/ui.frag.spv and b/shaders/prebuilt/ui.frag.spv differ diff --git a/shaders/prebuilt/ui.vert.glsl b/shaders/prebuilt/ui.vert.glsl index d23b609c..c1779add 100644 --- a/shaders/prebuilt/ui.vert.glsl +++ b/shaders/prebuilt/ui.vert.glsl @@ -19,12 +19,17 @@ layout(location = 5) in vec4 a_present_rect; // the id is what is left below them; integer transport preserves the exact // cross-backend hash. layout(location = 6) in uvec4 a_effect; +// A clip rectangle in target pixels (x0, y0, x1, y1): the fragment shader +// drops what lies outside it. x1 < x0 is none. A vertical transition clips +// its pane's instances to the box the pane is growing into. +layout(location = 7) in vec4 a_clip; layout(location = 0) out vec2 v_uv; layout(location = 1) out vec3 v_fg; layout(location = 2) out vec3 v_bg; layout(location = 3) out vec2 v_cell_uv; layout(location = 4) flat out uvec4 v_effect; +layout(location = 5) flat out vec4 v_clip; void main() { vec2 pos; @@ -70,5 +75,6 @@ void main() { v_fg = a_fg; v_bg = a_bg; v_effect = a_effect; + v_clip = a_clip; gl_Position = vec4(pos, 0.0, 1.0); } diff --git a/shaders/prebuilt/ui.vert.spv b/shaders/prebuilt/ui.vert.spv index 659049dd..619a9690 100644 Binary files a/shaders/prebuilt/ui.vert.spv and b/shaders/prebuilt/ui.vert.spv differ diff --git a/shaders/ui.frag.glsl b/shaders/ui.frag.glsl index 3c366602..0b099db3 100644 --- a/shaders/ui.frag.glsl +++ b/shaders/ui.frag.glsl @@ -17,6 +17,7 @@ layout(location = 1) in vec3 v_fg; layout(location = 2) in vec3 v_bg; layout(location = 3) in vec2 v_cell_uv; layout(location = 4) flat in uvec4 v_effect; +layout(location = 5) flat in vec4 v_clip; layout(location = 0) out vec4 o_col; @@ -31,6 +32,8 @@ float cellNoise(uint serial, uint col, uint row) { } void main() { + if (v_clip.z >= v_clip.x && (gl_FragCoord.x < v_clip.x || gl_FragCoord.x >= v_clip.z || + gl_FragCoord.y < v_clip.y || gl_FragCoord.y >= v_clip.w)) discard; float a = texture(u_atlas, v_uv).r; bool oldLayer = (v_effect.x & 0x80000000u) != 0u; // A block cursor is foreground chrome even though its fill occupies the diff --git a/shaders/ui.vert.glsl b/shaders/ui.vert.glsl index d23b609c..c1779add 100644 --- a/shaders/ui.vert.glsl +++ b/shaders/ui.vert.glsl @@ -19,12 +19,17 @@ layout(location = 5) in vec4 a_present_rect; // the id is what is left below them; integer transport preserves the exact // cross-backend hash. layout(location = 6) in uvec4 a_effect; +// A clip rectangle in target pixels (x0, y0, x1, y1): the fragment shader +// drops what lies outside it. x1 < x0 is none. A vertical transition clips +// its pane's instances to the box the pane is growing into. +layout(location = 7) in vec4 a_clip; layout(location = 0) out vec2 v_uv; layout(location = 1) out vec3 v_fg; layout(location = 2) out vec3 v_bg; layout(location = 3) out vec2 v_cell_uv; layout(location = 4) flat out uvec4 v_effect; +layout(location = 5) flat out vec4 v_clip; void main() { vec2 pos; @@ -70,5 +75,6 @@ void main() { v_fg = a_fg; v_bg = a_bg; v_effect = a_effect; + v_clip = a_clip; gl_Position = vec4(pos, 0.0, 1.0); } diff --git a/src/Presentation.zig b/src/Presentation.zig index e1f1b585..3b5bb3e0 100644 --- a/src/Presentation.zig +++ b/src/Presentation.zig @@ -44,6 +44,8 @@ pending: bool = false, previous_cells: []pardes.Cell = &.{}, previous_body_layers: [MAX_PANES]pardes.Layer = @splat(.{}), previous_tag_layers: [pardes.MAX_TAG_LAYERS]pardes.Layer = @splat(.{}), +previous_regions: [pardes.MAX_REGIONS]pardes.Region = undefined, +previous_nregions: u16 = 0, previous_cols: u16 = 0, previous_rows: u16 = 0, previous_valid: bool = false, @@ -346,6 +348,8 @@ pub fn capturePrevious(self: *Presentation, gpa: std.mem.Allocator, surface: *co old.cells = storage; } @memcpy(self.previous_cells, cells); + @memcpy(self.previous_regions[0..surface.nregions], surface.regionList()); + self.previous_nregions = surface.nregions; self.previous_cols = surface.cols; self.previous_rows = surface.rows; self.previous_valid = true; @@ -584,6 +588,7 @@ pub fn submit(self: *Presentation, p: *Pardes, s: *pardes.Surface) !void { s.previous_cells = self.previous_cells; s.previous_body_layers = &self.previous_body_layers; s.previous_tag_layers = &self.previous_tag_layers; + s.previous_regions = self.previous_regions[0..self.previous_nregions]; s.cell_diffs = self.diffs; } s.npanel_tracks = paintOrder( diff --git a/src/detached/client.zig b/src/detached/client.zig index 3347f609..9fa85fb4 100644 --- a/src/detached/client.zig +++ b/src/detached/client.zig @@ -107,6 +107,8 @@ pub const Client = struct { tag_layers: [pardes.MAX_TAG_LAYERS]pardes.Layer = @splat(.{}), /// The session's placed regions, as its last frame gave them. regions: std.ArrayListUnmanaged(pardes.Region) = .empty, + /// The palette its chrome is drawn in, as its last frame gave it. + chrome: pardes.Chrome = .{}, in: std.ArrayListUnmanaged(u8) = .empty, out: std.ArrayListUnmanaged(u8) = .empty, /// Bytes of `in` belonging to the message `next` returned last. Compacted at @@ -322,7 +324,7 @@ pub const Client = struct { try c.reshape(f.cols, f.rows); } try f.apply(c.grid.items); - f.applyLayers(c.gpa, &c.body_layers, &c.tag_layers, &c.regions) catch |err| return switch (err) { + f.applyLayers(c.gpa, &c.body_layers, &c.tag_layers, &c.regions, &c.chrome) catch |err| return switch (err) { error.OutOfMemory => error.Closed, else => @as(wire.Error, @errorCast(err)), }; diff --git a/src/detached/server.zig b/src/detached/server.zig index 46fb8fcf..9e18e125 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -760,6 +760,7 @@ pub const Session = struct { &surface.body_layers, &surface.tag_layers, surface.regionList(), + surface.chrome, ) catch |err| { log.debug("frame {d}x{d} not encodable: {t}", .{ surface.cols, surface.rows, err }); return; diff --git a/src/detached/wire.zig b/src/detached/wire.zig index 684c869b..32cd5229 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -169,8 +169,9 @@ pub const Frame = struct { nruns: u32, runs: []const u8, - pub fn applyLayers(f: Frame, gpa: std.mem.Allocator, layers: *[pardes.MAX_PANES]pardes.Layer, tags: *[pardes.MAX_TAG_LAYERS]pardes.Layer, regions: *std.ArrayListUnmanaged(pardes.Region)) !void { + pub fn applyLayers(f: Frame, gpa: std.mem.Allocator, layers: *[pardes.MAX_PANES]pardes.Layer, tags: *[pardes.MAX_TAG_LAYERS]pardes.Layer, regions: *std.ArrayListUnmanaged(pardes.Region), chrome: *pardes.Chrome) !void { regions.clearRetainingCapacity(); + chrome.* = .{}; for (layers) |*layer| { gpa.free(layer.cells); layer.* = .{}; @@ -188,6 +189,8 @@ pub const Frame = struct { gpa.free(layer.cells); layer.* = .{}; } + regions.clearRetainingCapacity(); + chrome.* = .{}; } if (f.layers.len == 0) return; var r: Reader = .init(f.layers); @@ -255,6 +258,15 @@ pub const Frame = struct { region.clip > region.rect.w or region.thumb_y > region.rect.h or region.thumb_h > region.rect.h - region.thumb_y) return error.BadValue; regions.appendAssumeCapacity(region); } + inline for (@typeInfo(pardes.Chrome).@"struct".fields) |field| { + const rgb = &@field(chrome, field.name); + if (field.type == ?[3]u8) { + if (try r.getBool()) { + rgb.* = .{ 0, 0, 0 }; + for (&rgb.*.?) |*channel| channel.* = try r.getByte(); + } + } else for (rgb) |*channel| channel.* = try r.getByte(); + } try r.end(); } @@ -697,7 +709,7 @@ pub fn encodeFramePointer( cells: []const pardes.Cell, prev: []const pardes.Cell, ) Error![]const u8 { - return encodeFrameLayers(out, cols, rows, cursor, pointer_shape, cells, prev, &.{}, &.{}, &.{}); + return encodeFrameLayers(out, cols, rows, cursor, pointer_shape, cells, prev, &.{}, &.{}, &.{}, .{}); } pub fn layersBound(layers: []const pardes.Layer, tags: []const pardes.Layer, regions: []const pardes.Region) usize { @@ -708,12 +720,15 @@ pub fn layersBound(layers: []const pardes.Layer, tags: []const pardes.Layer, reg for (tags) |layer| if (layer.rows != 0) { n += 28 + layer.cells.len * cell_max; }; - return n + regions.len * region_len; + return n + regions.len * region_len + chrome_len; } const region_len = 1 + 1 + 4 + 8 + 1 + 2 + 2 + 1 + 2; +/// The palette: each colour three bytes, a theme's optional ones a flag +/// before them. +const chrome_len = @typeInfo(pardes.Chrome).@"struct".fields.len * 4; -pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, pointer_shape: pardes.Surface.PointerShape, cells: []const pardes.Cell, prev: []const pardes.Cell, layers: []const pardes.Layer, tags: []const pardes.Layer, regions: []const pardes.Region) Error![]const u8 { +pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, pointer_shape: pardes.Surface.PointerShape, cells: []const pardes.Cell, prev: []const pardes.Cell, layers: []const pardes.Layer, tags: []const pardes.Layer, regions: []const pardes.Region, chrome: pardes.Chrome) Error![]const u8 { // The protocol's ceiling, enforced by the ENCODER too, and BEFORE the // assert below so a caller can be told rather than tripped. `max_payload` // is derived from these two, so a larger grid is a frame this decoder @@ -793,6 +808,13 @@ pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, point try w.putByte(region.notice); try w.putU16(region.clip); } + inline for (@typeInfo(pardes.Chrome).@"struct".fields) |field| { + const rgb = @field(chrome, field.name); + if (field.type == ?[3]u8) { + try w.putBool(rgb != null); + if (rgb) |value| for (value) |channel| try w.putByte(channel); + } else for (rgb) |channel| try w.putByte(channel); + } } std.mem.writeInt(u32, w.buf[layers_len_at..][0..4], @intCast(w.n - layers_start), .little); const nruns_at = w.n; @@ -1871,7 +1893,8 @@ test "detached wire carries compact body rows and exact pointer metrics" { const placed = [_]pardes.Region{.{ .kind = .rail, .owner = 3, .serial = 42, .rect = .{ .x = 0, .y = 1, .w = 2, .h = 3 }, .active = true, .thumb_y = 1, .thumb_h = 2, .clip = 1 }}; const out = try gpa.alloc(u8, frameBound(2, 4) + layersBound(&.{layer}, &.{tag}, &placed)); defer gpa.free(out); - const encoded = try encodeFrameLayers(out, 2, 4, null, .link, &cells, &cells, &.{layer}, &.{tag}, &placed); + const palette: pardes.Chrome = .{ .page = .{ 1, 2, 3 }, .border = .{ 4, 5, 6 }, .tag_focus_bg = .{ 7, 8, 9 }, .scroll_thumb = .{ 10, 11, 12 } }; + const encoded = try encodeFrameLayers(out, 2, 4, null, .link, &cells, &cells, &.{layer}, &.{tag}, &placed, palette); const packet = (try framed(encoded)).?; const frame = (try decodeServer(packet.tag, packet.payload)).frame; var decoded: [pardes.MAX_PANES]pardes.Layer = @splat(.{}); @@ -1881,8 +1904,10 @@ test "detached wire carries compact body rows and exact pointer metrics" { try frame.apply(&cells); var regions: std.ArrayListUnmanaged(pardes.Region) = .empty; defer regions.deinit(gpa); - try frame.applyLayers(gpa, &decoded, &tags, ®ions); + var chrome: pardes.Chrome = .{}; + try frame.applyLayers(gpa, &decoded, &tags, ®ions, &chrome); try std.testing.expectEqualDeep(placed[0..], regions.items); + try std.testing.expectEqualDeep(palette, chrome); try std.testing.expectEqual(@as(u16, 6), decoded[0].rows); try std.testing.expect(decoded[0].context_separators.isSet(0)); try std.testing.expect(!decoded[0].context_separators.isSet(1)); @@ -1908,7 +1933,7 @@ test "detached wire carries compact body rows and exact pointer metrics" { try std.testing.expect(mouse_encoded.len <= clientBound(message)); var truncated = frame; truncated.layers = frame.layers[0 .. frame.layers.len - 1]; - try std.testing.expectError(error.Truncated, truncated.applyLayers(gpa, &decoded, &tags, ®ions)); + try std.testing.expectError(error.Truncated, truncated.applyLayers(gpa, &decoded, &tags, ®ions, &chrome)); for (decoded) |item| { try std.testing.expectEqual(@as(u16, 0), item.rows); try std.testing.expectEqual(@as(usize, 0), item.cells.len); @@ -1922,6 +1947,6 @@ test "detached wire carries compact body rows and exact pointer metrics" { const no_layers = try encodeFramePointer(out, 2, 4, null, .arrow, &cells, &cells); const cleared_packet = (try framed(no_layers)).?; const cleared = (try decodeServer(cleared_packet.tag, cleared_packet.payload)).frame; - try cleared.applyLayers(gpa, &decoded, &tags, ®ions); + try cleared.applyLayers(gpa, &decoded, &tags, ®ions, &chrome); try std.testing.expectEqual(@as(u16, 0), decoded[0].rows); } diff --git a/src/draw.zig b/src/draw.zig index 7cfe7554..4242a6e7 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -47,14 +47,7 @@ const NOTICE_LAYER_BASE = pardes.NOTICE_LAYER_BASE; /// painters below and every shell read these rectangles. pub fn place(p: *Pardes, s: *Surface) void { s.nregions = 0; - const add = struct { - fn region(surface: *Surface, r: Region) void { - std.debug.assert(surface.nregions < surface.regions.len); - surface.regions[surface.nregions] = r; - surface.nregions += 1; - } - }.region; - add(s, .{ .kind = .page, .rect = .{ .x = 0, .y = 0, .w = s.cols, .h = s.rows } }); + s.addRegion(.{ .kind = .page, .rect = .{ .x = 0, .y = 0, .w = s.cols, .h = s.rows } }); for (&p.panes, 0..) |*slot, id| { const pane = slot.* orelse continue; Messages.collectNotices(p, pane, p.rects[id], id); @@ -64,12 +57,12 @@ pub fn place(p: *Pardes, s: *Surface) void { const active = id == p.active; const tag_y = p.tagTop(pane, r); const body_y = p.bodyTop(pane, r); - add(s, .{ .kind = .grip, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x, .y = tag_y, .w = config.GUTTER, .h = pane.tag_rows } }); - add(s, .{ .kind = .tag, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x + config.GUTTER, .y = tag_y, .w = r.w - config.GUTTER, .h = pane.tag_rows } }); + s.addRegion(.{ .kind = .grip, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x, .y = tag_y, .w = config.GUTTER, .h = pane.tag_rows } }); + s.addRegion(.{ .kind = .tag, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x + config.GUTTER, .y = tag_y, .w = r.w - config.GUTTER, .h = pane.tag_rows } }); // A folded pane is its tag rows and nothing else. if (pane.collapsed) continue; const body_h = r.h -| pane.tag_rows; - add(s, .{ .kind = .body, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x + config.GUTTER, .y = body_y, .w = r.w - config.GUTTER, .h = body_h } }); + s.addRegion(.{ .kind = .body, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x + config.GUTTER, .y = body_y, .w = r.w - config.GUTTER, .h = body_h } }); var rail: Region = .{ .kind = .rail, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x, .y = body_y, .w = config.GUTTER, .h = body_h } }; // An image's rail has no thumb; a native PDF's is measured as its // pages are placed (Pdf.draw); every other pane's is here. @@ -93,7 +86,7 @@ pub fn place(p: *Pardes, s: *Surface) void { rail.thumb_y = @intCast(@min(pos, track_h)); rail.thumb_h = @intCast(@min(len, track_h - rail.thumb_y)); } - add(s, rail); + s.addRegion(rail); // Each notice a chip at the right edge, one to a row in the order // collectNotices chose, as wide as its own message and no wider, so // the text beneath the rest of the row keeps reading as the file. @@ -106,7 +99,7 @@ pub fn place(p: *Pardes, s: *Surface) void { const chip = Messages.noticeCols(p, text, tw); if (chip == 0) continue; pane.notices.left[i] = tx + tw - chip; - add(s, .{ .kind = .notice, .owner = owner, .serial = pane.serial, .active = active, .notice = @intCast(i), .rect = .{ .x = tx + tw - chip, .y = body_y + @as(u16, pane.notices.row[i]), .w = chip, .h = BOX_H } }); + s.addRegion(.{ .kind = .notice, .owner = owner, .serial = pane.serial, .active = active, .notice = @intCast(i), .rect = .{ .x = tx + tw - chip, .y = body_y + @as(u16, pane.notices.row[i]), .w = chip, .h = BOX_H } }); } } const column_rows = p.columnBarHeight(); @@ -114,11 +107,16 @@ pub fn place(p: *Pardes, s: *Surface) void { if (p.col_w[c] == 0) continue; const active = if (layout.findPane(p, p.active)) |found| found.col == c else false; const inset = @min(TAG_TEXT_INSET, p.col_w[c]); - add(s, .{ .kind = .column_grip, .owner = @intCast(c), .serial = p.col_serial[c], .active = active, .rect = .{ .x = p.col_x[c], .y = p.topBarHeight(), .w = @min(config.GUTTER, p.col_w[c]), .h = column_rows } }); - add(s, .{ .kind = .column_tag, .owner = @intCast(c), .serial = p.col_serial[c], .active = active, .rect = .{ .x = p.col_x[c] + inset, .y = p.topBarHeight(), .w = p.col_w[c] - inset, .h = column_rows } }); + s.addRegion(.{ .kind = .column_grip, .owner = @intCast(c), .serial = p.col_serial[c], .active = active, .rect = .{ .x = p.col_x[c], .y = p.topBarHeight(), .w = @min(config.GUTTER, p.col_w[c]), .h = column_rows } }); + s.addRegion(.{ .kind = .column_tag, .owner = @intCast(c), .serial = p.col_serial[c], .active = active, .rect = .{ .x = p.col_x[c] + inset, .y = p.topBarHeight(), .w = p.col_w[c] - inset, .h = column_rows } }); }; + const active_col = if (layout.findPane(p, p.active)) |found| found.col else null; + for (0..p.ncol) |c| { + if (p.col_w[c] == 0) continue; + s.addRegion(.{ .kind = .column, .owner = @intCast(c), .serial = p.col_serial[c], .active = active_col == c, .rect = .{ .x = p.col_x[c], .y = p.topBarHeight(), .w = p.col_w[c], .h = s.rows -| p.topBarHeight() } }); + } if (p.settings.workspace_tag) - add(s, .{ .kind = .workspace_tag, .rect = .{ .x = 0, .y = 0, .w = s.cols, .h = p.topBarHeight() } }); + s.addRegion(.{ .kind = .workspace_tag, .rect = .{ .x = 0, .y = 0, .w = s.cols, .h = p.topBarHeight() } }); // A column carried to the window's right edge shows where it lands as a // rail in the last cell: the layers under it stop a cell short. if (p.drag == .column_move) rail: { @@ -176,8 +174,20 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { s.previous_cells = &.{}; s.previous_body_layers = &.{}; s.previous_tag_layers = &.{}; + s.previous_regions = &.{}; s.cell_diffs = &.{}; const chrome = p.chromeTheme(); + s.chrome = .{ + .page = p.theme().bg, + .fg = p.theme().fg, + .border = chrome.border, + .topbar_rule = config.gui_topbar_pane_border_rgb orelse chrome.border, + .tag_bg = chrome.tag_bg, + .tag_focus_bg = if (p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg, + .tag_fg = chrome.tag_fg, + .scroll_track = chrome.scroll_track, + .scroll_thumb = chrome.scroll_thumb, + }; place(p, s); // Separators have their own role; a scrollbar need not frame the page. const whole = s.regions[0].rect; @@ -203,16 +213,20 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { if (d.cur_x < s.cols) { var row: u16 = p.topBarHeight(); while (row < s.rows) : (row += 1) s.overlayDash(d.cur_x, row, "╎"); + s.addRegion(.{ .kind = .guide, .rect = .{ .x = d.cur_x, .y = p.topBarHeight(), .w = 1, .h = s.rows -| p.topBarHeight() } }); } if (d.corner) |k| if (d.cur_y < s.rows) { - var col = if (k.col == d.left_col) p.col_x[k.col] else d.cur_x +| 1; + const first = if (k.col == d.left_col) p.col_x[k.col] else d.cur_x +| 1; const end = if (k.col == d.left_col) d.cur_x else p.col_x[k.col] +| p.col_w[k.col] -| 1; + var col = first; while (col <= end and col < s.cols) : (col += 1) s.overlayDash(col, d.cur_y, "╌"); + if (col > first) s.addRegion(.{ .kind = .guide, .rect = .{ .x = first, .y = d.cur_y, .w = col - first, .h = 1 } }); }; }, .border_h => |d| if (d.cur_y < s.rows) { var col = p.col_x[d.col]; while (col < p.col_x[d.col] + p.col_w[d.col]) : (col += 1) s.overlayDash(col, d.cur_y, "╌"); + s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.col_x[d.col], .y = d.cur_y, .w = p.col_w[d.col], .h = 1 } }); }, .column_move => |d| rail: { const source = layout.columnBySerial(p, d.serial) orelse break :rail; @@ -232,13 +246,20 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { // color: this is where the grabbed column lands. const x = @min(if (edge == 0) config.GUTTER - 1 else edge - 1, s.cols -| 1); var row: u16 = p.topBarHeight(); + // A guide region for each run of it the tags do not break. + var run: u16 = row; while (row < s.rows) : (row += 1) { // Past the left edge's scroll rail the rail would cut // through column 0's grip and pane boxes: leave them whole. - if (edge == 0 and s.at(x, row).style.font_role == .tagline) continue; + if (edge == 0 and s.at(x, row).style.font_role == .tagline) { + if (row > run) s.addRegion(.{ .kind = .guide, .rect = .{ .x = x, .y = run, .w = 1, .h = row - run } }); + run = row + 1; + continue; + } s.overlayDash(x, row, "╎"); s.at(x, row).style.fg = .{ .rgb = chrome.column_box }; } + if (row > run) s.addRegion(.{ .kind = .guide, .rect = .{ .x = x, .y = run, .w = 1, .h = row - run } }); }, .move => |d| if (d.cur_x < s.cols) { if (layout.movePlacement(p, d.id, d.cur_x, d.cur_y)) |placement| { @@ -247,6 +268,8 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { s.set(col, placement.row, "╌", .{ .fg = .{ .rgb = chrome.lineno } }); } s.set(d.cur_x, placement.row, "▌", .{ .fg = .{ .rgb = chrome.lineno } }); + s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.col_x[placement.preview_col], .y = placement.row, .w = p.col_w[placement.preview_col], .h = 1 } }); + s.addRegion(.{ .kind = .guide, .rect = .{ .x = d.cur_x, .y = placement.row, .w = 1, .h = 1 } }); } }, .none, .select => if (p.pointer_inside) { @@ -256,6 +279,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { if (p.hover_col == p.col_x[c] + p.col_w[c] -| 1) { var row: u16 = p.topBarHeight(); while (row < s.rows) : (row += 1) s.overlayDash(p.hover_col, row, "╎"); + s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.hover_col, .y = p.topBarHeight(), .w = 1, .h = s.rows -| p.topBarHeight() } }); } } for (0..p.ncol) |cc| { @@ -264,6 +288,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { if (mouse.seamIdxAt(p, cc, p.hover_row) != null) { var col = p.col_x[cc]; while (col < p.col_x[cc] + p.col_w[cc]) : (col += 1) s.overlayDash(col, p.hover_row, "╌"); + s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.col_x[cc], .y = p.hover_row, .w = p.col_w[cc], .h = 1 } }); } } for (0..p.ncol -| 1) |cn| { @@ -272,6 +297,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { if (mouse.seamIdxAt(p, cn + 1, p.hover_row) == null) continue; var col = p.col_x[cn + 1]; while (col < p.col_x[cn + 1] + p.col_w[cn + 1]) : (col += 1) s.overlayDash(col, p.hover_row, "╌"); + s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.col_x[cn + 1], .y = p.hover_row, .w = p.col_w[cn + 1], .h = 1 } }); } }, } @@ -309,6 +335,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { const oh: u16 = 10; const ox: u16 = if (s.cols > ow) s.cols - ow else 0; if (ox + ow <= s.cols and p.topBarHeight() + oh <= s.rows) { + s.addRegion(.{ .kind = .debug, .rect = .{ .x = ox, .y = p.topBarHeight(), .w = ow, .h = oh } }); // vaxis single_rounded border ring, default style s.set(ox, p.topBarHeight(), "╭", .{}); s.set(ox + ow - 1, p.topBarHeight(), "╮", .{}); diff --git a/src/gui/gui.zig b/src/gui/gui.zig index ff99bd39..35fbc99d 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -21,6 +21,7 @@ const c_heap = @import("c_heap"); const ninep_io = @import("../9p_io.zig"); const detached_client = @import("../detached/client.zig"); +const tracy = @import("../tracy.zig"); pub const c = @cImport({ @cDefine("SDL_DISABLE_OLD_NAMES", "1"); @@ -37,6 +38,7 @@ const log = std.log.scoped(.gui); const font_ttf = @embedFile("AdwaitaMono-Regular.ttf"); const vert_spv = @embedFile("ui.vert.spv"); const frag_spv = @embedFile("ui.frag.spv"); +const decor_frag_spv = @embedFile("decor.frag.spv"); const overlay_vert_spv = @embedFile("overlay.vert.spv"); const overlay_frag_spv = @embedFile("overlay.frag.spv"); const image_vert_spv = @embedFile("image.vert.spv"); @@ -443,6 +445,11 @@ const CellInstance = extern struct { progress_bits: u32 = @bitCast(@as(f32, 1)), serial: u32 = 0, cell_coord: u32 = 0, + /// A clip rectangle in target pixels; x1 < x0 is none (ui.vert.glsl). + clip_x0: f32 = 0, + clip_y0: f32 = 0, + clip_x1: f32 = -1, + clip_y1: f32 = -1, }; const OverlayVertex = extern struct { @@ -545,7 +552,8 @@ test "saved native placement retains identity and geometry but no producer bytes } test "shader instance ABI carries aligned transition vectors" { - try std.testing.expectEqual(@as(usize, 104), @sizeOf(CellInstance)); + try std.testing.expectEqual(@as(usize, 120), @sizeOf(CellInstance)); + try std.testing.expectEqual(@as(usize, 104), @offsetOf(CellInstance, "clip_x0")); try std.testing.expectEqual(@as(usize, 56), @offsetOf(CellInstance, "panel_x0")); try std.testing.expectEqual(@as(usize, 72), @offsetOf(CellInstance, "present_x0")); try std.testing.expectEqual(@as(usize, 88), @offsetOf(CellInstance, "effect")); @@ -560,12 +568,17 @@ test "shader instance ABI carries aligned transition vectors" { .{ .pane = 1, .phase = .moving, .effect = .slide }, .{ .pane = 2, .phase = .moving, .effect = .slide }, }; - const plan = makePaintPlan(&tracks, true); - try std.testing.expectEqual(@as(usize, 4), plan.len); - try std.testing.expect(plan.batches[0].track == null); - try std.testing.expectEqual(@as(u8, 1), plan.batches[1].track.?.pane); - try std.testing.expectEqual(@as(u8, 2), plan.batches[2].track.?.pane); - try std.testing.expectEqual(@as(u8, 0), plan.batches[3].track.?.pane); + // Tier 0, the tracks as the core ordered them, then tiers 4 and 5. + const groups = makeGroups(&tracks, true); + try std.testing.expectEqual(@as(usize, 7), groups.len); + try std.testing.expect(groups.items[0].track == null); + try std.testing.expectEqual(@as(u8, 0), groups.items[1].track.?.pane); + try std.testing.expectEqual(@as(u8, 1), groups.items[2].track.?.pane); + try std.testing.expectEqual(@as(u8, 2), groups.items[3].track.?.pane); + try std.testing.expectEqual(@as(usize, 4), groups.notices); + try std.testing.expectEqual(@as(usize, 5), groups.guides); + try std.testing.expectEqual(@as(usize, 6), groups.ink); + for ([_]usize{ groups.notices, groups.guides, groups.ink }) |index| try std.testing.expect(groups.items[index].track == null); } test "GUI paint plan snaps history effects without a frozen grid" { @@ -573,11 +586,11 @@ test "GUI paint plan snaps history effects without a frozen grid" { .{ .pane = 0, .phase = .opening, .effect = .ascii }, .{ .pane = 1, .phase = .closing, .effect = .vertical }, }; - try std.testing.expectEqual(@as(usize, 1), makePaintPlan(&tracks, false).len); - const ready = makePaintPlan(&tracks, true); - try std.testing.expectEqual(@as(usize, 3), ready.len); - try std.testing.expectEqual(pardes.animation.Phase.opening, ready.batches[1].track.?.phase); - try std.testing.expectEqual(pardes.animation.Phase.closing, ready.batches[2].track.?.phase); + try std.testing.expectEqual(@as(usize, 0), makeGroups(&tracks, false).tracks); + const ready = makeGroups(&tracks, true); + try std.testing.expectEqual(@as(usize, 2), ready.tracks); + try std.testing.expectEqual(pardes.animation.Phase.opening, ready.items[1].track.?.phase); + try std.testing.expectEqual(pardes.animation.Phase.closing, ready.items[2].track.?.phase); } test "closing tombstone overlays but never owns canonical cells" { @@ -588,10 +601,10 @@ test "closing tombstone overlays but never owns canonical cells" { .from = .{ .x = 2, .y = 3, .w = 10, .h = 4 }, .to = .{ .x = 2, .y = -1, .w = 10, .h = 4 }, }; - const plan = makePaintPlan(&.{closing}, true); - try std.testing.expectEqual(@as(usize, 0), paintBatchAt(&plan, 4, 4)); - try std.testing.expectEqual(@as(usize, 2), plan.len); - try std.testing.expectEqual(pardes.animation.Phase.closing, plan.batches[1].track.?.phase); + const groups = makeGroups(&.{closing}, true); + try std.testing.expectEqual(@as(usize, 0), groupAt(&groups, 4, 4)); + try std.testing.expectEqual(@as(usize, 1), groups.tracks); + try std.testing.expectEqual(pardes.animation.Phase.closing, groups.items[1].track.?.phase); } fn updateCoreResize(core: *pardes.Pardes, cols: u16, rows: u16, cell_w: u32, cell_h: u32, tag_w: u32, tag_h: u32) bool { @@ -1745,6 +1758,7 @@ const Gui = struct { device: *c.SDL_GPUDevice, swapchain_format: c.SDL_GPUTextureFormat, pipeline: *c.SDL_GPUGraphicsPipeline, + decor_pipeline: *c.SDL_GPUGraphicsPipeline, overlay_pipeline: *c.SDL_GPUGraphicsPipeline, image_pipeline: *c.SDL_GPUGraphicsPipeline, crt_pipeline: *c.SDL_GPUGraphicsPipeline, @@ -1764,9 +1778,11 @@ const Gui = struct { native_images: std.AutoHashMapUnmanaged(pardes.ImageCacheKey, *c.SDL_GPUTexture) = .empty, presented_images: std.ArrayListUnmanaged(SavedImagePlace) = .empty, prepared_images: std.ArrayListUnmanaged(PreparedImage) = .empty, - /// renderFrame's cover of the grid by layers (coverLayers): this frame's - /// layers, then the layers a panel transition is leaving. - layer_cover: std.ArrayListUnmanaged(bool) = .empty, + /// renderFrame's cover of the grid (coverFrame): this frame's, then the + /// frame a panel transition is leaving. + cover: std.ArrayListUnmanaged(Cover) = .empty, + /// renderFrame's decor instances, group by group (buildDecor). + decor: std.ArrayListUnmanaged(CellInstance) = .empty, font: *c.UIFont, font_bytes: []u8 = &.{}, @@ -1996,47 +2012,127 @@ fn taglineLayoutForCell(g: *const Gui, col: u16) CellLayout { const boxContains = pardes.animation.Box.contains; -const PaintBatch = struct { +/// What one tier draws, or one track in tiers 2 and 3 (docs/render-pipeline.md +/// §3.4), in this order: its cells, its images, then its decor. +const Group = struct { track: ?pardes.animation.Track = null, cell_start: u32 = 0, cell_count: u32 = 0, image_start: u32 = 0, image_count: u32 = 0, + decor_start: u32 = 0, + decor_count: u32 = 0, }; -const PaintPlan = struct { - batches: [pardes.MAX_PANES * 2 + 1]PaintBatch = @splat(.{}), - len: usize = 1, +/// The frame's draw groups, bottom to top: tier 0 (the page and the panes +/// standing still), a group for each track in paint order (tier 2 moving and +/// opening, tier 3 closing), tier 4 (floating: the notices, then the guides +/// and the debug box, which the core paints over them) and tier 5 (ink: the +/// bar cursors; the pet and the touch overlay draw after it). Tier 1 is the +/// focus lift, which does not exist yet. +const Groups = struct { + items: [pardes.MAX_PANES * 2 + 4]Group = @splat(.{}), + /// Groups 1 through `tracks` are the tracks'. + tracks: usize = 0, + notices: usize = 1, + guides: usize = 2, + ink: usize = 3, + len: usize = 4, }; -fn makePaintPlan(tracks: []const pardes.animation.Track, has_diff: bool) PaintPlan { - var plan: PaintPlan = .{}; - for ([_]pardes.animation.Phase{ .moving, .opening, .closing }) |phase| { - for (tracks) |track| { - if (!track.active() or track.phase != phase) continue; - if (track.effect.needsPreviousGrid() and !has_diff) continue; - std.debug.assert(plan.len < plan.batches.len); - plan.batches[plan.len].track = track; - plan.len += 1; - } +fn makeGroups(tracks: []const pardes.animation.Track, has_diff: bool) Groups { + var groups: Groups = .{}; + for (tracks) |track| { + if (!track.active()) continue; + if (track.effect.needsPreviousGrid() and !has_diff) continue; + std.debug.assert(groups.tracks < pardes.MAX_PANES * 2); + groups.tracks += 1; + groups.items[groups.tracks].track = track; } - return plan; + groups.notices = groups.tracks + 1; + groups.guides = groups.tracks + 2; + groups.ink = groups.tracks + 3; + groups.len = groups.tracks + 4; + return groups; } -fn paintBatchAt(plan: *const PaintPlan, col: u16, row: u16) usize { - for (plan.batches[1..plan.len], 1..) |batch, index| { - if (batch.track.?.phase == .closing) continue; - if (boxContains(batch.track.?.to, col, row)) return index; +/// The group of the track whose pane covers this cell, else tier 0. +fn groupAt(groups: *const Groups, col: u16, row: u16) usize { + for (groups.items[1..][0..groups.tracks], 1..) |group, index| { + if (group.track.?.phase == .closing) continue; + if (boxContains(group.track.?.to, col, row)) return index; } return 0; } -fn paintBatchForSerial(plan: *const PaintPlan, serial: u32) usize { - for (plan.batches[1..plan.len], 1..) |batch, index| - if (batch.track.?.serial == serial) return index; +fn groupOf(groups: *const Groups, serial: u32) usize { + for (groups.items[1..][0..groups.tracks], 1..) |group, index| + if (group.track.?.serial == serial) return index; return 0; } +/// What draws a grid cell, marked once a frame from the layers and the +/// regions (coverFrame), so the cell loops read it rather than search. +const Cover = packed struct(u16) { + /// A layer draws it; the grid's copy is not drawn. + layer: bool = false, + /// Under a guide or the debug box: drawn from the grid over the panes. + floating: bool = false, + /// In a pane's grip, `grip_offset` cells from its left: the grip's + /// glyph is the cell that far to the left. + grip: bool = false, + grip_offset: u4 = 0, + /// In the focused pane's or column's tag rows. + focus: bool = false, + /// At a column's left edge, where a tag's anchor is inset. + anchor: bool = false, + _: u7 = 0, +}; + +/// Marks the grid cells a body or tag layer covers, which the layers paint +/// themselves, and what the regions say of the rest. Filling each rectangle +/// once replaces scanning every layer for every cell, which was a third of +/// the gui's CPU on a full screen of text. +fn coverFrame(cover: []Cover, cols: u16, rows: u16, body_layers: []const pardes.Layer, tag_layers: []const pardes.Layer, regions: []const pardes.Region) void { + @memset(cover, .{}); + const mark = struct { + fn rect(into: []Cover, width: u16, height: u16, r: pardes.Rect, comptime field: []const u8, value: anytype) void { + const right = @min(width, @as(u32, r.x) + r.w); + if (r.x >= right) return; + var y: u32 = r.y; + while (y < @min(height, @as(u32, r.y) + r.h)) : (y += 1) + for (into[y * width ..][r.x..right]) |*cell| { + @field(cell, field) = value; + }; + } + }; + for (body_layers) |*layer| { + if (layer.rows != 0) mark.rect(cover, cols, rows, layer.viewport, "layer", true); + } + for (tag_layers) |*layer| { + if (layer.rows != 0) mark.rect(cover, cols, rows, .{ .x = layer.viewport.x, .y = layer.viewport.y, .w = layer.viewport.w, .h = layer.rows }, "layer", true); + } + // The column bar's rows, which the focused column's tint spans. + var bar: ?pardes.Rect = null; + for (regions) |region| { + if (region.kind == .column_grip) bar = region.rect; + } + for (regions) |region| switch (region.kind) { + .grip => { + mark.rect(cover, cols, rows, region.rect, "grip", true); + for (0..@min(region.rect.w, 16)) |offset| mark.rect(cover, cols, rows, .{ .x = region.rect.x +| @as(u16, @intCast(offset)), .y = region.rect.y, .w = 1, .h = region.rect.h }, "grip_offset", @as(u4, @intCast(offset))); + if (region.active) mark.rect(cover, cols, rows, region.rect, "focus", true); + }, + .tag => if (region.active) mark.rect(cover, cols, rows, region.rect, "focus", true), + .column => { + mark.rect(cover, cols, rows, .{ .x = region.rect.x, .y = 0, .w = 1, .h = rows }, "anchor", true); + if (region.active) if (bar) |b| mark.rect(cover, cols, rows, .{ .x = region.rect.x, .y = b.y, .w = region.rect.w, .h = b.h }, "focus", true); + }, + .guide, .debug => mark.rect(cover, cols, rows, region.rect, "floating", true), + else => {}, + }; +} + fn panelCellCoord(track: pardes.animation.Track, col: u16, row: u16) u32 { const source = track.contentBox(); const x0: u16 = @intFromFloat(@max(0.0, @floor(source.x))); @@ -2101,6 +2197,15 @@ fn setTransitionFields( instance.progress_bits = @bitCast(active.amount()); instance.serial = active.serial; instance.cell_coord = cell_coord; + // A vertical transition shows its pane only inside the box it grows + // into, in whole pixels. + if (@hasField(@TypeOf(instance.*), "clip_x0") and active.effect == .vertical) { + const box = active.contentBox(); + instance.clip_x0 = std.math.clamp(@floor(box.x * layout.w), 0, win_w); + instance.clip_y0 = std.math.clamp(@floor(box.y * layout.h), 0, win_h); + instance.clip_x1 = std.math.clamp(@ceil((box.x + box.w) * layout.w), instance.clip_x0, win_w); + instance.clip_y1 = std.math.clamp(@ceil((box.y + box.h) * layout.h), instance.clip_y0, win_h); + } } pub const run = runNative; @@ -2276,7 +2381,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u 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 pipeline = try makePipeline(device, swapchain_format, frag_spv, 1, std.mem.zeroes(c.SDL_GPUColorTargetBlendState)); + const decor_pipeline = try makePipeline(device, swapchain_format, decor_frag_spv, 0, backgroundLayerBlend()); const overlay_pipeline = try makeOverlayPipeline(device, swapchain_format); const image_pipeline = try makeImagePipeline(device, swapchain_format); const crt_pipeline = try makeCrtPipeline(device, swapchain_format); @@ -2306,6 +2412,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u .device = device, .swapchain_format = swapchain_format, .pipeline = pipeline, + .decor_pipeline = decor_pipeline, .overlay_pipeline = overlay_pipeline, .image_pipeline = image_pipeline, .crt_pipeline = crt_pipeline, @@ -2343,6 +2450,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u // regrown to; the image ones are released below. defer { c.SDL_ReleaseGPUGraphicsPipeline(device, g.pipeline); + c.SDL_ReleaseGPUGraphicsPipeline(device, g.decor_pipeline); c.SDL_ReleaseGPUGraphicsPipeline(device, g.overlay_pipeline); c.SDL_ReleaseGPUGraphicsPipeline(device, g.crt_pipeline); c.SDL_ReleaseGPUTexture(device, g.atlas_tex); @@ -2368,7 +2476,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u g.native_images.deinit(gpa); g.presented_images.deinit(gpa); g.prepared_images.deinit(gpa); - g.layer_cover.deinit(gpa); + g.cover.deinit(gpa); + g.decor.deinit(gpa); } defer c.SDL_ReleaseGPUTransferBuffer(device, g.image_vxfer); defer c.SDL_ReleaseGPUBuffer(device, g.image_vbuf); @@ -2479,6 +2588,7 @@ fn localSession( .watches = &watches, .fs = fs, .test_mode = test_mode, + .test_clock = if (test_mode) host_io.testClock() else null, }; defer shell.shutdownPtys(); const host = shell.host(); @@ -2668,24 +2778,12 @@ fn attachedLoop(gpa: std.mem.Allocator, g: *Gui, client: *detached_client.Client } fn paintAttached(g: *Gui, gpa: std.mem.Allocator, client: *detached_client.Client) void { - var surface: pardes.Surface = .{ - .cols = client.cols, - .rows = client.rows, - .cells = client.grid.items, - .cursor = if (client.cursor) |cu| .{ .x = cu.x, .y = cu.y, .bar = cu.bar } else null, - .pointer_shape = client.pointer_shape, - .body_layers = client.body_layers, - .tag_layers = client.tag_layers, - }; - const chrome = frameChromeBg(&surface); + var surface = attachedSurface(client); _ = renderFrame( g, gpa, null, &surface, - null, - chrome, - chrome, .{}, false, ) catch |err| blk: { @@ -2694,17 +2792,67 @@ fn paintAttached(g: *Gui, gpa: std.mem.Allocator, client: *detached_client.Clien }; } -fn frameChromeBg(surface: *const pardes.Surface) [3]u8 { - if (surface.rows == 0 or surface.cols == 0) return bg_default; - for (surface.cells[0..surface.cols]) |cell| { - if (cell.default or cell.style.font_role != .tagline) continue; - return switch (cell.style.bg) { - .default => bg_default, - .index => |i| palColor(i), - .rgb => |rgb| rgb, - }; - } - return bg_default; +/// The session's frame as a surface: its cells, layers, regions and palette, +/// so its chrome is drawn from them as a local one's is. +fn attachedSurface(client: *const detached_client.Client) pardes.Surface { + var surface: pardes.Surface = .{ + .cols = client.cols, + .rows = client.rows, + .cells = client.grid.items, + .cursor = if (client.cursor) |cu| .{ .x = cu.x, .y = cu.y, .bar = cu.bar } else null, + .pointer_shape = client.pointer_shape, + .body_layers = client.body_layers, + .tag_layers = client.tag_layers, + .chrome = client.chrome, + }; + const placed = client.regions.items[0..@min(client.regions.items.len, surface.regions.len)]; + @memcpy(surface.regions[0..placed.len], placed); + surface.nregions = @intCast(placed.len); + return surface; +} + +test "an attached frontend draws the session's chrome from its frame alone" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); + defer core.deinit(); + // A gui-shaped core, whose tags and notices are layers. + core.update(.{ .resize = .{ .cols = 80, .rows = 24, .row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 6, .tagline_h = 12 } } }); + _ = try core.newShell(1, ""); + try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); + core.settings.focus_tint = true; + try std.testing.expect(core.executeBuiltinLine(0, "Msg a notice with a rule under it")); + pardes.test_api.sync(core); + // Past its slide into place, where its rule shows. + for (0..30) |_| core.update(.tick); + const s = try core.render(core.scratch.allocator()); + + const wire = @import("../detached/wire.zig"); + var client: detached_client.Client = .{ .gpa = gpa }; + defer client.deinit(); + var welcome: [64]u8 = undefined; + try client.in.appendSlice(gpa, try wire.encodeServer(&welcome, .{ .welcome = .{ .slot = 0, .cols = s.cols, .rows = s.rows } })); + const out = try gpa.alloc(u8, wire.frameBound(s.cols, s.rows) + wire.layersBound(&s.body_layers, &s.tag_layers, s.regionList())); + defer gpa.free(out); + try client.in.appendSlice(gpa, try wire.encodeFrameLayers(out, s.cols, s.rows, null, s.pointer_shape, s.cells, &.{}, &s.body_layers, &s.tag_layers, s.regionList(), s.chrome)); + _ = try client.next(); + _ = try client.next(); + const attached = attachedSurface(&client); + + var g: Gui = undefined; + g.cell_w = 10; + g.cell_h = 20; + g.tagline_width = 6; + g.tagline_height = 12; + g.decor = .empty; + defer g.decor.deinit(gpa); + var groups = makeGroups(&.{}, false); + try buildDecor(&g, gpa, s, &groups, fixedCellLayout(&g), 800, 480, true); + const local = try gpa.dupe(CellInstance, g.decor.items); + defer gpa.free(local); + try std.testing.expect(local.len > 8); + try std.testing.expect(groups.items[groups.notices].decor_count != 0); + try buildDecor(&g, gpa, &attached, &groups, fixedCellLayout(&g), 800, 480, true); + try std.testing.expectEqualSlices(u8, std.mem.sliceAsBytes(local), std.mem.sliceAsBytes(g.decor.items)); } fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { @@ -3708,6 +3856,8 @@ const Shell = struct { saw_event: bool = false, dump_err: ?anyerror = null, retired_shells: [pardes.MAX_PANES]RetiredShell = @splat(.{}), + /// PARDES_TEST_CLOCK's virtual time (host_io.testClock), in test mode. + test_clock: ?u64 = null, fn reap(s: *Shell) void { const now = shellClock(); @@ -3898,13 +4048,31 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { var in: Input = .{ .core = core }; if (s.gui) |g| { var sev = std.mem.zeroes(c.SDL_Event); + // Idle is asleep until an event: SDL's own, and the user events the + // shell's queue raises for a terminal's output, a completion, a file + // system request. What the shell moves or times itself polls every + // frame: a smooth scroll, a gamepad's sticks, the test stdin feed, + // a retired shell waiting to be killed, a present to retry. A + // minimized window sleeps through the core's animation too: nothing + // shows it, and it is caught up at once when the window is back. + const retiring = for (s.retired_shells) |child| { + if (child.kill_at != 0) break true; + } else for (s.ptys) |slot| { + if (slot) |pt| if (pt.kill_at != 0) break true; + } else false; + const polls = g.scroll_lag != 0 or g.scroll_delta != 0 or g.gamepad != null or s.test_mode or core.needs_frame or retiring; + const minimized = !s.test_mode and c.SDL_GetWindowFlags(g.window) & (c.SDL_WINDOW_MINIMIZED | c.SDL_WINDOW_OCCLUDED) != 0; // A latency trace is fed on stdin, whose poll below paces the loop: // waiting here too would make every tick cost two frames. - const ms: c_int = if (latency_fd >= 0) 0 else if (timeout_ms != 0) @intCast(timeout_ms) else 16; + const ms: c_int = if (latency_fd >= 0) 0 else if (minimized) -1 else if (timeout_ms != 0) @intCast(timeout_ms) else if (polls) 16 else -1; // The wait is the 9P connections' turn with the core. pardes.turn.rest(); const got = c.SDL_WaitEventTimeout(&sev, ms); pardes.turn.wake(); + // The core's wake ran out: a virtual clock moves exactly there. + if (!got and timeout_ms != 0) if (s.test_clock) |*virtual| { + virtual.* = @max(virtual.*, core.nextWake() orelse virtual.*); + }; if (got) { // A run of queued motions is one move to its last position (the // handler reads only absolute x/y and held buttons): acting on @@ -4044,9 +4212,6 @@ fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void { s.gpa, core, frame, - core.theme().bg, - config.gui_topbar_pane_border_rgb orelse core.chromeTheme().border, - core.chromeTheme().tag_bg, core.settings.scene_effects, core.settings.debug, ) catch |err| blk: { @@ -4078,8 +4243,8 @@ fn postPresent(ctx: ?*anyopaque) void { s.core.acknowledgePanelPresentation(frame.panelTracks()); } -fn guiNow(_: ?*anyopaque) u64 { - return c.SDL_GetTicksNS(); +fn guiNow(ctx: ?*anyopaque) u64 { + return shellOf(ctx).test_clock orelse c.SDL_GetTicksNS(); } fn gridPollFrame(ctx: ?*anyopaque) void { @@ -4846,7 +5011,7 @@ fn prepareNativeImages( gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, surface: *pardes.Surface, - plan: *PaintPlan, + groups: *Groups, sw: u32, sh: u32, ) bool { @@ -4872,9 +5037,9 @@ fn prepareNativeImages( } g.prepared_images.clearRetainingCapacity(); - for (plan.batches[0..plan.len], 0..) |*batch, batch_index| { - batch.image_start = std.math.cast(u32, g.prepared_images.items.len) orelse return false; - const track = batch.track; + for (groups.items[0..groups.len], 0..) |*group, group_index| { + group.image_start = std.math.cast(u32, g.prepared_images.items.len) orelse return false; + const track = group.track; if (track) |active| { if (active.phase == .closing) { @@ -4894,7 +5059,7 @@ fn prepareNativeImages( for (surface.images[0..surface.nimages]) |maybe| { const current = maybe orelse continue; if (validImageBytes(current) == null or - paintBatchForSerial(plan, current.serial) != batch_index) continue; + groupOf(groups, current.serial) != group_index) continue; const saved = SavedImagePlace.from(current); const current_track = if (track) |active| switch (active.effect) { .dissolve => if (snapshotContainsPlacement(g, current)) null else active, @@ -4903,7 +5068,7 @@ fn prepareNativeImages( appendPreparedImage(g, gpa, saved, current_track, null, false); } } - if (batch_index == 0) for (plan.batches[1..plan.len]) |tracked| { + if (group_index == 0) for (groups.items[1..][0..groups.tracks]) |tracked| { const active = tracked.track.?; if (active.effect != .vertical or active.phase != .opening) continue; for (g.presented_images.items) |saved| { @@ -4912,7 +5077,7 @@ fn prepareNativeImages( } }; const image_end = std.math.cast(u32, g.prepared_images.items.len) orelse return false; - batch.image_count = image_end - batch.image_start; + group.image_count = image_end - group.image_start; } const capacity = std.math.cast(u32, g.prepared_images.items.len) orelse return false; @@ -4981,11 +5146,10 @@ fn drawNativeImagesGpu( g: *Gui, rp: ?*c.SDL_GPURenderPass, surface: *pardes.Surface, - plan: *const PaintPlan, - batch_index: usize, + batch: Group, + whole: c.SDL_Rect, ) void { const pass = rp orelse return; - const batch = plan.batches[batch_index]; c.SDL_BindGPUGraphicsPipeline(pass, g.image_pipeline); const max_w = @as(u32, surface.cols) * g.cell_w; const max_h = @as(u32, surface.rows) * g.cell_h; @@ -5004,12 +5168,8 @@ fn drawNativeImagesGpu( idx += 1; } std.debug.assert(idx == batch.image_start + batch.image_count); - const whole = c.SDL_Rect{ - .x = 0, - .y = 0, - .w = @intCast(@as(u32, surface.cols) * g.cell_w), - .h = @intCast(@as(u32, surface.rows) * g.cell_h), - }; + // Back to the whole target: what draws after a picture may run on past + // the grid, as a rule and a band do. c.SDL_SetGPUScissor(pass, &whole); } @@ -5018,9 +5178,6 @@ fn renderFrame( gpa: std.mem.Allocator, core: ?*const pardes.Pardes, surface: *pardes.Surface, - theme_bg: ?[3]u8, - topbar_pane_border_rgb: [3]u8, - tagline_rgb: [3]u8, scene_effects: pardes.animation.SceneEffect, debug_on: bool, ) !bool { @@ -5084,45 +5241,39 @@ fn renderFrame( }; const scene: *c.SDL_GPUTexture = if (scene_on) g.scene_tex.? else target; + // The frame's CPU side: groups, decor, cover and every instance, up to + // their upload. + const build_zone = tracy.zone(@src(), "gui frame build"); + var building = true; + defer if (building) build_zone.end(); const layout = fixedCellLayout(g); - var paint_plan = makePaintPlan(surface.panelTracks(), surface.hasPanelDiff()); - var overlay_count = buildOverlay( - g, - core, - surface, - layout, - sw, - sh, - topbar_pane_border_rgb, - tagline_rgb, - paint_plan.len == 1, - debug_on, - ); + const chrome = &surface.chrome; + var groups = makeGroups(surface.panelTracks(), surface.hasPanelDiff()); + // Cursors stand still while panes move, as they always have. + const cursors_shown = groups.tracks == 0; + var overlay_count = buildOverlay(g, core, surface, sw, sh, debug_on); if (overlay_count != 0 and !uploadOverlayGpu(g, cmd, overlay_count)) overlay_count = 0; - if (!prepareNativeImages(g, gpa, cmd, surface, &paint_plan, sw, sh)) { + if (!prepareNativeImages(g, gpa, cmd, surface, &groups, sw, sh)) { g.prepared_images.clearRetainingCapacity(); - for (paint_plan.batches[0..paint_plan.len]) |*batch| { - batch.image_start = 0; - batch.image_count = 0; + for (groups.items[0..groups.len]) |*group| { + group.image_start = 0; + group.image_count = 0; } } const cells: u32 = @as(u32, surface.cols) * surface.rows; - try g.layer_cover.resize(gpa, @as(usize, cells) * 2); - const covered = g.layer_cover.items[0..cells]; - coverLayers(covered, surface.cols, surface.rows, surface.bodyLayers(), surface.tagLayers()); + try g.cover.resize(gpa, @as(usize, cells) * 2); + const covered = g.cover.items[0..cells]; + coverFrame(covered, surface.cols, surface.rows, surface.bodyLayers(), surface.tagLayers(), surface.regionList()); // Only a panel transition paints the grid under the layers it leaves. - const previous_covered = g.layer_cover.items[cells..]; - if (paint_plan.len > 1) - coverLayers(previous_covered, surface.cols, surface.rows, surface.previous_body_layers, surface.previous_tag_layers); + const previous_covered = g.cover.items[cells..]; + if (groups.tracks != 0) + coverFrame(previous_covered, surface.cols, surface.rows, surface.previous_body_layers, surface.previous_tag_layers, surface.previous_regions); + try buildDecor(g, gpa, surface, &groups, layout, win_w, win_h, cursors_shown); - const page = ground(theme_bg, g.transparent); + const page = ground(chrome.page, g.transparent); const background_opacity = backgroundOpacity(g.applied_window_opacity); - const tagline_base = pardes.Cell{ - .style = .{ .bg = .{ .rgb = tagline_rgb }, .font_role = .tagline }, - .default = false, - }; var color_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo); color_target.texture = scene; color_target.clear_color = if (page.clear) .{ .r = 0, .g = 0, .b = 0, .a = 0 } else .{ @@ -5139,28 +5290,21 @@ fn renderFrame( while (row < surface.rows) : (row += 1) { var col: u16 = 0; while (col < surface.cols) : (col += 1) { - if (covered[@as(usize, row) * surface.cols + col]) continue; - const batch_index = paintBatchAt(&paint_plan, col, row); - const track = paint_plan.batches[batch_index].track; - const data_effect = if (track) |active| - active.effect == .dissolve - else - false; - const data_diff = data_effect and gripCellChanged(surface, col, row); - const logical_idx: u32 = @as(u32, row) * surface.cols + col; + const at = @as(usize, row) * surface.cols + col; + if (covered[at].layer and !covered[at].floating) continue; + const group_index = if (covered[at].floating) groups.guides else groupAt(&groups, col, row); + const track = groups.items[group_index].track; + const data_effect = if (track) |active| active.effect == .dissolve else false; + const data_diff = data_effect and gripCellChanged(surface, covered, previous_covered, col, row); const instance_count = if (data_diff) - cellInstanceCount(&surface.previous_cells[logical_idx]) + cellInstanceCount(surface.at(col, row)) + cellInstanceCount(&surface.previous_cells[at]) + cellInstanceCount(&surface.cells[at]) else - cellInstanceCount(surface.at(col, row)); - paint_plan.batches[batch_index].cell_count = std.math.add( - u32, - paint_plan.batches[batch_index].cell_count, - instance_count, - ) catch return error.GpuCapacity; + cellInstanceCount(&surface.cells[at]); + groups.items[group_index].cell_count = std.math.add(u32, groups.items[group_index].cell_count, instance_count) catch return error.GpuCapacity; } } - for (paint_plan.batches[1..paint_plan.len]) |*batch| { - const track = batch.track.?; + for (groups.items[1..][0..groups.tracks]) |*group| { + const track = group.track.?; const duplicate_under = track.effect == .vertical and track.phase == .opening; if (!duplicate_under and track.phase != .closing) continue; row = 0; @@ -5168,33 +5312,29 @@ fn renderFrame( var col: u16 = 0; while (col < surface.cols) : (col += 1) { if (!boxContains(track.contentBox(), col, row)) continue; - if (previous_covered[@as(usize, row) * surface.cols + col]) continue; - const destination = if (duplicate_under) &paint_plan.batches[0] else batch; - const logical_idx: u32 = @as(u32, row) * surface.cols + col; - destination.cell_count = std.math.add( - u32, - destination.cell_count, - cellInstanceCount(&surface.previous_cells[logical_idx]), - ) catch return error.GpuCapacity; + const at = @as(usize, row) * surface.cols + col; + if (previous_covered[at].layer) continue; + const destination = if (duplicate_under) &groups.items[0] else group; + destination.cell_count = std.math.add(u32, destination.cell_count, cellInstanceCount(&surface.previous_cells[at])) catch return error.GpuCapacity; } } } for (surface.bodyLayers()) |*layer| { if (layer.rows == 0) continue; - const batch = &paint_plan.batches[paintBatchAt(&paint_plan, layer.viewport.x, layer.viewport.y)]; + const group = &groups.items[groupAt(&groups, layer.viewport.x, layer.viewport.y)]; const bottom = @as(f32, @floatFromInt(layer.viewport.y + layer.viewport.h)) * layout.h; for (0..layer.rows) |lr| { if (layer.rowTop(@intCast(lr), layout.h, @floatFromInt(g.tagline_height)) >= bottom) break; - batch.cell_count += layer.cols + @as(u32, if (lr < layer.context_rows) 1 else 0) + @as(u32, @intFromBool(layer.hasContextBorderAfter(lr))); + group.cell_count += layer.cols + @as(u32, if (lr < layer.context_rows) 1 else 0) + @as(u32, @intFromBool(layer.hasContextBorderAfter(lr))); } } - for (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| { - const track = batch.track.?; + for (groups.items[1..][0..groups.tracks], 1..) |group, index| { + const track = group.track.?; const under = track.effect == .vertical and track.phase == .opening; if (!under and track.phase != .closing and track.effect != .dissolve) continue; for (surface.previous_body_layers) |*layer| { if (layer.rows == 0 or layer.serial != track.serial) continue; - const destination = if (under) &paint_plan.batches[0] else &paint_plan.batches[batch_index]; + const destination = &groups.items[if (under) 0 else index]; const bottom = @as(f32, @floatFromInt(layer.viewport.y + layer.viewport.h)) * layout.h; for (0..layer.rows) |lr| { if (layer.rowTop(@intCast(lr), layout.h, @floatFromInt(g.tagline_height)) >= bottom) break; @@ -5204,28 +5344,28 @@ fn renderFrame( } for (surface.tagLayers()) |*layer| { if (layer.rows == 0) continue; - const batch = &paint_plan.batches[paintBatchAt(&paint_plan, layer.viewport.x, layer.viewport.y)]; - batch.cell_count += (tagLayerCellCount(g, layer) + 1) * layer.rows; + const group_index = if (layer.kind == .notice) groups.notices else groupAt(&groups, layer.viewport.x, layer.viewport.y); + groups.items[group_index].cell_count += (tagLayerCellCount(g, layer) + 1) * layer.rows; } - for (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| { - const track = batch.track.?; + for (groups.items[1..][0..groups.tracks], 1..) |group, index| { + const track = group.track.?; const under = track.effect == .vertical and track.phase == .opening; if (!under and track.phase != .closing and track.effect != .dissolve) continue; for (surface.previous_tag_layers) |*layer| { if (layer.rows == 0 or layer.kind != .pane or layer.serial != track.serial) continue; - const destination = if (under) &paint_plan.batches[0] else &paint_plan.batches[batch_index]; - destination.cell_count += (tagLayerCellCount(g, layer) + 1) * layer.rows; + groups.items[if (under) 0 else index].cell_count += (tagLayerCellCount(g, layer) + 1) * layer.rows; } } - var cell_next: [pardes.MAX_PANES * 2 + 1]u32 = @splat(0); + var cell_next: [pardes.MAX_PANES * 2 + 4]u32 = @splat(0); var cell_total: u32 = 0; - for (paint_plan.batches[0..paint_plan.len], 0..) |*batch, batch_index| { - batch.cell_start = cell_total; - cell_next[batch_index] = cell_total; - cell_total = std.math.add(u32, cell_total, batch.cell_count) catch return error.GpuCapacity; - } - if (cell_total != 0) { - const capacity = std.math.add(u32, cell_total, cells) catch return error.GpuCapacity; + for (groups.items[0..groups.len], 0..) |*group, index| { + group.cell_start = cell_total; + cell_next[index] = cell_total; + cell_total = std.math.add(u32, cell_total, group.cell_count) catch return error.GpuCapacity; + } + const decor_len: u32 = @intCast(g.decor.items.len); + if (cell_total != 0 or decor_len != 0) { + const capacity = std.math.add(u32, cell_total, cells + decor_len) catch return error.GpuCapacity; try ensureVbuf(g, capacity); const vptr: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, g.vxfer.?, false) orelse { @@ -5239,45 +5379,40 @@ fn renderFrame( row = 0; while (row < surface.rows) : (row += 1) { const line = surface.cells[@as(usize, row) * surface.cols ..][0..surface.cols]; + const line_cover = covered[@as(usize, row) * surface.cols ..][0..surface.cols]; const line_cursor: ?usize = if (surface.cursor) |cu| if (cu.y == row) cu.x else null else null; var col: u16 = 0; while (col < surface.cols) : (col += 1) { - if (covered[@as(usize, row) * surface.cols + col]) continue; - const batch_index = paintBatchAt(&paint_plan, col, row); - const track = paint_plan.batches[batch_index].track; - const logical_idx: u32 = @as(u32, row) * surface.cols + col; - const data_effect = if (track) |active| - active.effect == .dissolve - else - false; - const data_diff = data_effect and gripCellChanged(surface, col, row); - if (data_diff) { - emitSurfaceCell( - g, - core, - instances, - &cell_next[batch_index], - col, - row, - layout, - win_w, - win_h, - track, - surface.previous_cells[@as(usize, row) * surface.cols ..][0..surface.cols], - col, - null, - &tagline_base, - paneGripCell(surface.previous_tag_layers, surface.previous_cells, surface.cols, col, row), - true, - false, - page, - ); - } + const at = @as(usize, row) * surface.cols + col; + if (covered[at].layer and !covered[at].floating) continue; + const group_index = if (covered[at].floating) groups.guides else groupAt(&groups, col, row); + const track = groups.items[group_index].track; + const data_effect = if (track) |active| active.effect == .dissolve else false; + const data_diff = data_effect and gripCellChanged(surface, covered, previous_covered, col, row); + if (data_diff) emitSurfaceCell( + g, + chrome, + instances, + &cell_next[group_index], + col, + row, + layout, + win_w, + win_h, + track, + surface.previous_cells[@as(usize, row) * surface.cols ..][0..surface.cols], + previous_covered[@as(usize, row) * surface.cols ..][0..surface.cols], + col, + null, + true, + false, + page, + ); emitSurfaceCell( g, - core, + chrome, instances, - &cell_next[batch_index], + &cell_next[group_index], col, row, layout, @@ -5285,12 +5420,11 @@ fn renderFrame( win_h, if (data_effect and !data_diff) null else track, line, + line_cover, col, line_cursor, - &tagline_base, - paneGripCell(surface.tagLayers(), surface.cells, surface.cols, col, row), false, - paint_plan.len == 1 and logical_idx == cursor_idx and !cursor_bar and bodyLayerAt(surface, col, row) == null, + cursors_shown and at == cursor_idx and !cursor_bar and bodyLayerAt(surface, col, row) == null, page, ); } @@ -5299,36 +5433,36 @@ fn renderFrame( // is a tag layer, and instances paint in emission order. for (surface.bodyLayers()) |*layer| { if (layer.rows == 0) continue; - const batch_index = paintBatchAt(&paint_plan, layer.viewport.x, layer.viewport.y); - emitBodyLayer(g, instances, &cell_next[batch_index], layer, win_w, win_h, paint_plan.batches[batch_index].track, page, paint_plan.len == 1, false, topbar_pane_border_rgb); + const group_index = groupAt(&groups, layer.viewport.x, layer.viewport.y); + emitBodyLayer(g, instances, &cell_next[group_index], layer, win_w, win_h, groups.items[group_index].track, page, cursors_shown, false, chrome.topbar_rule); } for (surface.tagLayers()) |*layer| { if (layer.rows == 0) continue; - const batch_index = paintBatchAt(&paint_plan, layer.viewport.x, layer.viewport.y); - emitTagLayer(g, instances, &cell_next[batch_index], layer, win_w, win_h, paint_plan.batches[batch_index].track, page, paint_plan.len == 1, false); + const track_index = groupAt(&groups, layer.viewport.x, layer.viewport.y); + // A notice floats over the panes, moving with its own. + const group_index = if (layer.kind == .notice) groups.notices else track_index; + emitTagLayer(g, instances, &cell_next[group_index], layer, win_w, win_h, groups.items[track_index].track, page, cursors_shown, false); } - for (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| { - const track = batch.track.?; + for (groups.items[1..][0..groups.tracks], 1..) |group, index| { + const track = group.track.?; const under = track.effect == .vertical and track.phase == .opening; if (!under and track.phase != .closing and track.effect != .dissolve) continue; for (surface.previous_tag_layers) |*layer| { if (layer.rows == 0 or layer.kind != .pane or layer.serial != track.serial) continue; - const destination: usize = if (under) 0 else batch_index; - emitTagLayer(g, instances, &cell_next[destination], layer, win_w, win_h, if (under) null else track, page, false, track.effect == .dissolve); + emitTagLayer(g, instances, &cell_next[if (under) 0 else index], layer, win_w, win_h, if (under) null else track, page, false, track.effect == .dissolve); } } - for (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| { - const track = batch.track.?; + for (groups.items[1..][0..groups.tracks], 1..) |group, index| { + const track = group.track.?; const under = track.effect == .vertical and track.phase == .opening; if (!under and track.phase != .closing and track.effect != .dissolve) continue; for (surface.previous_body_layers) |*layer| { if (layer.rows == 0 or layer.serial != track.serial) continue; - const destination: usize = if (under) 0 else batch_index; - emitBodyLayer(g, instances, &cell_next[destination], layer, win_w, win_h, if (under) null else track, page, false, track.effect == .dissolve, topbar_pane_border_rgb); + emitBodyLayer(g, instances, &cell_next[if (under) 0 else index], layer, win_w, win_h, if (under) null else track, page, false, track.effect == .dissolve, chrome.topbar_rule); } } - for (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| { - const track = batch.track.?; + for (groups.items[1..][0..groups.tracks], 1..) |group, index| { + const track = group.track.?; const duplicate_under = track.effect == .vertical and track.phase == .opening; if (!duplicate_under and track.phase != .closing) continue; row = 0; @@ -5336,13 +5470,12 @@ fn renderFrame( var col: u16 = 0; while (col < surface.cols) : (col += 1) { if (!boxContains(track.contentBox(), col, row)) continue; - if (previous_covered[@as(usize, row) * surface.cols + col]) continue; - const destination: usize = if (duplicate_under) 0 else batch_index; + if (previous_covered[@as(usize, row) * surface.cols + col].layer) continue; emitSurfaceCell( g, - core, + chrome, instances, - &cell_next[destination], + &cell_next[if (duplicate_under) 0 else index], col, row, layout, @@ -5350,10 +5483,9 @@ fn renderFrame( win_h, if (duplicate_under) null else track, surface.previous_cells[@as(usize, row) * surface.cols ..][0..surface.cols], + previous_covered[@as(usize, row) * surface.cols ..][0..surface.cols], col, null, - &tagline_base, - paneGripCell(surface.previous_tag_layers, surface.previous_cells, surface.cols, col, row), false, false, page, @@ -5361,57 +5493,56 @@ fn renderFrame( } } } - for (paint_plan.batches[0..paint_plan.len], 0..) |batch, batch_index| - std.debug.assert(cell_next[batch_index] == batch.cell_start + batch.cell_count); + for (groups.items[0..groups.len], 0..) |group, index| + std.debug.assert(cell_next[index] == group.cell_start + group.cell_count); shifted = emitScrollRows(g, instances, cell_total, surface, layout, win_w, win_h, page); + @memcpy(instances[cell_total + shifted ..][0..decor_len], g.decor.items); c.SDL_UnmapGPUTransferBuffer(g.device, g.vxfer.?); if (g.atlas_dirty_top < g.atlas_dirty_bottom) 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 = (cell_total + shifted) * @sizeOf(CellInstance) }; + const dst = c.SDL_GPUBufferRegion{ .buffer = g.vbuf.?, .offset = 0, .size = (cell_total + shifted + decor_len) * @sizeOf(CellInstance) }; c.SDL_UploadToGPUBuffer(copy, &src, &dst, false); c.SDL_EndGPUCopyPass(copy); } + build_zone.end(); + building = false; const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null); pushLayerOpacity(cmd, rp, background_opacity); const whole = c.SDL_Rect{ .x = 0, .y = 0, .w = @intCast(sw), .h = @intCast(sh) }; - for (paint_plan.batches[0..paint_plan.len], 0..) |batch, batch_index| { - const has_shifted = batch_index == 0 and shifted != 0; - if ((batch.cell_count != 0 or has_shifted) and g.vbuf != null) { + const decor_base = cell_total + shifted; + for (groups.items[0..groups.len], 0..) |group, index| { + const has_shifted = index == 0 and shifted != 0; + if ((group.cell_count != 0 or has_shifted) and g.vbuf != 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); - if (batch.cell_count != 0) { - const panel_clipped = if (batch.track) |active| active.effect == .vertical else false; - if (panel_clipped) { - const clip = panelBoxScissor(g, batch.track.?.contentBox(), sw, sh); - c.SDL_SetGPUScissor(rp, &clip); - } - const binding = c.SDL_GPUBufferBinding{ - .buffer = g.vbuf.?, - .offset = batch.cell_start * @sizeOf(CellInstance), - }; + if (group.cell_count != 0) { + const binding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = group.cell_start * @sizeOf(CellInstance) }; c.SDL_BindGPUVertexBuffers(rp, 0, &binding, 1); - c.SDL_DrawGPUPrimitives(rp, 6, batch.cell_count, 0, 0); - if (panel_clipped) c.SDL_SetGPUScissor(rp, &whole); + c.SDL_DrawGPUPrimitives(rp, 6, group.cell_count, 0, 0); } if (has_shifted) { const clip = scrollScissor(g, layout, sw, sh); c.SDL_SetGPUScissor(rp, &clip); - const binding = c.SDL_GPUBufferBinding{ - .buffer = g.vbuf.?, - .offset = cell_total * @sizeOf(CellInstance), - }; + const binding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = cell_total * @sizeOf(CellInstance) }; c.SDL_BindGPUVertexBuffers(rp, 0, &binding, 1); c.SDL_DrawGPUPrimitives(rp, 6, shifted, 0, 0); c.SDL_SetGPUScissor(rp, &whole); } } - if (batch.image_count != 0) - drawNativeImagesGpu(g, rp, surface, &paint_plan, batch_index); + if (group.image_count != 0) drawNativeImagesGpu(g, rp, surface, group, whole); + if (group.decor_count != 0 and g.vbuf != null) { + c.SDL_BindGPUGraphicsPipeline(rp, g.decor_pipeline); + // Ink keeps its colour at any WindowOpacity. + pushLayerOpacity(cmd, rp, if (index == groups.ink) 1 else background_opacity); + const binding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = (decor_base + group.decor_start) * @sizeOf(CellInstance) }; + c.SDL_BindGPUVertexBuffers(rp, 0, &binding, 1); + c.SDL_DrawGPUPrimitives(rp, 6, group.decor_count, 0, 0); + } } drawOverlayGpu(g, cmd, rp, overlay_count); c.SDL_EndGPURenderPass(rp); @@ -5455,23 +5586,23 @@ fn renderFrame( } if (g.capture) { command_consumed = true; - try captureFrame(g, gpa, cmd, target, sw, sh); + try captureFrame(g, gpa, cmd, target, sw, sh, if (groups.tracks != 0) groups.items[1].track.?.frame else null); g.presented_scene = rendered_scene; - if (paint_plan.len == 1) rememberPresentedImages(g, gpa, surface); + if (groups.tracks == 0) rememberPresentedImages(g, gpa, surface); return true; } if (g.soft_present) { command_consumed = true; try softPresentFrame(g, cmd, target, sw, sh); g.presented_scene = rendered_scene; - if (paint_plan.len == 1) rememberPresentedImages(g, gpa, surface); + if (groups.tracks == 0) rememberPresentedImages(g, gpa, surface); return true; } command_consumed = true; const submitted = c.SDL_SubmitGPUCommandBuffer(cmd); if (submitted) { g.presented_scene = rendered_scene; - if (paint_plan.len == 1) rememberPresentedImages(g, gpa, surface); + if (groups.tracks == 0) rememberPresentedImages(g, gpa, surface); } return submitted; } @@ -5487,24 +5618,6 @@ const ResolvedCell = struct { const topbarPaneBorderPixels = pardes.topbarPaneBorderPixels; const taglineBandOffset = pardes.taglineBandOffset; -fn topbarPaneBorderHeight(surface: *const pardes.Surface, top: u16, cell_h: u32, tagline_h: u32) ?u32 { - if (top == 0 or surface.rows <= top) return null; - const height = topbarPaneBorderPixels(cell_h, tagline_h); - if (height == 0) return null; - const base = @as(usize, top) * surface.cols; - for (surface.cells[base..][0..surface.cols]) |cell| - if (!cell.default and cell.style.font_role == .tagline) return height; - return null; -} - -fn bottomTaglinePresent(surface: *const pardes.Surface) bool { - if (surface.rows == 0) return false; - const base = @as(usize, surface.rows - 1) * surface.cols; - for (surface.cells[base..][0..surface.cols]) |cell| - if (!cell.default and cell.style.font_role == .tagline) return true; - return false; -} - test "tagline bands center in their cells and Tagbottom faces the window edge" { const cell_h: u32 = 20; const tagline_h: u32 = 16; @@ -5587,40 +5700,6 @@ fn cellInstanceCount(cell: *const pardes.Cell) u32 { return if (cellFontRole(cell) == .tagline) 2 else 1; } -fn taglineBaseRgb(core: *const pardes.Pardes, col: u16, row: u16) [3]u8 { - const chrome = core.chromeTheme(); - if (!core.settings.focus_tint) return chrome.tag_bg; - if (row >= core.topBarHeight() and row < core.topBarHeight() + core.columnBarHeight()) { - for (0..core.ncol) |column| for (core.col_panes[column][0..core.col_n[column]]) |id| { - if (id != core.active) continue; - const x = core.col_x[column]; - if (col >= x and col - x < core.col_w[column]) return chrome.tag_active_bg; - }; - } - const r = core.rects[core.active]; - const pane = core.panes[core.active] orelse return chrome.tag_bg; - const tag_y = core.tagTop(pane, r); - return if (r.h > 0 and row >= tag_y and row < tag_y + pane.tag_rows and col >= r.x and col - r.x < r.w) chrome.tag_active_bg else chrome.tag_bg; -} - -test "compact column and pane underlays share focus tint without tinting the global tag" { - const core = try pardes.Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 24 }); - defer core.deinit(); - _ = try core.newShell(1, ""); - try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); - pardes.test_api.sync(core); - core.active = 1; - const active = core.rects[1]; - const inactive = core.rects[0]; - const chrome = core.chromeTheme(); - try std.testing.expectEqual(chrome.tag_bg, taglineBaseRgb(core, active.x, 0)); - try std.testing.expectEqual(chrome.tag_active_bg, taglineBaseRgb(core, active.x, pardes.TOPBAR_H)); - try std.testing.expectEqual(chrome.tag_bg, taglineBaseRgb(core, inactive.x, pardes.TOPBAR_H)); - try std.testing.expectEqual(chrome.tag_active_bg, taglineBaseRgb(core, active.x, active.y)); - core.settings.focus_tint = false; - try std.testing.expectEqual(chrome.tag_bg, taglineBaseRgb(core, active.x, pardes.TOPBAR_H)); -} - fn tagLayerIn(layers: []const pardes.Layer, col: u16, row: u16) ?*const pardes.Layer { for (layers) |*layer| { const r = layer.viewport; @@ -5629,34 +5708,10 @@ fn tagLayerIn(layers: []const pardes.Layer, col: u16, row: u16) ?*const pardes.L return null; } -/// Marks the grid cells a body or tag layer covers, which the layers paint -/// themselves: the grid's cell loops skip them. Filling each layer's rectangle -/// once replaces scanning every layer for every cell, which was a third of the -/// gui's CPU on a full screen of text. It marks exactly the cells bodyLayerIn -/// and tagLayerIn find a layer for: a tag layer covers its rows, and a -/// viewport past the grid covers the grid's part of it. -fn coverLayers(cover: []bool, cols: u16, rows: u16, body_layers: []const pardes.Layer, tag_layers: []const pardes.Layer) void { - @memset(cover, false); - for (body_layers) |*layer| { - const r = layer.viewport; - const right = @min(cols, @as(u32, r.x) + r.w); - if (layer.rows == 0 or r.x >= right) continue; - var y: u32 = r.y; - while (y < @min(rows, @as(u32, r.y) + r.h)) : (y += 1) @memset(cover[y * cols ..][r.x..right], true); - } - for (tag_layers) |*layer| { - const r = layer.viewport; - const right = @min(cols, @as(u32, r.x) + r.w); - if (layer.rows == 0 or r.x >= right) continue; - var y: u32 = r.y; - while (y < @min(rows, @as(u32, r.y) + layer.rows)) : (y += 1) @memset(cover[y * cols ..][r.x..right], true); - } -} - -fn expectCoverIsScan(cover: []const bool, cols: u16, rows: u16, body_layers: []const pardes.Layer, tag_layers: []const pardes.Layer) !void { +fn expectCoverIsScan(cover: []const Cover, cols: u16, rows: u16, body_layers: []const pardes.Layer, tag_layers: []const pardes.Layer) !void { for (0..rows) |row| for (0..cols) |col| { const scan = bodyLayerIn(body_layers, @intCast(col), @intCast(row)) != null or tagLayerIn(tag_layers, @intCast(col), @intCast(row)) != null; - try std.testing.expectEqual(scan, cover[row * cols + col]); + try std.testing.expectEqual(scan, cover[row * cols + col].layer); }; } @@ -5670,7 +5725,7 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); pardes.layout.insert(core, 1, 1, 2); pardes.layout.insert(core, 0, 1, 3); - var cover: [97 * 31]bool = undefined; + var cover: [97 * 31]Cover = undefined; var notices = false; for (0..32) |step| { core.settings.tag_bottom = step & 1 != 0; @@ -5683,9 +5738,20 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand } const s = try core.render(core.scratch.allocator()); try std.testing.expectEqual(@as(usize, cover.len), @as(usize, s.cols) * s.rows); - @memset(&cover, true); // what an earlier frame left - coverLayers(&cover, s.cols, s.rows, s.bodyLayers(), s.tagLayers()); + @memset(&cover, .{ .layer = true, .floating = true, .grip = true, .focus = true }); // what an earlier frame left + coverFrame(&cover, s.cols, s.rows, s.bodyLayers(), s.tagLayers(), s.regionList()); try expectCoverIsScan(&cover, s.cols, s.rows, s.bodyLayers(), s.tagLayers()); + // A grip's cells know their way to its glyph, and the focused + // pane's tag rows are the focused ones. + for (s.regionList()) |region| { + if (region.kind != .grip) continue; + for (0..region.rect.w) |offset| { + const cell = cover[@as(usize, region.rect.y) * s.cols + region.rect.x + offset]; + try std.testing.expect(cell.grip); + try std.testing.expectEqual(offset, cell.grip_offset); + try std.testing.expectEqual(region.owner == core.active, cell.focus); + } + } for (s.tagLayers()) |layer| notices = notices or (layer.rows != 0 and layer.kind == .notice); } try std.testing.expect(notices); @@ -5705,18 +5771,18 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand const random = prng.random(); var body: [pardes.MAX_PANES]pardes.Layer = undefined; var tag: [pardes.MAX_TAG_LAYERS]pardes.Layer = undefined; - var grid: [64 * 40]bool = undefined; + var grid: [64 * 40]Cover = undefined; for (0..3000) |_| { const cols = random.intRangeAtMost(u16, 1, 64); const rows = random.intRangeAtMost(u16, 1, 40); const empty = random.intRangeAtMost(u8, 2, 5); // about one layer in `empty` is empty for (&body) |*layer| layer.* = .{ .rows = @intFromBool(random.uintLessThan(u8, empty) != 0), .viewport = gen.rect(random, cols, rows) }; - for (&tag) |*layer| layer.* = .{ .cols = @intFromBool(random.uintLessThan(u8, empty) != 0), .viewport = gen.rect(random, cols, rows) }; + for (&tag) |*layer| layer.* = .{ .rows = @intFromBool(random.uintLessThan(u8, empty) != 0), .viewport = gen.rect(random, cols, rows) }; const bodies = body[0..random.uintAtMost(usize, body.len)]; const tags = tag[0..random.uintAtMost(usize, tag.len)]; const cells = grid[0 .. @as(usize, cols) * rows]; - @memset(cells, random.boolean()); - coverLayers(cells, cols, rows, bodies, tags); + @memset(cells, .{ .layer = random.boolean() }); + coverFrame(cells, cols, rows, bodies, tags, &.{}); try expectCoverIsScan(cells, cols, rows, bodies, tags); } } @@ -5844,42 +5910,23 @@ fn emitBodyLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const } } -const GripCell = struct { cell: *const pardes.Cell, offset: u16 }; - -fn gripCellChanged(surface: *const pardes.Surface, col: u16, row: u16) bool { +/// A dissolving cell changes with its grip's glyph, which is the cell its +/// grip offset to the left, in this frame's grip or the last one's. +fn gripCellChanged(surface: *const pardes.Surface, covered: []const Cover, previous_covered: []const Cover, col: u16, row: u16) bool { if (surface.panelCellChanged(col, row)) return true; - if (paneGripCell(surface.tagLayers(), surface.cells, surface.cols, col, row)) |grip| { - if (surface.panelCellChanged(col - grip.offset, row)) return true; - } - if (paneGripCell(surface.previous_tag_layers, surface.previous_cells, surface.cols, col, row)) |grip| { - if (surface.panelCellChanged(col - grip.offset, row)) return true; - } + const at = @as(usize, row) * surface.cols + col; + for ([_]Cover{ covered[at], previous_covered[at] }) |cover| + if (cover.grip and surface.panelCellChanged(col -| cover.grip_offset, row)) return true; return false; } -fn paneGripCell(layers: []const pardes.Layer, cells: []const pardes.Cell, cols: u16, col: u16, row: u16) ?GripCell { - const at = @as(usize, row) * cols + col; - if (at >= cells.len or cells[at].default or cells[at].style.font_role != .tagline) return null; - for (layers) |layer| { - if (layer.rows == 0 or layer.kind != .pane or layer.viewport.x < pardes.TAG_TEXT_INSET or row < layer.viewport.y or row - layer.viewport.y >= layer.rows) continue; - const x = layer.viewport.x - pardes.TAG_TEXT_INSET; - if (col < x or col - x >= config.GUTTER) continue; - const index = @as(usize, row) * cols + x; - if (index >= cells.len) return null; - const cell = &cells[index]; - if (cell.default or cell.printableAscii() == ' ' or cell.style.invisible) return null; - return .{ .cell = cell, .offset = col - x }; - } - return null; -} - fn anchorInset(g: *const Gui, row: u16, win_h: f32) u32 { return taglineBandOffset(row, win_h, g.cell_h, g.tagline_height); } fn emitSurfaceCell( g: *Gui, - core: ?*const pardes.Pardes, + chrome: *const pardes.Chrome, instances: [*]CellInstance, next: *u32, col: u16, @@ -5889,18 +5936,20 @@ fn emitSurfaceCell( win_h: f32, track: ?pardes.animation.Track, line: []const pardes.Cell, + line_cover: []const Cover, at: usize, cursor: ?usize, - tagline_base: *const pardes.Cell, - grip: ?GripCell, old_layer: bool, is_cursor: bool, page: Ground, ) void { const role = cellFontRole(&line[at]); if (role == .tagline) { - var band_base = tagline_base.*; - if (core) |p| band_base.style.bg = .{ .rgb = taglineBaseRgb(p, col, row) }; + const here = line_cover[at]; + const band_base: pardes.Cell = .{ + .style = .{ .bg = .{ .rgb = if (here.focus) chrome.tag_focus_bg else chrome.tag_bg }, .font_role = .tagline }, + .default = false, + }; // The smaller font changes the ink, not the chrome's coverage. Painting // this underlay at tagline height leaves page-colored gutters between // adjacent workspace, column and pane tags at TaglineSize < 100. @@ -5908,27 +5957,32 @@ fn emitSurfaceCell( next.* += 1; // Tag glyphs keep their font size inside a full physical-width grip. emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, line, at, cursor, old_layer, is_cursor, page); + // A grip's glyph is the cell at its left edge, and a blank one is + // no glyph: its marks are decor. + const glyph = &line[at - @min(here.grip_offset, at)]; + const grip = here.grip and !glyph.default and glyph.printableAscii() != ' ' and !glyph.style.invisible; + const offset: u16 = if (grip) @min(here.grip_offset, at) else 0; // An anchor starts at its column's rule: its left edge is inset by // its top margin so the band frames it on the left as above and // below, and its mark moves right by half that to stay centered. - const inset: u32 = if (core) |p| if (std.mem.indexOfScalar(u16, p.col_x[0..p.ncol], col -| (if (grip) |indicator| indicator.offset else 0)) != null) anchorInset(g, row, win_h) else 0 else 0; - if (grip) |indicator| { + const inset: u32 = if (line_cover[at - offset].anchor) anchorInset(g, row, win_h) else 0; + if (grip) { // A grip is one glyph centered across the gutter, not text: it is // looked up by its codepoint's glyph, never shaped. - const drawn = glyphForCodepoint(g, cellCodepoint(indicator.cell)); - const cached = cachedGlyph(g, .{ .face = drawn.face, .glyph = drawn.glyph, .role = .tagline, .decoration = GlyphDecoration.fromCell(indicator.cell), .centered = true }); + const drawn = glyphForCodepoint(g, cellCodepoint(glyph)); + const cached = cachedGlyph(g, .{ .face = drawn.face, .glyph = drawn.glyph, .role = .tagline, .decoration = GlyphDecoration.fromCell(glyph), .centered = true }); const slot = cached orelse g.space_slot; - const offset: f32 = if (cached != null) @as(f32, @floatFromInt(@as(u32, indicator.offset) * g.cell_w)) - @as(f32, @floatFromInt(inset / 2)) else 0; - const resolved = resolveCell(g, indicator.cell[0..1], 0, null, .tagline, false, page); + const shift: f32 = if (cached != null) @as(f32, @floatFromInt(@as(u32, offset) * g.cell_w)) - @as(f32, @floatFromInt(inset / 2)) else 0; + const resolved = resolveCell(g, glyph[0..1], 0, null, .tagline, false, page); instances[next.*].fr = @as(f32, @floatFromInt(resolved.fg[0])) / 255; instances[next.*].fg = @as(f32, @floatFromInt(resolved.fg[1])) / 255; instances[next.*].fb = @as(f32, @floatFromInt(resolved.fg[2])) / 255; - instances[next.*].u0 = (@as(f32, @floatFromInt(slot.u)) + offset) / atlas_w; + instances[next.*].u0 = (@as(f32, @floatFromInt(slot.u)) + shift) / atlas_w; instances[next.*].v0 = @as(f32, @floatFromInt(slot.v)) / atlas_h; instances[next.*].v1 = @as(f32, @floatFromInt(slot.v + g.tagline_height)) / atlas_h; } instances[next.*].u1 = instances[next.*].u0 + @as(f32, @floatFromInt(g.cell_w)) / atlas_w; - if (inset > 0 and (grip == null or grip.?.offset == 0)) { + if (inset > 0 and (!grip or offset == 0)) { instances[next.*].x0 += @as(f32, @floatFromInt(inset)) / win_w * 2; instances[next.*].u0 += @as(f32, @floatFromInt(inset)) / atlas_w; } @@ -5940,8 +5994,7 @@ fn emitSurfaceCell( } test "small tagline fonts leave no background gutters between adjacent tags" { - const core = try pardes.Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); - defer core.deinit(); + const chrome: pardes.Chrome = .{}; var g: Gui = undefined; g.words = .{}; g.cell_w = 10; @@ -5954,7 +6007,7 @@ test "small tagline fonts leave no background gutters between adjacent tags" { var next: u32 = 0; for (0..3) |row| { // Column 1: past the anchor's inset edge at the column's left. - emitSurfaceCell(&g, core, &instances, &next, 1, @intCast(row), fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], 0, null, &cell, null, false, false, Ground.opaqueRgb(bg_default)); + emitSurfaceCell(&g, &chrome, &instances, &next, 1, @intCast(row), fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], &.{.{}}, 0, null, false, false, Ground.opaqueRgb(bg_default)); const base = instances[row * 2]; const ink = instances[row * 2 + 1]; try std.testing.expectApproxEqAbs(@as(f32, 20.0 / 480.0 * 2), base.y0 - base.y1, 0.0001); @@ -5967,12 +6020,12 @@ test "small tagline fonts leave no background gutters between adjacent tags" { // Column 0 is an anchor's edge: its ink is inset by the band's margin // (2 px here) and its underlay still covers the whole cell. next = 0; - emitSurfaceCell(&g, core, &instances, &next, 0, 1, fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], 0, null, &cell, null, false, false, Ground.opaqueRgb(bg_default)); + emitSurfaceCell(&g, &chrome, &instances, &next, 0, 1, fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], &.{.{ .anchor = true }}, 0, null, false, false, Ground.opaqueRgb(bg_default)); try std.testing.expectApproxEqAbs(@as(f32, 10.0 / 800.0 * 2), instances[0].x1 - instances[0].x0, 0.0001); try std.testing.expectApproxEqAbs(@as(f32, 8.0 / 800.0 * 2), instances[1].x1 - instances[1].x0, 0.0001); try std.testing.expectApproxEqAbs(@as(f32, 8) / atlas_w, instances[1].u1 - instances[1].u0, 0.0001); next = 0; - emitSurfaceCell(&g, null, &instances, &next, 0, 2, fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], 0, null, &cell, null, true, false, Ground.opaqueRgb(bg_default)); + emitSurfaceCell(&g, &chrome, &instances, &next, 0, 2, fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], &.{.{}}, 0, null, true, false, Ground.opaqueRgb(bg_default)); try std.testing.expectEqual(@as(u32, 2), next); try std.testing.expectApproxEqAbs(@as(f32, 16.0 / 480.0 * 2), instances[1].y0 - instances[1].y1, 0.0001); } @@ -6086,24 +6139,25 @@ test "insert cursor overlays without replacing the character beneath it" { }; try std.testing.expectEqual(@as(u32, 'x'), cellCodepoint(&cell)); - var vertices: [6]OverlayVertex = undefined; - var builder: OverlayBuilder = .{ - .vertices = &vertices, - .win_w = 100, - .win_h = 100, - }; - addCursorBar(&builder, 2, 3, true, 8, .{ - .x_off = 0, - .y_off = 0, - .w = 10, - .h = 20, - }, .{ 32, 48, 64 }); - try std.testing.expectEqual(@as(usize, 6), builder.len); - try std.testing.expectApproxEqAbs(@as(f32, -0.32), vertices[0].y, 0.0001); - try std.testing.expectApproxEqAbs(@as(f32, -0.48), vertices[2].y, 0.0001); - try std.testing.expectApproxEqAbs(@as(f32, 32.0 / 255.0), vertices[0].r, 0.0001); - for (vertices) |vertex| try std.testing.expectEqual(@as(u32, 1), vertex.ink); - try std.testing.expect(!builder.ink); + // A bar at a tag cell: tagline-tall, centered in its row, in the + // cell's ink, drawn as decor over it. + var cells: [25]pardes.Cell = @splat(.{}); + cells[3 * 5 + 2] = .{ .default = false, .style = .{ .fg = .{ .rgb = .{ 32, 48, 64 } }, .font_role = .tagline } }; + const surface: pardes.Surface = .{ .cols = 5, .rows = 5, .cells = &cells, .cursor = .{ .x = 2, .y = 3, .bar = true } }; + var g: Gui = undefined; + g.decor = .empty; + defer g.decor.deinit(std.testing.allocator); + g.cell_w = 10; + g.cell_h = 20; + g.tagline_width = 8; + g.tagline_height = 8; + try cursorDecor(&g, std.testing.allocator, &surface, fixedCellLayout(&g), 100, 100); + try std.testing.expectEqual(@as(usize, 1), g.decor.items.len); + const bar = g.decor.items[0]; + try std.testing.expectApproxEqAbs(@as(f32, -0.32), bar.y0, 0.0001); + try std.testing.expectApproxEqAbs(@as(f32, -0.48), bar.y1, 0.0001); + try std.testing.expectApproxEqAbs(@as(f32, 32.0 / 255.0), bar.br, 0.0001); + try std.testing.expectEqual(@as(f32, 1), bar.fr); } fn palColor(idx: u8) [3]u8 { @@ -6613,17 +6667,24 @@ test "pane indicator halves share dissolve changes and safe exhausted atlas" { cells[0].text[0] = '$'; var previous = cells; previous[0].text[0] = '^'; - var layers = [_]pardes.Layer{.{ .kind = .pane, .serial = 1, .viewport = .{ .x = pardes.TAG_TEXT_INSET, .y = 0, .w = 3, .h = 1 }, .cols = 3, .rows = 1 }}; var diffs: [12]pardes.PanelCellDiff = @splat(.unchanged); diffs[0] = pardes.PanelCellDiff.between(&previous[0], &cells[0]); - var surface: pardes.Surface = .{ .cols = 6, .rows = 2, .cells = &cells, .previous_cells = &previous, .previous_tag_layers = &layers, .cell_diffs = &diffs }; - surface.tag_layers[0] = layers[0]; - try std.testing.expect(gripCellChanged(&surface, 0, 0)); - try std.testing.expect(gripCellChanged(&surface, 1, 0)); - try std.testing.expect(!gripCellChanged(&surface, 2, 0)); - try std.testing.expect(paneGripCell(surface.tagLayers(), &cells, 6, 2, 0) == null); - layers[0].kind = .column; - try std.testing.expect(paneGripCell(&layers, &cells, 6, 0, 0) == null); + const surface: pardes.Surface = .{ .cols = 6, .rows = 2, .cells = &cells, .previous_cells = &previous, .cell_diffs = &diffs }; + // A grip two cells wide at the top left, in both frames. + const grip = [_]pardes.Region{.{ .kind = .grip, .serial = 1, .rect = .{ .x = 0, .y = 0, .w = 2, .h = 1 } }}; + var cover: [12]Cover = undefined; + var previous_cover: [12]Cover = undefined; + coverFrame(&cover, 6, 2, &.{}, &.{}, &grip); + coverFrame(&previous_cover, 6, 2, &.{}, &.{}, &grip); + try std.testing.expect(gripCellChanged(&surface, &cover, &previous_cover, 0, 0)); + try std.testing.expect(gripCellChanged(&surface, &cover, &previous_cover, 1, 0)); + try std.testing.expect(!gripCellChanged(&surface, &cover, &previous_cover, 2, 0)); + try std.testing.expect(!cover[2].grip); + // A column's grip has no glyph of a pane's. + coverFrame(&cover, 6, 2, &.{}, &.{}, &.{.{ .kind = .column_grip, .rect = .{ .x = 0, .y = 0, .w = 2, .h = 1 } }}); + try std.testing.expect(!cover[0].grip); + coverFrame(&cover, 6, 2, &.{}, &.{}, &grip); + const chrome: pardes.Chrome = .{}; const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?; defer c.ui_font_free(font); @@ -6647,7 +6708,7 @@ test "pane indicator halves share dissolve changes and safe exhausted atlas" { var instances: [4]CellInstance = undefined; var next: u32 = 0; for (0..2) |col| { - emitSurfaceCell(&g, null, &instances, &next, @intCast(col), 0, fixedCellLayout(&g), 100, 100, null, cells[0..6], col, null, &cells[2], .{ .cell = &cells[0], .offset = @intCast(col) }, old, false, Ground.opaqueRgb(bg_default)); + emitSurfaceCell(&g, &chrome, &instances, &next, @intCast(col), 0, fixedCellLayout(&g), 100, 100, null, cells[0..6], cover[0..6], col, null, old, false, Ground.opaqueRgb(bg_default)); try std.testing.expectEqual(@as(f32, 0), instances[col * 2 + 1].u0); try std.testing.expectEqual(old, instances[col * 2 + 1].effect & old_layer_bit != 0); } @@ -6802,10 +6863,12 @@ fn ensureVbuf(g: *Gui, cells: u32) !void { g.vbuf_cells = capacity; } -fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline { +/// A pipeline over cell instances: the cells' own (ui.frag, the atlas, no +/// blending) or the decor's (decor.frag, blended like the overlay). +fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat, frag: []const u8, samplers: u32, blend: c.SDL_GPUColorTargetBlendState) !*c.SDL_GPUGraphicsPipeline { const vs = try makeShader(device, vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0, 0); defer c.SDL_ReleaseGPUShader(device, vs); - const fs = try makeShader(device, frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 1); + const fs = try makeShader(device, frag, c.SDL_GPU_SHADERSTAGE_FRAGMENT, samplers, 1); defer c.SDL_ReleaseGPUShader(device, fs); var vbuf_desc = c.SDL_GPUVertexBufferDescription{ @@ -6822,8 +6885,9 @@ fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) .{ .location = 4, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "panel_x0") }, .{ .location = 5, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "present_x0") }, .{ .location = 6, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_UINT4, .offset = @offsetOf(CellInstance, "effect") }, + .{ .location = 7, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "clip_x0") }, }; - var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = std.mem.zeroes(c.SDL_GPUColorTargetBlendState) }; + var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = blend }; var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo); info.vertex_shader = vs; @@ -7135,7 +7199,10 @@ fn latencyNote(comptime fmt: []const u8, args: anytype) void { _ = libc.write(latency_fd, line.ptr, line.len); } -fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, target: *c.SDL_GPUTexture, sw: u32, sh: u32) !void { +/// Writes the frame to latest.ppm in the capture directory, and a panel +/// transition's frame `transition` to transition-.ppm too, so a test +/// can hold one partway (test/gui_golden.py). +fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, target: *c.SDL_GPUTexture, sw: u32, sh: u32, transition: ?u16) !void { const size = c.SDL_CalculateGPUTextureFormatSize(g.swapchain_format, sw, sh, 1); if (size == 0) { _ = c.SDL_SubmitGPUCommandBuffer(cmd); @@ -7168,10 +7235,12 @@ fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, t 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); + try writeCapturePpm(g, gpa, pixels[0..size], sw, sh, "latest"); + var name: [24]u8 = undefined; + if (transition) |frame| try writeCapturePpm(g, gpa, pixels[0..size], sw, sh, std.fmt.bufPrint(&name, "transition-{d}", .{frame}) catch unreachable); } -fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u32, height: u32) !void { +fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u32, height: u32, name: []const u8) !void { // A latency trace times frames, and an 8 MB file write per frame would // stall the loop it is timing. if (g.capture_dir.len == 0 or latency_fd >= 0) return; @@ -7185,8 +7254,8 @@ fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u 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 tmp_path = std.fmt.bufPrintSentinel(&tmp_buf, "{s}/{s}.ppm.tmp", .{ g.capture_dir, name }, 0) catch return error.CapturePathTooLong; + const final_path = std.fmt.bufPrintSentinel(&final_buf, "{s}/{s}.ppm", .{ g.capture_dir, name }, 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; @@ -7212,116 +7281,263 @@ fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u if (libc.rename(tmp_path, final_path) != 0) return error.CaptureWriteFailed; } -fn addCursorBar( - builder: *OverlayBuilder, - x: u16, - y: u16, - bar: bool, - visual_height: f32, - layout: CellLayout, - rgb: [3]u8, -) void { - if (!bar) return; - const previous_ink = builder.ink; - builder.ink = true; - defer builder.ink = previous_ink; - const x0 = layout.x_off + @as(f32, @floatFromInt(x)) * layout.w; - const height = std.math.clamp(visual_height, 1.0, layout.h); - const y0 = layout.y_off + @as(f32, @floatFromInt(y)) * layout.h + - @as(f32, @floatFromInt(taglineBandOffset(y, builder.win_h, @intFromFloat(layout.h), @intFromFloat(height)))); - builder.addRect( - x0, - y0, - x0 + @max(1.0, layout.w / 8.0), - y0 + height, - .{ - .r = @as(f32, @floatFromInt(rgb[0])) / 255.0, - .g = @as(f32, @floatFromInt(rgb[1])) / 255.0, - .b = @as(f32, @floatFromInt(rgb[2])) / 255.0, - .a = 1.0, - }, - ); +/// One flat, hard-edged rectangle of decor, in window pixels: clipped to the +/// window and dropped when empty, as the triangle overlay drew it, and moved +/// by `track` as its pane's cells are. +fn addDecor(g: *Gui, gpa: std.mem.Allocator, x0_in: f32, y0_in: f32, x1_in: f32, y1_in: f32, rgb: [3]u8, alpha: f32, track: ?pardes.animation.Track, win_w: f32, win_h: f32) !void { + const x0 = std.math.clamp(@min(x0_in, x1_in), 0.0, win_w); + const x1 = std.math.clamp(@max(x0_in, x1_in), 0.0, win_w); + const y0 = std.math.clamp(@min(y0_in, y1_in), 0.0, win_h); + const y1 = std.math.clamp(@max(y0_in, y1_in), 0.0, win_h); + if (x1 <= x0 or y1 <= y0) return; + // decor.frag.glsl reads the colour from the ground and the coverage + // from the ink's red. + var instance: CellInstance = .{ + .x0 = (x0 / win_w) * 2.0 - 1.0, + .y0 = 1.0 - (y0 / win_h) * 2.0, + .x1 = (x1 / win_w) * 2.0 - 1.0, + .y1 = 1.0 - (y1 / win_h) * 2.0, + .u0 = 0, + .v0 = 0, + .u1 = 0, + .v1 = 0, + .fr = alpha, + .fg = 0, + .fb = 0, + .br = @as(f32, @floatFromInt(rgb[0])) / 255.0, + .bg = @as(f32, @floatFromInt(rgb[1])) / 255.0, + .bb = @as(f32, @floatFromInt(rgb[2])) / 255.0, + }; + setTransitionFields(&instance, track, fixedCellLayout(g), win_w, win_h, 0); + try g.decor.append(gpa, instance); +} + +/// The frame's decor, group by group (docs/render-pipeline.md §3.4), all of it +/// read from the regions and the palette: the page's rules and the still +/// panes' rails, rules and grip marks in tier 0, a moving or closing pane's +/// in its track's group, notice rules in tier 4, bar cursors in tier 5. +fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, groups: *Groups, layout: CellLayout, win_w: f32, win_h: f32, cursors_shown: bool) !void { + g.decor.clearRetainingCapacity(); + const chrome = &surface.chrome; + const regions = surface.regionList(); + for (groups.items[0..groups.len], 0..) |*group, index| { + group.decor_start = @intCast(g.decor.items.len); + defer group.decor_count = @as(u32, @intCast(g.decor.items.len)) - group.decor_start; + if (index == 0) { + const top = for (regions) |region| { + if (region.kind == .workspace_tag) break region.rect.h; + } else 0; + // The workspace tag is ruled off from the tags right under it. + const tag_under = for (regions) |region| switch (region.kind) { + .grip, .tag, .column_grip, .column_tag => if (region.rect.y == top) break true, + else => {}, + } else false; + const height = topbarPaneBorderPixels(g.cell_h, g.tagline_height); + if (top != 0 and top < surface.rows and tag_under and height != 0) { + const y0: f32 = @floatFromInt(top * g.cell_h - height / 2); + try addDecor(g, gpa, 0, y0, win_w, y0 + @as(f32, @floatFromInt(height)), chrome.topbar_rule, 1, null, win_w, win_h); + } + // The column bar is ruled off from the panes under it. + for (regions) |bar| { + if (bar.kind != .column_grip) continue; + const y = @as(f32, @floatFromInt(bar.rect.y + bar.rect.h)) * layout.h; + try addDecor(g, gpa, 0, y - 1, win_w, y, chrome.border, 1, null, win_w, win_h); + break; + } + // A tag on the last row runs on through the pixels under the grid. + const grid_bottom = @as(f32, @floatFromInt(surface.rows)) * layout.h; + const tag_last = for (regions) |region| switch (region.kind) { + .grip, .tag, .column_grip, .column_tag, .workspace_tag => if (region.rect.h != 0 and region.rect.y + region.rect.h == surface.rows) break true, + else => {}, + } else false; + if (grid_bottom < win_h and tag_last) try addDecor(g, gpa, 0, grid_bottom, win_w, win_h, chrome.tag_bg, 1, null, win_w, win_h); + } + if (index <= groups.tracks) { + const track = group.track; + // A closing pane is gone: its chrome is the last frame's. + const closing = if (track) |active| active.phase == .closing else false; + const placed = if (closing) surface.previous_regions else regions; + const grid = if (closing) surface.previous_cells else surface.cells; + try paneDecor(g, gpa, placed, groups, index, chrome, layout, surface.cols, surface.rows, win_w, win_h, track); + // A single spine runs down each column, through every tag and body. + if (index == 0) for (regions) |column| { + if (column.kind != .column) continue; + const x = @as(f32, @floatFromInt(column.rect.x)) * layout.w; + const y0 = @as(f32, @floatFromInt(column.rect.y)) * layout.h; + const grid_y1 = @as(f32, @floatFromInt(surface.rows)) * layout.h; + const y1 = if (win_h - grid_y1 < layout.h) @max(grid_y1, win_h) else grid_y1; + if (y1 > y0) try addDecor(g, gpa, x, y0, x + 1, y1, chrome.border, 1, null, win_w, win_h); + }; + try gripDecor(g, gpa, placed, grid, groups, index, layout, surface.cols, surface.rows, win_w, win_h, track); + } + // A notice chip is tagline hung over the body's top rows, so it is + // ruled off from the text beneath it the way a tag is: the same + // colour and thickness along its bottom, running to the window's + // edge when it does, fading up and dissolving with its band. + if (index == groups.notices) for (surface.tagLayers()) |*layer| { + if (layer.rows == 0 or layer.kind != .notice) continue; + const x0 = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w; + const x1 = x0 + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w; + const rule_x1 = if (win_w - x1 < layout.w) @max(x1, win_w) else x1; + const top = @as(f32, @floatFromInt(layer.viewport.y)) * layout.h; + // A band still falling into place is cut at its row's top; its + // rule comes with it, and shows only once it is below that cut. + const rule_y = top + (1 + layer.slide) * layout.h; + if (rule_y - 1 < top) continue; + const track = groups.items[groupAt(groups, layer.viewport.x, layer.viewport.y)].track; + try addDecor(g, gpa, x0, rule_y - 1, rule_x1, rule_y, chrome.border, layer.fade, track, win_w, win_h); + }; + if (index == groups.ink and cursors_shown) try cursorDecor(g, gpa, surface, layout, win_w, win_h); + } +} + +/// Each pane's rule between its tag and body, and its scroll rail: a page +/// coloured gutter with a thin track and its thumb, running down through the +/// pixels past the last whole row. Only the panes of group `index`. +fn paneDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, groups: *const Groups, index: usize, chrome: *const pardes.Chrome, layout: CellLayout, cols: u16, rows: u16, win_w: f32, win_h: f32, track: ?pardes.animation.Track) !void { + const page = chrome.page orelse bg_default; + for (regions) |rail| { + if (rail.kind != .rail or rail.rect.h == 0 or groupOf(groups, rail.serial) != index) continue; + const tag = for (regions) |region| { + if (region.kind == .tag and region.owner == rail.owner) break region; + } else continue; + if (@as(u32, tag.rect.x) + tag.rect.w > cols or @as(u32, tag.rect.y) + tag.rect.h > rows or + @as(u32, rail.rect.y) + rail.rect.h > rows) continue; + const x0 = @as(f32, @floatFromInt(rail.rect.x)) * layout.w; + const x1 = @as(f32, @floatFromInt(tag.rect.x + tag.rect.w)) * layout.w; + // Like the band above it, the rule of a pane at the right edge runs + // on through the pixels past the last whole cell. Under a tag on + // top, over one below its body. + const rule_x1 = if (win_w - x1 < layout.w) @max(x1, win_w) else x1; + const tag_bottom = tag.rect.y > rail.rect.y; + const rule_y = @as(f32, @floatFromInt(if (tag_bottom) tag.rect.y else tag.rect.y + tag.rect.h)) * layout.h; + try addDecor(g, gpa, x0, if (tag_bottom) rule_y else rule_y - 1, rule_x1, if (tag_bottom) rule_y + 1 else rule_y, chrome.border, 1, track, win_w, win_h); + + const y0 = @as(f32, @floatFromInt(rail.rect.y)) * layout.h; + const body_end = rail.rect.y + rail.rect.h; + const body_y1 = @as(f32, @floatFromInt(body_end)) * layout.h; + const y1 = if (body_end == rows and win_h - body_y1 < layout.h) @max(body_y1, win_h) else body_y1; + try addDecor(g, gpa, x0, y0, x0 + layout.w, y1, page, 1, track, win_w, win_h); + try addDecor(g, gpa, x0, y0, x0 + @min(layout.w, 2), y1, chrome.scroll_track, 1, track, win_w, win_h); + if (rail.thumb_h != 0) { + const thumb_y0 = @as(f32, @floatFromInt(rail.rect.y + rail.thumb_y)) * layout.h; + const thumb_y1 = if (rail.thumb_y + rail.thumb_h == rail.rect.h) y1 else @as(f32, @floatFromInt(rail.rect.y + rail.thumb_y + rail.thumb_h)) * layout.h; + try addDecor(g, gpa, x0, thumb_y0, x0 + @min(layout.w, @max(2, @floor(layout.w * 0.5))), thumb_y1, chrome.scroll_thumb, 1, track, win_w, win_h); + } + } } -fn buildOverlay( - g: *Gui, - core: ?*const pardes.Pardes, - surface: *const pardes.Surface, - layout: CellLayout, - sw: u32, - sh: u32, - topbar_pane_border_rgb: [3]u8, - tagline_rgb: [3]u8, - transient_on: bool, - debug_on: bool, -) u32 { - 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 }; - appendWorkspacePet(g, core, surface, &builder); - // The workspace tag grows a row per line, so the border follows its foot. - const top_rows: u16 = if (core) |p| p.topBarHeight() else pardes.TOPBAR_H; - if (topbarPaneBorderHeight(surface, top_rows, g.cell_h, g.tagline_height)) |height| { - const y0: f32 = @floatFromInt(top_rows * g.cell_h - height / 2); - const rgb = topbar_pane_border_rgb; - builder.addRect(0, y0, win_w, y0 + @as(f32, @floatFromInt(height)), .{ - .r = @as(f32, @floatFromInt(rgb[0])) / 255.0, - .g = @as(f32, @floatFromInt(rgb[1])) / 255.0, - .b = @as(f32, @floatFromInt(rgb[2])) / 255.0, - .a = 1.0, - }); +/// The two short bars on each row of a grip with no mode glyph there, +/// centered in its anchor. Only the panes of group `index`. +fn gripDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, cells: []const pardes.Cell, groups: *const Groups, index: usize, layout: CellLayout, cols: u16, rows: u16, win_w: f32, win_h: f32, track: ?pardes.animation.Track) !void { + if (g.tagline_height < 6 or g.tagline_width < 3) return; + for (regions) |grip| { + if (grip.kind != .grip or groupOf(groups, grip.serial) != index) continue; + const x = grip.rect.x; + for (0..grip.rect.h) |line| { + const y = grip.rect.y + @as(u16, @intCast(line)); + if (x >= cols or y >= rows) continue; + const cell = &cells[@as(usize, y) * cols + x]; + if (cell.default or cell.printableAscii() != ' ') continue; + const rgb = switch (cell.style.fg) { + .rgb => |rgb| rgb, + else => continue, + }; + const center_x = (@as(f32, @floatFromInt(x)) + @as(f32, @floatFromInt(config.GUTTER)) / 2) * layout.w + @as(f32, @floatFromInt(anchorInset(g, y, win_h) / 2)); + const center_y = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, win_h, g.cell_h, g.tagline_height))) + @as(f32, @floatFromInt(g.tagline_height)) / 2; + const half_w = @max(1, @min(3, layout.w - 2)); + try addDecor(g, gpa, @floor(center_x - half_w), @floor(center_y - 2), @floor(center_x + half_w), @floor(center_y - 1), rgb, 1, track, win_w, win_h); + try addDecor(g, gpa, @floor(center_x - half_w), @floor(center_y + 1), @floor(center_x + half_w), @floor(center_y + 2), rgb, 1, track, win_w, win_h); + } } - if (core) |p| if (p.columnBarHeight() > 0) { - const y = @as(f32, @floatFromInt(top_rows + p.columnBarHeight())) * layout.h; - builder.addRect(0, y - 1, win_w, y, overlayRgb(p.chromeTheme().border)); +} + +test "decor is read from the regions, each pane's in its own group" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); + defer core.deinit(); + _ = try core.newShell(1, ""); + try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); + pardes.test_api.sync(core); + const s = try core.render(core.scratch.allocator()); + var g: Gui = undefined; + g.decor = .empty; + defer g.decor.deinit(gpa); + g.cell_w = 10; + g.cell_h = 20; + g.tagline_width = 6; + g.tagline_height = 12; + const layout = fixedCellLayout(&g); + const ndc = struct { + fn x(px: f32) f32 { + return px / 800 * 2 - 1; + } }; - const grid_bottom = @as(f32, @floatFromInt(surface.rows)) * layout.h; - if (grid_bottom < win_h and bottomTaglinePresent(surface)) { - const rgb = tagline_rgb; - builder.addRect(0, grid_bottom, win_w, win_h, .{ - .r = @as(f32, @floatFromInt(rgb[0])) / 255.0, - .g = @as(f32, @floatFromInt(rgb[1])) / 255.0, - .b = @as(f32, @floatFromInt(rgb[2])) / 255.0, - .a = 1.0, - }); - } - if (transient_on) { - if (core) |p| appendPaneChrome(p, surface, layout, &builder); - appendTagGrips(g, surface, layout, &builder); - if (surface.cursor) |cursor| { - if (cursor.x < surface.cols and cursor.y < surface.rows and bodyLayerAt(surface, cursor.x, cursor.y) == null and tagLayerIn(surface.tagLayers(), cursor.x, cursor.y) == null) { - const cell = surface.cells[@as(usize, cursor.y) * surface.cols + cursor.x]; - const role = cellFontRole(&cell); - const visual_height: f32 = if (role == .tagline) - @floatFromInt(g.tagline_height) - else - layout.h; - const cursor_layout = if (role == .tagline) - taglineLayoutForCell(g, cursor.x) - else - layout; - addCursorBar(&builder, cursor.x, cursor.y, cursor.bar, visual_height, cursor_layout, cursorColor(core, &cell, role)); - } + var groups = makeGroups(&.{}, false); + try buildDecor(&g, gpa, s, &groups, layout, 800, 480, true); + // A spine one pixel wide at each column's left edge, down from under the + // workspace tag; a rail two pixels wide at each pane's. + for (core.col_x[0..core.ncol]) |x| { + const left = ndc.x(@as(f32, @floatFromInt(x)) * 10); + const spine = for (g.decor.items) |item| { + if (item.x0 == left and @abs(item.x1 - ndc.x(@as(f32, @floatFromInt(x)) * 10 + 1)) < 0.0001) break item; + } else return error.TestExpectedSpine; + try std.testing.expectApproxEqAbs(@as(f32, 1 - 20.0 / 480.0 * 2), spine.y0, 0.0001); + try std.testing.expectEqual(@as(f32, -1), spine.y1); + const rail = for (g.decor.items) |item| { + if (item.x0 == left and @abs(item.x1 - ndc.x(@as(f32, @floatFromInt(x)) * 10 + 2)) < 0.0001) break item; + } else return error.TestExpectedRail; + try std.testing.expectApproxEqAbs(@as(f32, s.chrome.scroll_track[0]) / 255, rail.br, 0.0001); + } + try std.testing.expectEqual(@as(u32, 0), groups.items[groups.notices].decor_count); + const still = groups.items[0].decor_count; + + // Pane 1 moving: its rule and rail leave tier 0 for its track's group, + // which moves them with it. + const serial = core.panes[1].?.serial; + const moving: pardes.animation.Track = .{ .pane = 1, .serial = serial, .phase = .moving, .effect = .slide, .from = .{ .x = 0, .y = 1, .w = 40, .h = 23 }, .to = .{ .x = 40, .y = 1, .w = 40, .h = 23 } }; + groups = makeGroups(&.{moving}, true); + try buildDecor(&g, gpa, s, &groups, layout, 800, 480, groups.tracks == 0); + const tracked = groups.items[1]; + try std.testing.expect(tracked.decor_count >= 4); + try std.testing.expectEqual(still, groups.items[0].decor_count + tracked.decor_count); + for (g.decor.items[tracked.decor_start..][0..tracked.decor_count]) |item| + try std.testing.expectEqual(@as(u32, @intFromEnum(pardes.animation.Transition.slide)), item.effect); + // Cursors stand still while panes move. + try std.testing.expectEqual(@as(u32, 0), groups.items[groups.ink].decor_count); +} + +/// The bar cursors: the grid's where no layer has the cell, and each layer's +/// at its own pitch. +fn cursorDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, layout: CellLayout, win_w: f32, win_h: f32) !void { + const chrome = &surface.chrome; + if (surface.cursor) |cursor| { + if (cursor.bar and cursor.x < surface.cols and cursor.y < surface.rows and bodyLayerAt(surface, cursor.x, cursor.y) == null and tagLayerIn(surface.tagLayers(), cursor.x, cursor.y) == null) { + const cell = &surface.cells[@as(usize, cursor.y) * surface.cols + cursor.x]; + const role = cellFontRole(cell); + const width: f32 = if (role == .tagline) @floatFromInt(g.tagline_width) else layout.w; + const height = std.math.clamp(if (role == .tagline) @as(f32, @floatFromInt(g.tagline_height)) else layout.h, 1.0, layout.h); + const x0 = @as(f32, @floatFromInt(cursor.x)) * layout.w; + const y0 = @as(f32, @floatFromInt(cursor.y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(cursor.y, win_h, @intFromFloat(layout.h), @intFromFloat(height)))); + try addDecor(g, gpa, x0, y0, x0 + @max(1.0, width / 8.0), y0 + height, cursorColor(chrome, cell, role), 1, null, win_w, win_h); } } - if (transient_on) for (surface.bodyLayers()) |*layer| { + for (surface.bodyLayers()) |*layer| { if (layer.rows == 0) continue; + const cursor = layer.cursor orelse continue; + if (!cursor.bar or cursor.y >= layer.rows or cursor.x >= layer.cols) continue; const left = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w; const bottom = @as(f32, @floatFromInt(layer.viewport.y + layer.viewport.h)) * layout.h; - if (layer.cursor) |cursor| { - if (!cursor.bar or cursor.y >= layer.rows or cursor.x >= layer.cols) continue; - const top = layer.rowTop(cursor.y, layout.h, @floatFromInt(g.tagline_height)); - if (top >= bottom) continue; - const height = layer.rowHeight(cursor.y, layout.h, @floatFromInt(g.tagline_height)); - const role: pardes.FontRole = if (cursor.y < layer.context_rows) .tagline else .body; - const width: f32 = if (role == .tagline) @floatFromInt(g.tagline_width) else layout.w; - const x = left + @as(f32, @floatFromInt(cursor.x)) * width; - const cell = &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x]; - builder.addRect(x, top, x + @max(1, width / 8), @min(bottom, top + height), overlayRgb(cursorColor(core, cell, role))); - } - }; - if (transient_on) for (surface.tagLayers()) |*layer| { + const top = layer.rowTop(cursor.y, layout.h, @floatFromInt(g.tagline_height)); + if (top >= bottom) continue; + const height = layer.rowHeight(cursor.y, layout.h, @floatFromInt(g.tagline_height)); + const role: pardes.FontRole = if (cursor.y < layer.context_rows) .tagline else .body; + const width: f32 = if (role == .tagline) @floatFromInt(g.tagline_width) else layout.w; + const x = left + @as(f32, @floatFromInt(cursor.x)) * width; + const cell = &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x]; + try addDecor(g, gpa, x, top, x + @max(1, width / 8), @min(bottom, top + height), cursorColor(chrome, cell, role), 1, null, win_w, win_h); + } + for (surface.tagLayers()) |*layer| { if (layer.rows == 0) continue; const cursor = layer.cursor orelse continue; if (!cursor.bar or cursor.x >= layer.cols or cursor.y >= layer.rows) continue; @@ -7331,10 +7547,25 @@ fn buildOverlay( const x = left + @as(f32, @floatFromInt(cursor.x)) * width; if (x >= right) continue; const y = layer.viewport.y + cursor.y; - const top = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, builder.win_h, g.cell_h, g.tagline_height))); - builder.addRect(x, top, @min(right, x + @max(1, width / 8)), top + @as(f32, @floatFromInt(g.tagline_height)), overlayRgb(cursorColor(core, &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x], .tagline))); - }; - if (transient_on) appendTouchOverlay(g, &builder, debug_on); + const top = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, win_h, g.cell_h, g.tagline_height))); + try addDecor(g, gpa, x, top, @min(right, x + @max(1, width / 8)), top + @as(f32, @floatFromInt(g.tagline_height)), cursorColor(chrome, &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x], .tagline), 1, null, win_w, win_h); + } +} + +/// The triangle overlay: the workspace pet and the touch debug view, over +/// everything. +fn buildOverlay( + g: *Gui, + core: ?*const pardes.Pardes, + surface: *const pardes.Surface, + sw: u32, + sh: u32, + debug_on: bool, +) u32 { + if (g.overlay_vertices.len == 0 or sw == 0 or sh == 0) return 0; + var builder = OverlayBuilder{ .vertices = g.overlay_vertices, .win_w = @floatFromInt(sw), .win_h = @floatFromInt(sh) }; + appendWorkspacePet(g, core, surface, &builder); + appendTouchOverlay(g, &builder, debug_on); return @intCast(builder.len); } @@ -7409,33 +7640,11 @@ test "pet overlay stays beyond workspace text and vanishes while its tag is edit try std.testing.expectEqual(@as(usize, 0), builder.len); } -fn cursorColor(core: ?*const pardes.Pardes, cell: *const pardes.Cell, role: pardes.FontRole) [3]u8 { - if (core) |p| return if (role == .tagline) switch (cell.style.fg) { - .rgb => |rgb| rgb, - else => p.chromeTheme().tag_fg, - } else p.theme().fg orelse fg_default; - return switch (cell.style.fg) { +fn cursorColor(chrome: *const pardes.Chrome, cell: *const pardes.Cell, role: pardes.FontRole) [3]u8 { + return if (role == .tagline) switch (cell.style.fg) { .rgb => |rgb| rgb, - .index => |index| palColor(index), - // Detached frames carry explicit page backgrounds but blank cells need - // not carry foreground ink. Keep their insertion caret visible too. - .default => switch (cell.style.bg) { - .rgb => |rgb| if ((@as(u16, rgb[0]) * 3 + @as(u16, rgb[1]) * 6 + rgb[2]) / 10 > 140) - .{ 32, 32, 32 } - else - fg_default, - else => fg_default, - }, - }; -} - -test "detached insertion caret contrasts with blank light and dark pages" { - var cell: pardes.Cell = .{ .default = false, .style = .{ .bg = .{ .rgb = .{ 248, 246, 237 } } } }; - try std.testing.expectEqual([3]u8{ 32, 32, 32 }, cursorColor(null, &cell, .body)); - cell.style.bg = .{ .rgb = .{ 24, 28, 32 } }; - try std.testing.expectEqual(fg_default, cursorColor(null, &cell, .body)); - cell.style.fg = .{ .rgb = .{ 122, 188, 160 } }; - try std.testing.expectEqual([3]u8{ 122, 188, 160 }, cursorColor(null, &cell, .body)); + else => chrome.tag_fg, + } else chrome.fg orelse fg_default; } fn overlayRgb(rgb: [3]u8) OverlayColor { @@ -7447,185 +7656,6 @@ fn overlayRgb(rgb: [3]u8) OverlayColor { }; } -fn cellBackgroundIs(cell: *const pardes.Cell, rgb: [3]u8) bool { - return !cell.default and switch (cell.style.bg) { - .rgb => |bg| std.mem.eql(u8, &bg, &rgb), - else => false, - }; -} - -// The canonical surface still owns scrollbar positions and hit targets. Only -// their pixel ink is inset here; no layout, selection or mouse coordinates move. -fn appendPaneChrome( - core: *const pardes.Pardes, - surface: *const pardes.Surface, - layout: CellLayout, - builder: *OverlayBuilder, -) void { - const chrome = core.chromeTheme(); - const page = overlayRgb(core.theme().bg orelse bg_default); - for (core.panes, core.rects) |slot, r| { - const pane = slot orelse continue; - if (r.w <= config.GUTTER or r.h <= pane.tag_rows) continue; - if (@as(u32, r.x) + r.w > surface.cols or @as(u32, r.y) + r.h > surface.rows) continue; - const tag_y = core.tagTop(pane, r); - const body_y = core.bodyTop(pane, r); - const body_end = body_y + (r.h -| pane.tag_rows); - const x0 = @as(f32, @floatFromInt(r.x)) * layout.w; - const x1 = x0 + @as(f32, @floatFromInt(r.w)) * layout.w; - const tag_cells = surface.cells[@as(usize, tag_y) * surface.cols + r.x + pardes.TAG_TEXT_INSET ..][0 .. r.w - pardes.TAG_TEXT_INSET]; - const tag_visible = for (tag_cells) |*cell| { - if (cellFontRole(cell) != .tagline) break false; - } else true; - if (tag_visible) { - const rule_y = @as(f32, @floatFromInt(tag_y + (if (core.settings.tag_bottom) 0 else pane.tag_rows))) * layout.h; - // Like the band above it, the rule of a pane at the right edge - // runs on through the pixels past the last whole cell. - const rule_x1 = if (builder.win_w - x1 < layout.w) @max(x1, builder.win_w) else x1; - builder.addRect(x0, rule_y - @as(f32, if (core.settings.tag_bottom) 0 else 1), rule_x1, rule_y + @as(f32, if (core.settings.tag_bottom) 1 else 0), overlayRgb(chrome.border)); - } - - // Do not paint over resize previews or debug text occupying the rail. - var thumb_start: ?u16 = null; - var thumb_end: u16 = body_y; - var row = body_y; - while (row < body_end) : (row += 1) { - const cell = &surface.cells[@as(usize, row) * surface.cols + r.x]; - if (cell.printableAscii() != ' ') break; - if (cellBackgroundIs(cell, chrome.scroll_thumb)) { - if (thumb_start == null) thumb_start = row; - thumb_end = row + 1; - } else if (!cellBackgroundIs(cell, chrome.scroll_track)) break; - } - if (row != body_end) continue; - const y0 = @as(f32, @floatFromInt(body_y)) * layout.h; - const body_y1 = @as(f32, @floatFromInt(body_end)) * layout.h; - const y1 = if (body_end == surface.rows and builder.win_h - body_y1 < layout.h) @max(body_y1, builder.win_h) else body_y1; - builder.addRect(x0, y0, x0 + layout.w, y1, page); - appendSquareRail(builder, x0, y0, y1, layout.w, false, overlayRgb(chrome.scroll_track)); - if (thumb_start) |first| { - const thumb_y0 = @as(f32, @floatFromInt(first)) * layout.h; - const thumb_y1 = if (thumb_end == body_end) y1 else @as(f32, @floatFromInt(thumb_end)) * layout.h; - appendSquareRail(builder, x0, thumb_y0, thumb_y1, layout.w, true, overlayRgb(chrome.scroll_thumb)); - } - } - // A notice chip is tagline hung over the body's top rows, so it is ruled - // off from the text beneath it the way a tagline is: the same colour and - // thickness along its bottom, running to the window edge when it does. - for (surface.tagLayers()) |*layer| { - if (layer.rows == 0 or layer.kind != .notice) continue; - const x0 = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w; - const x1 = x0 + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w; - const rule_x1 = if (builder.win_w - x1 < layout.w) @max(x1, builder.win_w) else x1; - const top = @as(f32, @floatFromInt(layer.viewport.y)) * layout.h; - // A band still falling into place is cut at its row's top; its rule - // comes with it, and shows only once it is below that cut. - const rule_y = top + (1 + layer.slide) * layout.h; - if (rule_y - 1 < top) continue; - // ...and fades up and dissolves with it rather than standing alone. - var rule = overlayRgb(chrome.border); - rule.a = layer.fade; - builder.addRect(x0, rule_y - 1, rule_x1, rule_y, rule); - } - appendColumnSpines(core, surface.rows, layout, builder); -} - -// Tag handles use the canonical physical cells, including detached frames. -// Their tag text begins at the layer viewport and may use a narrower font. -fn appendTagGrips(g: *const Gui, surface: *const pardes.Surface, layout: CellLayout, builder: *OverlayBuilder) void { - if (g.tagline_height < 6 or g.tagline_width < 3) return; - for (surface.tagLayers()) |layer| { - if (layer.rows == 0 or layer.kind != .pane or layer.viewport.x < pardes.TAG_TEXT_INSET) continue; - const x = layer.viewport.x - pardes.TAG_TEXT_INSET; - // A mark on each of the tag's rows whose grip cell is blank: every - // row but the one with the mode's glyph. - for (0..layer.rows) |index| { - const y = layer.viewport.y + @as(u16, @intCast(index)); - if (x >= surface.cols or y >= surface.rows) continue; - const cell = &surface.cells[@as(usize, y) * surface.cols + x]; - if (cell.default or cell.printableAscii() != ' ') continue; - const rgb = switch (cell.style.fg) { - .rgb => |rgb| rgb, - else => continue, - }; - const center_x = (@as(f32, @floatFromInt(x)) + @as(f32, @floatFromInt(config.GUTTER)) / 2) * layout.w + @as(f32, @floatFromInt(anchorInset(g, y, builder.win_h) / 2)); - const center_y = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, builder.win_h, g.cell_h, g.tagline_height))) + @as(f32, @floatFromInt(g.tagline_height)) / 2; - const half_w = @max(1, @min(3, layout.w - 2)); - builder.addRect(@floor(center_x - half_w), @floor(center_y - 2), @floor(center_x + half_w), @floor(center_y - 1), overlayRgb(rgb)); - builder.addRect(@floor(center_x - half_w), @floor(center_y + 1), @floor(center_x + half_w), @floor(center_y + 2), overlayRgb(rgb)); - } - } -} - -test "column grips stay unmarked while pane marks retain physical centering" { - var g: Gui = undefined; - g.tagline_height = 12; - g.tagline_width = 6; - g.cell_h = 20; - var cells: [80]pardes.Cell = @splat(.{ .default = false, .style = .{ .fg = .{ .rgb = .{ 100, 120, 140 } } } }); - var surface: pardes.Surface = .{ .cols = 20, .rows = 4, .cells = &cells }; - surface.tag_layers[0] = .{ .kind = .column, .viewport = .{ .x = 8, .y = 1, .w = 13, .h = 1 }, .cols = 20, .rows = 1 }; - surface.tag_layers[1] = .{ .kind = .pane, .viewport = .{ .x = 8, .y = 3, .w = 13, .h = 1 }, .cols = 20, .rows = 1 }; - var vertices: [24]OverlayVertex = undefined; - var builder: OverlayBuilder = .{ .vertices = &vertices, .win_w = 200, .win_h = 80 }; - appendTagGrips(&g, &surface, .{ .w = 10, .h = 20, .x_off = 0, .y_off = 0 }, &builder); - try std.testing.expectEqual(@as(usize, 12), builder.len); - // Centered in the anchor, whose left edge is inset by the band's offset - // (8 px on this last row): the mark moves right by half of it. - try std.testing.expectApproxEqAbs(@as(f32, 61.0 / 200.0 * 2.0 - 1.0), vertices[0].x, 0.0001); -} - -fn appendSquareRail(builder: *OverlayBuilder, x: f32, y0: f32, y1: f32, cell_w: f32, thumb: bool, color: OverlayColor) void { - const width = @min(cell_w, if (thumb) @max(2, @floor(cell_w * 0.5)) else 2); - builder.addRect(x, y0, x + width, y1, color); -} - -fn appendColumnSpines(core: *const pardes.Pardes, rows: u16, layout: CellLayout, builder: *OverlayBuilder) void { - const y0 = @as(f32, @floatFromInt(core.topBarHeight())) * layout.h; - const grid_y1 = @as(f32, @floatFromInt(rows)) * layout.h; - // Down through the pixels past the last whole row, as tags run right. - const y1 = if (builder.win_h - grid_y1 < layout.h) @max(grid_y1, builder.win_h) else grid_y1; - if (y1 <= y0) return; - // A single boundary spans all panes, including their tags. Its left edge - // agrees with the square scroll markers and existing move-box hit area. - for (core.col_x[0..core.ncol], core.col_w[0..core.ncol]) |col, width| { - if (width == 0) continue; - const x = @as(f32, @floatFromInt(col)) * layout.w; - builder.addRect(x, y0, x + 1, y1, overlayRgb(core.chromeTheme().border)); - } -} - -test "square scroll markers share the column edge and have no rounded end inset" { - var vertices: [12]OverlayVertex = undefined; - var builder: OverlayBuilder = .{ .vertices = &vertices, .win_w = 800, .win_h = 480 }; - appendSquareRail(&builder, 400, 40, 480, 10, false, overlayRgb(.{ 40, 40, 40 })); - appendSquareRail(&builder, 400, 80, 160, 10, true, overlayRgb(.{ 100, 100, 100 })); - try std.testing.expectEqual(@as(usize, 12), builder.len); - try std.testing.expectEqual(vertices[0].x, vertices[6].x); - try std.testing.expectApproxEqAbs(@as(f32, 1 - 80.0 / 480.0 * 2), vertices[6].y, 0.0001); - try std.testing.expectApproxEqAbs(@as(f32, 1 - 160.0 / 480.0 * 2), vertices[8].y, 0.0001); - try std.testing.expectApproxEqAbs(@as(f32, 5.0 / 800.0 * 2), vertices[7].x - vertices[6].x, 0.0001); -} - -test "column spines remain continuous across every pane tag and body" { - const core = try pardes.Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); - defer core.deinit(); - _ = try core.newShell(1, ""); - try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); - pardes.test_api.sync(core); - var vertices: [12]OverlayVertex = undefined; - var builder: OverlayBuilder = .{ .vertices = &vertices, .win_w = 800, .win_h = 480 }; - appendColumnSpines(core, 24, .{ .w = 10, .h = 20, .x_off = 0, .y_off = 0 }, &builder); - try std.testing.expectEqual(@as(usize, 12), builder.len); - for (0..2) |col| { - const first = vertices[col * 6]; - const bottom = vertices[col * 6 + 2]; - try std.testing.expectApproxEqAbs(@as(f32, 1 - 20.0 / 480.0 * 2), first.y, 0.0001); - try std.testing.expectEqual(@as(f32, -1), bottom.y); - try std.testing.expectApproxEqAbs(@as(f32, @floatFromInt(core.col_x[col])) * 10.0 / 800.0 * 2 - 1, first.x, 0.0001); - } -} - fn appendTouchOverlay(g: *Gui, builder: *OverlayBuilder, debug_on: bool) void { if (!debug_on) { g.touch.click_flash_frames = 0; diff --git a/src/pardes.zig b/src/pardes.zig index f8087bdb..616747fa 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -39,6 +39,7 @@ pub const ImagePatch = @import("surface.zig").ImagePatch; pub const ImagePlace = @import("surface.zig").ImagePlace; pub const Surface = @import("surface.zig").Surface; pub const Region = @import("surface.zig").Region; +pub const Chrome = @import("surface.zig").Chrome; pub const MAX_REGIONS = @import("surface.zig").MAX_REGIONS; pub const builtins = @import("builtins.zig"); const limits = memory.limits; diff --git a/src/surface.zig b/src/surface.zig index db7dd468..07b593b1 100644 --- a/src/surface.zig +++ b/src/surface.zig @@ -344,6 +344,13 @@ pub const Region = struct { column_grip, column_tag, workspace_tag, + /// A column below the workspace tag, its bar and panes: its left + /// edge is the spine a pixel shell rules down it. + column, + /// A drag's guide or a seam's hover hint, drawn over the panes. + guide, + /// The debug stats box, drawn over the panes. + debug, }; pub const none = std.math.maxInt(u8); @@ -368,7 +375,30 @@ pub const Region = struct { clip: u16 = 0, }; -pub const MAX_REGIONS = 1 + MAX_PANES * (4 + panes.Pane.Notices.max) + pardes.MAX_COLS * 2 + 1; +/// Guides a frame can draw: a run of a column-move rail per pane it skips +/// the tags of, and a handful of border and hover dashes. +pub const MAX_GUIDES = MAX_PANES + 4; +pub const MAX_REGIONS = 1 + MAX_PANES * (4 + panes.Pane.Notices.max) + pardes.MAX_COLS * 3 + 1 + MAX_GUIDES + 1; + +/// The colours a pixel shell draws its chrome in (rules, rails, grips, the +/// tag underlay), resolved by the core each frame, so a frontend attached +/// to a detached session draws them from the frame alone. +pub const Chrome = struct { + /// The theme's ground and ink; null where it leaves them to the shell. + page: ?[3]u8 = null, + fg: ?[3]u8 = null, + /// Rules and column spines. + border: [3]u8 = .{ 0, 0, 0 }, + /// The rule under the workspace tag. + topbar_rule: [3]u8 = .{ 0, 0, 0 }, + tag_bg: [3]u8 = .{ 0, 0, 0 }, + /// The focused pane's and column's tag underlay: `tag_bg` unless + /// FocusTint is on. + tag_focus_bg: [3]u8 = .{ 0, 0, 0 }, + tag_fg: [3]u8 = .{ 0, 0, 0 }, + scroll_track: [3]u8 = .{ 0, 0, 0 }, + scroll_thumb: [3]u8 = .{ 0, 0, 0 }, +}; /// The canonical interface: what a frame of pardes IS. The tty shell writes /// these cells to vaxis one-to-one; the SDL shells rasterize them. @@ -385,6 +415,7 @@ pub const Surface = struct { rows: u16 = 0, regions: [MAX_REGIONS]Region = undefined, nregions: u16 = 0, + chrome: Chrome = .{}, cells: []Cell = &.{}, /// bar: draw an insert-style thin cursor instead of the block cursor: ?struct { x: u16, y: u16, bar: bool = false } = null, @@ -395,6 +426,7 @@ pub const Surface = struct { previous_cells: []const Cell = &.{}, previous_body_layers: []const Layer = &.{}, previous_tag_layers: []const Layer = &.{}, + previous_regions: []const Region = &.{}, cell_diffs: []const PanelCellDiff = &.{}, pub fn tagLayers(s: *const Surface) []const Layer { @@ -409,6 +441,12 @@ pub const Surface = struct { return s.regions[0..s.nregions]; } + pub fn addRegion(s: *Surface, r: Region) void { + std.debug.assert(s.nregions < s.regions.len); + s.regions[s.nregions] = r; + s.nregions += 1; + } + /// The first part of `kind` that `owner` has: its tag, its rail. pub fn region(s: *Surface, kind: Region.Kind, owner: usize) ?*Region { for (s.regions[0..s.nregions]) |*r| if (r.kind == kind and r.owner == owner) return r; diff --git a/test/gui-goldens.txt b/test/gui-goldens.txt index e52155a3..27c39150 100644 --- a/test/gui-goldens.txt +++ b/test/gui-goldens.txt @@ -10,8 +10,9 @@ 10-tagbottom dd8ba0d3ecabf3420d000b0c9591a57278179cbd2a9060f849d283f3881d1847 11-column-tags 6b1731d1611404597c96ad77f34c2d5cd5a81c1085ec68c6779388e07ce28614 12-multiline-tag 1b9ccd331814dc76111470f18448b1ca192e1edc3d79ea6eee19a6a78a4b1831 -13-image de18edee2ab2e0f2d4bf4162d86e3d8a1852fccfc344830491fa1a359095d4ea -14-theme-ink 6e733d4212373c90b860b2e41102ced8719b07480591b5da3905267c8358db18 -15-theme-acme-light e099dc1a351de8efa63ecd0ec0899636ea31b722a945eb40137a18abd87a82e2 -16-debug e97ce74b7a3d4a08f6a529b1f0de111d62e5532c829a6b1633b966dd45218f55 -17-terminal 304a84d1ef871f581d0a3fade9bc1acdcc87a3612c607548ab0d2851871753ee +13-image a52e2cf9f238447d885692cba01e68ef7fdc6ae3fc7c8fe1e95c8463c85f519c +14-theme-ink cc2ea256ddda24e1c97871f75b71ddf62fa825bd02f5d1432592d05e478c707a +15-theme-acme-light 2bde8a2bdfada7f061217f52cb2272913fc0657dbfa1d7448e1852f321f8e3bf +16-debug 129ad6f5f85b0e60e81bc6e8e78434629ee751f49731cf2eb28fd0553efd914a +17-terminal a1305fd755b6bc957228bbf28833f972c2355af9abf40b77f76b30741109574f +18-mid-transition d4f353ef5fda80411cd9de9729e5b23f59ff7511c10774e69214152ee8070725 diff --git a/test/gui_golden.py b/test/gui_golden.py index 327911cc..d97009c9 100644 --- a/test/gui_golden.py +++ b/test/gui_golden.py @@ -106,7 +106,9 @@ def run(args): display = Path(os.environ.get('WAYLAND_DISPLAY', 'wayland-0')) if not display.is_absolute(): display = Path(os.environ.get('XDG_RUNTIME_DIR', '/run/user/%d' % os.getuid())) / display - inherited = {'PARDES_TEST': '1', 'PARDES_TEST_COLS': '120', 'PARDES_TEST_ROWS': '40', + # The virtual clock: a transition's frames are the same frames on any + # machine, so one partway through can be hashed too. + inherited = {'PARDES_TEST': '1', 'PARDES_TEST_CLOCK': '1', 'PARDES_TEST_COLS': '120', 'PARDES_TEST_ROWS': '40', 'PARDES_TEST_PAD': '7', 'PARDES_TEST_CAPTURE_DIR': str(root), 'SDL_VIDEODRIVER': 'wayland', 'WAYLAND_DISPLAY': str(display)} launch = ['-c', 'stty cols 120 rows 40; exec "$@"', name, str(args.binary.resolve()), @@ -117,11 +119,12 @@ def run(args): first = require(wait_for(lambda: next((s for s, tag in panes(client).items() if 'shapes.zig' in tag), None)), 'source pane missing') - def scene(label): - results.append((label, settled(root))) + def scene(label, frame='latest.ppm'): + settled(root) + results.append((label, hashlib.sha256((root / frame).read_bytes()).hexdigest())) if args.keep: args.keep.mkdir(parents=True, exist_ok=True) - shutil.copyfile(root / 'latest.ppm', args.keep / (label + '.ppm')) + shutil.copyfile(root / frame, args.keep / (label + '.ppm')) print(label, results[-1][1][:16], flush=True) def mouse(kind, button, x, y): @@ -196,6 +199,13 @@ def run(args): client.write(f'/pane/{terminal}/pty/data', b"PS1='$ '; clear; printf '\\033[1;36mcolour\\033[0m plain \\033[32mgreen\\033[0m\\n'\r") require(wait_for(lambda: b'green' in client.read(f'/pane/{terminal}/body')), 'terminal output missing') scene('17-terminal') + # Partway through a transition: the panes, the picture among them, + # sliding over for a new column, frame 6 of PanelSlide's 12. Its + # chrome slides with each pane. + client.write('/ctl', b'PanelSlide\n') + client.write(f'/pane/{first}/exec', b'Newcol\n') + require(wait_for(lambda: (root / 'transition-6.ppm').exists()), 'transition frame 6 never drawn') + scene('18-mid-transition', 'transition-6.ppm') lines = ''.join(f'{label} {digest}\n' for label, digest in results) if args.update: GOLDENS.write_text(lines) -- cgit v1.3 From c6a5aaf5c4a020eaefe24943ecb6cf9d4eb26836 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 15:57:22 -0300 Subject: Post chain: Shadertoy passes compiled by glslc; Crt, Ripple and Glitch rewritten as Shadertoy files Stage 9 of docs/render-pipeline.md. src/gui/Post.zig runs the chain over the finished frame: each pass reads the one before, ping-pong textures between, the last writes the window; an empty chain is the direct path. The prefix (shaders/post/prefix.glsl) is ghostty's uniform block, name for name and offset for offset (tested against a copy of its Uniforms, the selection colours by their GLSL names), at SDL GPU's bindings, y down. A user's file (Shader ) is compiled by glslc with compileGlsl's own flags on a thread of its own, taken in at the next loop step; errors name the file's own lines; a failed compile keeps the last good pipeline; glslc missing is said once. The bundled passes compile with the build, through the same prefix. Crt, Ripple and Glitch are Shadertoy files, rewritten: no barrel (input is identity, tested), everything in linear light and dithered, keyed to iTime. scene_effects, crt.zig and crt.frag.glsl are gone; the core keeps no clock for the chain. ShaderAnimation off|on|always drives redraw level A: the chain alone over the retained frame (34 us CPU a redraw, measured). Shared files touched: pardes.zig (nextWake loses the scene line, disableSceneEffects empties the chain, two tests), builtins.zig (one switch arm), ninep/ctl.zig (two switch arms), macos.zig (flags from the chain), gui.zig. Not touched: Messages.zig, mouse.zig, detached/*, host_io, tty. --- build.zig | 28 +- docs/render-pipeline.md | 28 +- shaders/crt.frag.glsl | 175 ----------- shaders/crt.vert.glsl | 9 - shaders/post.vert.glsl | 10 + shaders/post/crt.glsl | 79 +++++ shaders/post/glitch.glsl | 60 ++++ shaders/post/prefix.glsl | 67 ++++ shaders/post/ripple.glsl | 49 +++ shaders/prebuilt/crt.frag.glsl | 175 ----------- shaders/prebuilt/crt.frag.spv | Bin 13832 -> 0 bytes shaders/prebuilt/crt.vert.glsl | 9 - shaders/prebuilt/crt.vert.spv | Bin 1208 -> 0 bytes shaders/prebuilt/post-crt.frag.glsl | 146 +++++++++ shaders/prebuilt/post-crt.frag.spv | Bin 0 -> 9480 bytes shaders/prebuilt/post-glitch.frag.glsl | 127 ++++++++ shaders/prebuilt/post-glitch.frag.spv | Bin 0 -> 8308 bytes shaders/prebuilt/post-ripple.frag.glsl | 116 +++++++ shaders/prebuilt/post-ripple.frag.spv | Bin 0 -> 7180 bytes shaders/prebuilt/post.vert.glsl | 10 + shaders/prebuilt/post.vert.spv | Bin 0 -> 1208 bytes src/animation.zig | 6 - src/builtins.zig | 2 +- src/config.zig | 150 ++++++++- src/effect_sources.zig | 23 +- src/gui/Post.zig | 559 +++++++++++++++++++++++++++++++++ src/gui/crt.zig | 101 ------ src/gui/gui.zig | 338 ++++++++++---------- src/macos.zig | 23 +- src/ninep/ctl.zig | 4 +- src/pardes.zig | 28 +- 31 files changed, 1611 insertions(+), 711 deletions(-) delete mode 100644 shaders/crt.frag.glsl delete mode 100644 shaders/crt.vert.glsl create mode 100644 shaders/post.vert.glsl create mode 100644 shaders/post/crt.glsl create mode 100644 shaders/post/glitch.glsl create mode 100644 shaders/post/prefix.glsl create mode 100644 shaders/post/ripple.glsl delete mode 100644 shaders/prebuilt/crt.frag.glsl delete mode 100644 shaders/prebuilt/crt.frag.spv delete mode 100644 shaders/prebuilt/crt.vert.glsl delete mode 100644 shaders/prebuilt/crt.vert.spv create mode 100644 shaders/prebuilt/post-crt.frag.glsl create mode 100644 shaders/prebuilt/post-crt.frag.spv create mode 100644 shaders/prebuilt/post-glitch.frag.glsl create mode 100644 shaders/prebuilt/post-glitch.frag.spv create mode 100644 shaders/prebuilt/post-ripple.frag.glsl create mode 100644 shaders/prebuilt/post-ripple.frag.spv create mode 100644 shaders/prebuilt/post.vert.glsl create mode 100644 shaders/prebuilt/post.vert.spv create mode 100644 src/gui/Post.zig delete mode 100644 src/gui/crt.zig (limited to 'build.zig') diff --git a/build.zig b/build.zig index 803f773d..c1117593 100644 --- a/build.zig +++ b/build.zig @@ -50,7 +50,11 @@ const gui_shaders = [_][]const u8{ "decor.frag", "overlay.vert", "overlay.frag", "image.vert", "image.frag", - "crt.vert", "crt.frag", + "post.vert", + // The bundled post passes: shaders/post/.glsl behind the Shadertoy + // prefix (shaderSource). + "post-crt.frag", "post-ripple.frag", + "post-glitch.frag", }; pub fn build(b: *std.Build) void { @@ -161,7 +165,7 @@ pub fn build(b: *std.Build) void { for (gui_shaders) |name| { update_shaders.addCopyFileToSource(compileGlsl(b, name), b.fmt("shaders/prebuilt/{s}.spv", .{name})); update_shaders.addCopyFileToSource( - b.path(b.fmt("shaders/{s}.glsl", .{name})), + shaderSource(b, name), b.fmt("shaders/prebuilt/{s}.glsl", .{name}), ); } @@ -691,7 +695,7 @@ pub fn build(b: *std.Build) void { mod.addOptions("pardes_isolation", if (isolated_mod == mod) iso_on else iso_off); if (shell == .gui) for (gui_shaders) |name| mod.addAnonymousImport( b.fmt("effect-source-{s}.glsl", .{name}), - .{ .root_source_file = b.path(b.fmt("{s}/{s}.glsl", .{ gui_effect_source_dir, name })) }, + .{ .root_source_file = if (prebuilt_shaders) b.path(b.fmt("{s}/{s}.glsl", .{ gui_effect_source_dir, name })) else shaderSource(b, name) }, ); if (shell == .macos) mod.addAnonymousImport("effect-source-crt.ci.metal", .{ .root_source_file = b.path("shaders/crt.ci.metal"), @@ -756,6 +760,8 @@ pub fn build(b: *std.Build) void { gm.addAnonymousImport("AdwaitaMono-Regular.ttf", .{ .root_source_file = b.path("assets/AdwaitaMono-Regular.ttf"), }); + // Put before a user's Shadertoy file when the gui compiles it. + gm.addAnonymousImport("post-prefix.glsl", .{ .root_source_file = b.path("shaders/post/prefix.glsl") }); for (gui_shaders) |name| gm.addAnonymousImport(b.fmt("{s}.spv", .{name}), .{ .root_source_file = if (prebuilt_shaders) b.path(b.fmt("shaders/prebuilt/{s}.spv", .{name})) @@ -1444,7 +1450,6 @@ pub fn build(b: *std.Build) void { .{ .path = "src/esp32p4/input_rescue.zig", .libc = false }, .{ .path = "src/fonts.zig", .libc = true }, .{ .path = "src/c_heap.zig", .libc = false }, - .{ .path = "src/gui/crt.zig", .libc = false }, }) |entry| { const tests = b.addTest(.{ .root_module = b.createModule(.{ @@ -1626,10 +1631,23 @@ fn reindexed(b: *std.Build, archive: std.Build.LazyPath, index: usize) std.Build // glslc shaders/.glsl -fshader-stage= -o .spv, // returned as a LazyPath for @embedFile. The stage is the name's own suffix. +/// The GLSL a shader is compiled from: shaders/.glsl, or for a bundled +/// post pass (post-.frag) shaders/post/.glsl behind the Shadertoy +/// prefix, the one the gui puts before a user's file at run time. +fn shaderSource(b: *std.Build, name: []const u8) std.Build.LazyPath { + if (!std.mem.startsWith(u8, name, "post-")) return b.path(b.fmt("shaders/{s}.glsl", .{name})); + const joined = b.addSystemCommand(&.{"cat"}); + joined.addFileArg(b.path("shaders/post/prefix.glsl")); + joined.addFileArg(b.path(b.fmt("shaders/post/{s}.glsl", .{name["post-".len .. name.len - ".frag".len]}))); + return joined.captureStdOut(.{}); +} + +/// The one glslc invocation every shader is compiled with, at build time here +/// and at run time for a user's post pass (src/gui/Post.zig). fn compileGlsl(b: *std.Build, name: []const u8) std.Build.LazyPath { const stage = if (std.mem.endsWith(u8, name, ".vert")) "vertex" else "fragment"; const cmd = b.addSystemCommand(&.{ "glslc", b.fmt("-fshader-stage={s}", .{stage}) }); - cmd.addFileArg(b.path(b.fmt("shaders/{s}.glsl", .{name}))); + cmd.addFileArg(shaderSource(b, name)); cmd.addArg("-o"); return cmd.addOutputFileArg(b.fmt("{s}.spv", .{name})); } diff --git a/docs/render-pipeline.md b/docs/render-pipeline.md index d25323b6..254187a3 100644 --- a/docs/render-pipeline.md +++ b/docs/render-pipeline.md @@ -491,21 +491,27 @@ Mirror ghostty 1.3.2 (`zig-pkg/ghostty-*/src/renderer/shadertoy.zig`, ≈3 cells, at the corners of a 4K screen, where the grips and rails are. `crt.zig` is deleted. If the user insists on barrel in the bundled CRT, keep `crt.zig`'s inverse keyed to "bundled crt active". -- **Compile**: GLSL → SPIR-V at runtime with glslang (ghostty's `pkg/glslang`, - the same vendored package in zig-pkg, GUI build only, behind `-Dshadertoy`). - Compiled on load and on file change (file_watch), never on the frame path; - the last good pipeline stays on error; the error is a pane message. glslang is - C++ with its own allocator: a recorded exception to the "Zig allocator hooks - in all C deps" policy. +- **Compile** (decision 3): a user's file is compiled by spawning `glslc` with + the build's own invocation (`-fshader-stage=fragment -o -`, SPIR-V on + stdout), on a thread of its own when the file joins the chain, never on the + frame path; the result is taken in at the next loop step. The prefix ends in + `#line 1`, so glslc's errors count the file's own lines, and they name the + file, not the temporary source. A failed compile keeps the file's last good + pipeline and says glslc's first line as a message; glslc missing is said + once, and only files stay off (the bundled passes are compiled with the + build, through the same prefix). No file watching: `Shader ` twice + (out, then in) compiles it again. - **Animation mode**: `ShaderAnimation off|on|always` (ghostty's `custom-shader-animation`): off = redraw only when content changes; on = continuous while the window is focused; always = continuous. Continuous means the shell redraws its retained Surface (3.1), never a core tick or render. - **Existing crt/ripple/glitch** become bundled Shadertoy files in - `shaders/post/` selected by the same builtins; `scene_effects` and - `crt.frag.glsl`/`crt.zig` are deleted. The bundled CRT is rewritten to - quality (6.1); ripple and glitch are dropped unless the feel review keeps a - rewritten version. + `shaders/post/` selected by the same builtins (decision 2: kept, polished); + `scene_effects` and `crt.frag.glsl`/`crt.zig` are deleted. The chain is + `Crt`, `Ripple`, `Glitch` and `Shader ` in the order they were put in + (`Shader off` empties it of files), `ShaderAnimation off|on|always` + (default on). The core keeps no clock for it: level A redraws are the + shell's. - macOS (out of scope) would need spirv-cross → MSL, as ghostty does. Region filters (e.g. "tint/blur one pane") are NOT built. If one is ever needed: @@ -811,7 +817,7 @@ stripped. No visual change until stage 9 unless stated. | 6 | body layer joined the same way (paint once, copy visible rows) | riskiest join; A/B the old double paint in a temporary test with tag_bottom on and off, then delete it | | 7 | Layer merge (TagLayer + BodyLayer), wire v8, web accessors | after the other agent lands; touches mouse hit paths; breaks the macOS shell's layer ABI (accepted: macOS build ignored for now); done: one `Layer` (src/Layer.zig) with `rows` (0 = no layer) and a `cursor{x, y}`; a tag of N rows is ONE layer of N grid rows (`tagHit` answers the row as `line`, `bodyHit` keeps its meaning), so the per-line layer bases are gone. Wire v8 ships rows, cursor y and the region list in the one bump; v7 and v9 peers are refused in both directions (tests). web: `tag_layer_value` 11 = rows, 12 = cursor y, and app.mjs lays every row. macOS: its Zig side compiles against `Layer`, but pardes.h still sees one row per tag layer (a taller tag shows its first row there) | | 8 | GUI draws from regions: role tiers with track groups in tiers 2-3, page cover, hard-edged snapped decor for rules/rails/grips (pane chrome in the pane's tier), per-instance clip; delete inference functions, `transient_on` and `mark_hover` (breaks macOS glass hover; accepted, macOS ignored for now) | stage-0 PPM goldens byte-identical (possible only because hard decor is snapped and not anti-aliased); pane chrome now also shows during transitions, which is the one allowed visible delta, listed Done: the frame is drawn in groups (makeGroups): tier 0, one group per track in paint order (tiers 2 and 3), tier 4 as two groups (the notices; then the guides and the debug box, which the core paints over them), tier 5 (bar cursors), each drawing cells, images, decor. Decor is every rule, rail, thumb, grip mark, spine, the workspace and column rules, the bottom band, notice rules and bar cursors: whole-pixel rects drawn by `decor.frag` over `ui.vert` as cell instances (colour in the ground, coverage in fg.r, blended like the overlay), so each carries its track's transition and clip; a closing pane's comes from the last frame's regions (`Surface.previous_regions`). Only `solid` exists yet: `decor.vert` and the other kinds (§5.1) come with the first effect that needs them. Deleted: taglineBaseRgb, topbarPaneBorderHeight, bottomTaglinePresent, frameChromeBg, cellBackgroundIs and the rail inference, paneGripCell's scan, transient_on, PaintPlan; one cover map (coverFrame: layer, grip and offset, focus, anchor, floating) is marked from layers and regions once a frame. New regions: `column` (spines, anchors, the focused column's tint), `guide`, `debug`; `Surface.chrome` is the palette, on the wire in v8 (not yet shipped, so no bump), and an attached GUI draws the same chrome (test). Per-instance clip (`CellInstance.clip_*`, 104 → 120 bytes an instance, ~15% more upload a frame) replaces the vertical transition's scissor. While any pane moves, opens or closes, notices stay in their own panes' groups, under whatever slides over them; tier 4 holds them only when nothing moves. Goldens: 01-12 byte-identical; 13-17 differ only in pixels past the grid (with a picture on screen the image pass left the scissor at the grid's size, cutting every rule end, rail foot and band that runs into the leftover pixels; they now run to the edge as in every scene without one); 16-debug shows the debug box (it was drawn from the grid under the source's context-row layer, so the GUI never showed it there); 18-mid-transition is new (virtual clock, PanelSlide Newcol with the picture, frame 6 of 12: chrome moves with its pane). Other deltas, outside the goldens: a guide over a tag or a context-row body now shows; with WindowOpacity < 100 a layer's bar cursor is ink like the grid's; an attached GUI gains the focus tint, notice rules, spines and the theme's page and caret colours. Deferrals fixed: the GUI sleeps when idle (SDL and queue events wake it; a smooth scroll, a gamepad, the test feed, a shell's kill deadline, a present to retry still poll), and a minimized or occluded window sleeps through animation. Tracy, `gui frame build`, 200x60, terminal output plus scrolling, ReleaseFast, two interleaved runs of ~180 frames: before 992/1015 µs median, after 989/987 µs | -| 9 | post chain: glslang, Shadertoy prefix, ping-pong, ShaderAnimation, redraw levels A/B (§5.5); bundled CRT without barrel; delete scene_effects/crt.zig/crt.frag | first visual change (the CRT look) | +| 9 | post chain: glslang, Shadertoy prefix, ping-pong, ShaderAnimation, redraw levels A/B (§5.5); bundled CRT without barrel; delete scene_effects/crt.zig/crt.frag | first visual change (the CRT look) Done: src/gui/Post.zig, shaders/post/ (prefix, crt, ripple, glitch), post.vert. glslc (decision 3) with compileGlsl's flags, off the frame path; ghostty's Uniforms matched offset for offset by a test against a copy, and the prefix's block checked name by name; ghostty's test_shadertoy_crt and _focus compile, _invalid fails with glslc's words. Level A measured (Tracy, 200x60, Crt, idle 10 s): 215 chain-only redraws at 34 µs median CPU each, 6 core frames (957 µs) in the same time; the core's clock stays idle. Input is identity (test: the corner click with Crt on). The three bundled passes were rewritten: Crt without barrel or tube edge, scanlines and mask that average to one, dithered vignette; Ripple as rings in pixels, eased in, lit on their slopes in linear light, dithered; Glitch as short eased bursts of torn bands with an RGB split, keyed to iTime. Before/after stills for the user's judgement. | | 10+ | `fx` bookmark: G1–G3 → feel review → G4 bundled → G5 lapis theme → P2s; tty T1–T2 → feel review → removals of audited effects (after user decision) → T3–T5 | each effect its own change, default off | Tests to add: fixed-clock animation tests (Tween/Spring closed form, retarget diff --git a/shaders/crt.frag.glsl b/shaders/crt.frag.glsl deleted file mode 100644 index b0b0b93b..00000000 --- a/shaders/crt.frag.glsl +++ /dev/null @@ -1,175 +0,0 @@ -#version 450 - -// The Crt builtin's post-process. Screen point cc shows the scene at -// uv' = cc*(1 + BARREL*r2), and src/gui/crt.zig maps mouse input through that -// same transform — keep them equal or clicks drift near the edges. - -layout(set = 2, binding = 0) uniform sampler2D u_scene; -layout(std140, set = 3, binding = 0) uniform CrtUniforms { - float time_seconds; - float frame; - vec2 viewport; - // x = CRT, y = ripple, z = glitch. One scene pass can combine them. - vec4 effects; -} u_crt; - -layout(location = 0) out vec4 o_col; - -const float TAU = 6.28318530718; -const float BARREL = 0.016; // MUST match src/gui/crt.zig barrel -const float EDGE_SOFTNESS_PX = 3.5; -const float CHROMA_PX = 0.28; // radial R/B separation in physical pixels at r2=1 -const float BLOOM_RADIUS_PX = 1.75; -const float BLOOM_THRESHOLD = 0.60; // linear-light channel peak -const float BLOOM_SOFTNESS = 0.25; -const float BLOOM_STRENGTH = 0.045; -const float SCAN_PERIOD_PX = 3.0; -const float SCAN_STRENGTH = 0.055; -const float MASK_PERIOD_PX = 3.0; -const float MASK_STRENGTH = 0.015; -const float VIGNETTE_STRENGTH = 0.11; // 11% only at the extreme corners -const float HUM_STRENGTH = 0.0025; -const float NOISE_STRENGTH = 0.0020; -const float EXPOSURE = 1.012; - -vec3 srgbToLinear(vec3 c) { - vec3 low = c / 12.92; - vec3 high = pow((c + 0.055) / 1.055, vec3(2.4)); - return mix(high, low, lessThanEqual(c, vec3(0.04045))); -} - -vec3 linearToSrgb(vec3 c) { - c = max(c, vec3(0.0)); - vec3 low = c * 12.92; - vec3 high = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055; - return mix(high, low, lessThanEqual(c, vec3(0.0031308))); -} - -vec3 sceneLinear(vec2 uv) { - return srgbToLinear(texture(u_scene, uv).rgb); -} - -vec3 brightPass(vec2 uv) { - vec3 c = sceneLinear(uv); - float peak = max(c.r, max(c.g, c.b)); - return c * smoothstep(BLOOM_THRESHOLD, BLOOM_THRESHOLD + BLOOM_SOFTNESS, peak); -} - -float noiseAt(vec2 p) { - vec3 q = fract(vec3(p.xyx) * 0.1031); - q += dot(q, q.yzx + 33.33); - return fract((q.x + q.y) * q.z); -} - -// The non-CRT scene effects deliberately distort the source coordinates, -// rather than remapping the final colors. Themes retain their palette and the -// effects remain composable with the linear-light CRT treatment below. -vec2 sceneUv(vec2 uv) { - if (u_crt.effects.y > 0.5) { - vec2 centered = uv - 0.5; - float radius = length(centered); - float envelope = 1.0 - smoothstep(0.04, 0.72, radius); - float wave = sin(radius * 42.0 - u_crt.time_seconds * 5.2); - uv += centered / max(radius, 0.00001) * wave * envelope * 0.0032; - } - if (u_crt.effects.z > 0.5) { - float band = floor(uv.y * 96.0); - float seed = noiseAt(vec2(band, floor(u_crt.time_seconds * 13.0))); - float tear = smoothstep(0.955, 1.0, seed); - uv.x += (noiseAt(vec2(seed, band)) - 0.5) * tear * 0.020; - } - return uv; -} - -// The scene, ALPHA INCLUDED: with a see-through ground the grid arrives -// premultiplied and partly empty, and a pass that dropped .a would turn the -// desktop behind a `*_transparent` theme into opaque black the moment Crt, -// Ripple or Glitch came on. -vec4 sceneSrgb(vec2 uv) { - vec2 warped = sceneUv(uv); - vec4 center = texture(u_scene, warped); - if (u_crt.effects.z <= 0.5) return center; - - float band = floor(warped.y * 96.0); - float seed = noiseAt(vec2(band, floor(u_crt.time_seconds * 13.0))); - float tear = smoothstep(0.955, 1.0, seed); - vec2 chroma = vec2((0.35 + 1.25 * tear) / u_crt.viewport.x, 0.0); - return vec4( - texture(u_scene, warped + chroma).r, - center.g, - texture(u_scene, warped - chroma).b, - center.a - ); -} - -vec3 effectSceneLinear(vec2 uv) { - return srgbToLinear(sceneSrgb(uv).rgb); -} - -void main() { - vec2 size = u_crt.viewport; - vec2 texel = 1.0 / size; - vec2 cc = gl_FragCoord.xy / size * 2.0 - 1.0; - float r2 = dot(cc, cc); - vec2 uv = cc * (1.0 + BARREL * r2 * u_crt.effects.x) * 0.5 + 0.5; - - // Ripple and glitch are useful without the CRT. They only move samples; - // skip every tube-specific light/mask/exposure adjustment. - if (u_crt.effects.x <= 0.5) { - // The scene texture is already sRGB. Avoid a needless transfer-curve - // round trip so a coordinate-only effect preserves theme bytes. - vec4 s = sceneSrgb(uv); - o_col = vec4(clamp(s.rgb, 0.0, 1.0), clamp(s.a, 0.0, 1.0)); - return; - } - - vec3 col = vec3(0.0); - // Outside the tube nothing is drawn, so nothing is covered either: the - // old opaque black there would have punched a rectangle out of the - // compositor's backdrop. - float alpha = 0.0; - float edge = min(min(uv.x, uv.y), min(1.0 - uv.x, 1.0 - uv.y)); - if (edge > 0.0) { - vec3 center = effectSceneLinear(uv); - vec2 chroma = cc * (CHROMA_PX * r2) * texel; - // Each radial sample follows the same ripple/glitch path from its own - // pre-warp coordinate. This is also how the Metal kernel composes the - // effects; sampling a once-warped center here made combined effects - // diverge between the two GUI backends. - col = vec3( - effectSceneLinear(uv + chroma).r, - center.g, - effectSceneLinear(uv - chroma).b - ); - - vec2 bloom_px = texel * BLOOM_RADIUS_PX; - vec3 bloom = brightPass(uv + vec2(bloom_px.x, 0.0)) + - brightPass(uv - vec2(bloom_px.x, 0.0)) + - brightPass(uv + vec2(0.0, bloom_px.y)) + - brightPass(uv - vec2(0.0, bloom_px.y)); - col += bloom * (0.25 * BLOOM_STRENGTH); - - // The mask has unit mean and the scan loss stays small; neither needs - // the old global lift that remapped every theme toward white. - float scan_wave = 0.5 + 0.5 * cos(gl_FragCoord.y * TAU / SCAN_PERIOD_PX); - col *= 1.0 - SCAN_STRENGTH * scan_wave * scan_wave; - vec3 mask = 1.0 + MASK_STRENGTH * cos( - gl_FragCoord.x * TAU / MASK_PERIOD_PX + vec3(0.0, TAU / 3.0, 2.0 * TAU / 3.0) - ); - col *= mask; - - vec2 edge_px = min(uv, 1.0 - uv) * size; - float tube = smoothstep(0.0, EDGE_SOFTNESS_PX, min(edge_px.x, edge_px.y)); - float vignette = 1.0 - VIGNETTE_STRENGTH * pow(clamp(r2 * 0.5, 0.0, 1.0), 1.6); - float hum = 1.0 + HUM_STRENGTH * sin((uv.y * 1.35 - u_crt.time_seconds * 0.11) * TAU); - col *= tube * vignette * hum * EXPOSURE; - // The tube's edge falloff dims coverage as well as colour; everything - // else here is a light adjustment and leaves it alone. - alpha = sceneSrgb(uv).a * tube; - - float luma = dot(col, vec3(0.2126, 0.7152, 0.0722)); - vec2 noise_seed = gl_FragCoord.xy + vec2(u_crt.frame * 17.0, u_crt.frame * 59.0); - col += (noiseAt(noise_seed) - 0.5) * NOISE_STRENGTH * (0.15 + 0.85 * clamp(luma, 0.0, 1.0)); - } - o_col = vec4(linearToSrgb(clamp(col, 0.0, 1.0)), clamp(alpha, 0.0, 1.0)); -} diff --git a/shaders/crt.vert.glsl b/shaders/crt.vert.glsl deleted file mode 100644 index 4e15fcdf..00000000 --- a/shaders/crt.vert.glsl +++ /dev/null @@ -1,9 +0,0 @@ -#version 450 - -// CRT post-process pass: one fullscreen triangle from gl_VertexIndex, no -// vertex buffer. The fragment shader derives uv from gl_FragCoord. - -void main() { - vec2 p = vec2(float((gl_VertexIndex << 1) & 2), float(gl_VertexIndex & 2)); - gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); -} diff --git a/shaders/post.vert.glsl b/shaders/post.vert.glsl new file mode 100644 index 00000000..86739a65 --- /dev/null +++ b/shaders/post.vert.glsl @@ -0,0 +1,10 @@ +#version 450 + +// A post pass (docs/render-pipeline.md §6): one fullscreen triangle from +// gl_VertexIndex, no vertex buffer. The Shadertoy fragment shader works from +// gl_FragCoord, y down, as its fragCoord. + +void main() { + vec2 p = vec2(float((gl_VertexIndex << 1) & 2), float(gl_VertexIndex & 2)); + gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); +} diff --git a/shaders/post/crt.glsl b/shaders/post/crt.glsl new file mode 100644 index 00000000..16a5cdd9 --- /dev/null +++ b/shaders/post/crt.glsl @@ -0,0 +1,79 @@ +// Crt: a slot-mask tube's light, with nothing that moves a pixel. There is +// no barrel: every click lands where it is drawn, rails and grips at the +// window's edges included. What is left is light: a beam profile for the +// scanlines, an aperture mask, a soft bloom of the brightest ink, a faint +// vignette and the hum of a live tube. Everything is worked out in linear +// light and dithered back into 8 bits, so the vignette never bands. It is a +// Shadertoy file: pardes puts its prefix before it (shaders/post/prefix.glsl). + +const float TAU = 6.28318530718; +const float CHROMA_PX = 0.35; // red and blue apart, at the corners +const float BLOOM_RADIUS_PX = 1.75; +const float BLOOM_THRESHOLD = 0.60; // linear-light channel peak +const float BLOOM_SOFTNESS = 0.25; +const float BLOOM_STRENGTH = 0.05; +const float SCAN_PERIOD_PX = 3.0; +const float SCAN_STRENGTH = 0.07; +const float MASK_PERIOD_PX = 3.0; +const float MASK_STRENGTH = 0.018; +const float VIGNETTE_STRENGTH = 0.12; // at the far corners only +const float HUM_STRENGTH = 0.004; +const float HUM_HZ = 0.11; +const float EXPOSURE = 1.015; + +vec3 toLinear(vec3 c) { + return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); +} + +vec3 toSrgb(vec3 c) { + c = max(c, vec3(0.0)); + return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308))); +} + +vec3 scene(vec2 uv) { + return toLinear(texture(iChannel0, uv).rgb); +} + +vec3 brightPass(vec2 uv) { + vec3 c = scene(uv); + float peak = max(c.r, max(c.g, c.b)); + return c * smoothstep(BLOOM_THRESHOLD, BLOOM_THRESHOLD + BLOOM_SOFTNESS, peak); +} + +// Interleaved gradient noise: a dither with no visible pattern. +float ign(vec2 p) { + return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 size = iResolution.xy; + vec2 texel = 1.0 / size; + vec2 uv = fragCoord / size; + vec2 cc = uv * 2.0 - 1.0; + float r2 = dot(cc, cc); + + // The beam converges least at the corners: red and blue drift apart + // there, by a third of a pixel. + vec2 chroma = cc * (CHROMA_PX * r2 * 0.5) * texel; + vec3 col = vec3(scene(uv + chroma).r, scene(uv).g, scene(uv - chroma).b); + + vec2 b = texel * BLOOM_RADIUS_PX; + vec3 bloom = brightPass(uv + vec2(b.x, 0.0)) + brightPass(uv - vec2(b.x, 0.0)) + + brightPass(uv + vec2(0.0, b.y)) + brightPass(uv - vec2(0.0, b.y)); + col += bloom * (0.25 * BLOOM_STRENGTH); + + // A beam is brightest on its line and falls off between lines; the + // mask is three phosphor stripes a triad. Both average to one, so the + // theme keeps its brightness. + float line = 0.5 + 0.5 * cos(fragCoord.y * TAU / SCAN_PERIOD_PX); + col *= 1.0 - SCAN_STRENGTH * (line * line - 0.375) / 0.625; + col *= 1.0 + MASK_STRENGTH * cos(fragCoord.x * TAU / MASK_PERIOD_PX + vec3(0.0, TAU / 3.0, 2.0 * TAU / 3.0)); + + float vignette = 1.0 - VIGNETTE_STRENGTH * pow(clamp(r2 * 0.5, 0.0, 1.0), 1.6); + float hum = 1.0 + HUM_STRENGTH * sin((uv.y * 1.35 - iTime * HUM_HZ) * TAU); + col *= vignette * hum * EXPOSURE; + + vec3 srgb = toSrgb(col); + srgb += (ign(fragCoord + float(iFrame % 64) * vec2(5.588238, 5.588238)) - 0.5) / 255.0; + fragColor = vec4(clamp(srgb, 0.0, 1.0), texture(iChannel0, uv).a); +} diff --git a/shaders/post/glitch.glsl b/shaders/post/glitch.glsl new file mode 100644 index 00000000..ff9ebcb8 --- /dev/null +++ b/shaders/post/glitch.glsl @@ -0,0 +1,60 @@ +// Glitch: now and then the signal slips. Every couple of seconds a short +// burst tears a few horizontal bands sideways and pulls their red and blue +// apart; the bands are sized in pixels and the burst eases in and out over +// a fraction of a second, so it reads as one event rather than a flicker, +// and it looks the same at 60 or 144 Hz because every step is keyed to +// iTime, not to frames. Between bursts the page is left exactly as it is. +// A torn band's brightness dips in linear light. It is a Shadertoy file: +// pardes puts its prefix before it. + +const float PERIOD = 2.4; // seconds between bursts, on average +const float BURST = 0.28; // seconds a burst lasts +const float STEP_HZ = 24.0; // how often a burst re-tears +const float SHIFT_PX = 14.0; +const float SPLIT_PX = 2.5; +const float DIP = 0.12; + +float hash(vec2 p) { + vec3 q = fract(vec3(p.xyx) * 0.1031); + q += dot(q, q.yzx + 33.33); + return fract((q.x + q.y) * q.z); +} + +vec3 toLinear(vec3 c) { + return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); +} + +vec3 toSrgb(vec3 c) { + c = max(c, vec3(0.0)); + return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308))); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 size = iResolution.xy; + vec2 uv = fragCoord / size; + // Which burst window this is, when in it the burst starts, and how far + // into the burst we are: 0 outside one. + float window = floor(iTime / PERIOD); + float start = window * PERIOD + hash(vec2(window, 7.0)) * (PERIOD - BURST); + float t = (iTime - start) / BURST; + float live = step(0.0, t) * step(t, 1.0); + float envelope = live * sin(3.14159265 * clamp(t, 0.0, 1.0)); + if (envelope <= 0.0) { + fragColor = texture(iChannel0, uv); + return; + } + // Bands of 6 to 40 px, re-cut at STEP_HZ during the burst; about one + // band in four tears. + float tick = floor(iTime * STEP_HZ); + float band_px = mix(6.0, 40.0, hash(vec2(tick, 3.0))); + float band = floor(fragCoord.y / band_px); + float roll = hash(vec2(band, tick)); + float tear = smoothstep(0.72, 0.9, roll) * envelope; + float shift = (hash(vec2(tick, band + 11.0)) - 0.5) * 2.0 * SHIFT_PX * tear; + vec2 at = vec2(fragCoord.x + shift, fragCoord.y); + vec2 split = vec2(SPLIT_PX * tear, 0.0); + vec4 center = texture(iChannel0, at / size); + vec3 col = vec3(texture(iChannel0, (at + split) / size).r, center.g, texture(iChannel0, (at - split) / size).b); + col = toSrgb(toLinear(col) * (1.0 - DIP * tear)); + fragColor = vec4(col, center.a); +} diff --git a/shaders/post/prefix.glsl b/shaders/post/prefix.glsl new file mode 100644 index 00000000..efa49483 --- /dev/null +++ b/shaders/post/prefix.glsl @@ -0,0 +1,67 @@ +#version 450 +// pardes's Shadertoy prefix (docs/render-pipeline.md §6), put before every +// post pass: a bundled one in shaders/post/ at build time, a user's file at +// run time. The uniform names, types and std140 order are ghostty 1.3.2's +// (src/renderer/shaders/shadertoy_prefix.glsl), so a shader written for +// ghostty runs here; only the bindings are SDL GPU's (a fragment shader's +// textures in set 2, its uniform blocks in set 3). Y is down everywhere, as +// on ghostty's Metal renderer: fragCoord, iChannel0's uv and the cursor +// rectangles share one top-left space, and iCurrentCursor is (left x, +// BOTTOM-edge y, width, height) in framebuffer pixels. + +layout(set = 3, binding = 0, std140) uniform Globals { + uniform vec3 iResolution; + uniform float iTime; + uniform float iTimeDelta; + uniform float iFrameRate; + uniform int iFrame; + uniform float iChannelTime[4]; + uniform vec3 iChannelResolution[4]; + uniform vec4 iMouse; + uniform vec4 iDate; + uniform float iSampleRate; + uniform vec4 iCurrentCursor; + uniform vec4 iPreviousCursor; + uniform vec4 iCurrentCursorColor; + uniform vec4 iPreviousCursorColor; + uniform int iCurrentCursorStyle; + uniform int iPreviousCursorStyle; + uniform int iCursorVisible; + uniform float iTimeCursorChange; + uniform float iTimeFocus; + uniform int iFocus; + uniform vec3 iPalette[256]; + uniform vec3 iBackgroundColor; + uniform vec3 iForegroundColor; + uniform vec3 iCursorColor; + uniform vec3 iCursorText; + uniform vec3 iSelectionForegroundColor; + uniform vec3 iSelectionBackgroundColor; +}; + +// pardes's own: 1 when the window is opaque, so a pass cannot punch holes +// in it through its alpha. +layout(set = 3, binding = 1, std140) uniform PardesPost { + float pardesOpaque; +}; + +#define CURSORSTYLE_BLOCK 0 +#define CURSORSTYLE_BLOCK_HOLLOW 1 +#define CURSORSTYLE_BAR 2 +#define CURSORSTYLE_UNDERLINE 3 +#define CURSORSTYLE_LOCK 4 + +layout(set = 2, binding = 0) uniform sampler2D iChannel0; + +layout(location = 0) out vec4 _fragColor; + +#define texture2D texture + +void mainImage(out vec4 fragColor, in vec2 fragCoord); +void main() { + mainImage(_fragColor, gl_FragCoord.xy); + if (pardesOpaque > 0.5) _fragColor.a = 1.0; +} + +// The shader's own lines count from 1, so glslc's errors name them. +#line 1 diff --git a/shaders/post/ripple.glsl b/shaders/post/ripple.glsl new file mode 100644 index 00000000..ffecbf70 --- /dev/null +++ b/shaders/post/ripple.glsl @@ -0,0 +1,49 @@ +// Ripple: the page as a still pool a slow swell crosses. Rings travel out +// from the middle of the window at a fixed speed in pixels, so they look +// the same at any size and frame rate; the displacement is at most a couple +// of pixels, so text stays readable, and it eases in over its first half +// second instead of snapping on. Each ring catches the light a little on its +// rising side, in linear light, and the result is dithered so the shading +// never bands. It is a Shadertoy file: pardes puts its prefix before it. + +const float TAU = 6.28318530718; +const float WAVELENGTH_PX = 150.0; +const float SPEED_PX = 55.0; // outward, per second +const float AMPLITUDE_PX = 2.2; +const float SHADE = 0.035; // light on a slope, linear +const float EASE_IN = 0.5; // seconds + +vec3 toLinear(vec3 c) { + return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); +} + +vec3 toSrgb(vec3 c) { + c = max(c, vec3(0.0)); + return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308))); +} + +float ign(vec2 p) { + return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 size = iResolution.xy; + vec2 center = 0.5 * size; + vec2 offset = fragCoord - center; + float radius = length(offset); + float reach = 0.5 * length(size); + // Strongest a third of the way out, fading to nothing at the middle + // and at the edges, so the window's frame never wobbles. + float envelope = smoothstep(0.0, 0.18, radius / reach) * (1.0 - smoothstep(0.35, 0.95, radius / reach)); + float ease = smoothstep(0.0, EASE_IN, iTime); + float phase = (radius - iTime * SPEED_PX) * TAU / WAVELENGTH_PX; + float height = sin(phase); + float slope = cos(phase); + vec2 dir = offset / max(radius, 1e-3); + vec2 moved = fragCoord + dir * height * AMPLITUDE_PX * envelope * ease; + + vec4 s = texture(iChannel0, moved / size); + vec3 col = toLinear(s.rgb) * (1.0 + SHADE * slope * envelope * ease); + vec3 srgb = toSrgb(col) + (ign(fragCoord + float(iFrame % 64) * 5.588238) - 0.5) / 255.0; + fragColor = vec4(clamp(srgb, 0.0, 1.0), s.a); +} diff --git a/shaders/prebuilt/crt.frag.glsl b/shaders/prebuilt/crt.frag.glsl deleted file mode 100644 index b0b0b93b..00000000 --- a/shaders/prebuilt/crt.frag.glsl +++ /dev/null @@ -1,175 +0,0 @@ -#version 450 - -// The Crt builtin's post-process. Screen point cc shows the scene at -// uv' = cc*(1 + BARREL*r2), and src/gui/crt.zig maps mouse input through that -// same transform — keep them equal or clicks drift near the edges. - -layout(set = 2, binding = 0) uniform sampler2D u_scene; -layout(std140, set = 3, binding = 0) uniform CrtUniforms { - float time_seconds; - float frame; - vec2 viewport; - // x = CRT, y = ripple, z = glitch. One scene pass can combine them. - vec4 effects; -} u_crt; - -layout(location = 0) out vec4 o_col; - -const float TAU = 6.28318530718; -const float BARREL = 0.016; // MUST match src/gui/crt.zig barrel -const float EDGE_SOFTNESS_PX = 3.5; -const float CHROMA_PX = 0.28; // radial R/B separation in physical pixels at r2=1 -const float BLOOM_RADIUS_PX = 1.75; -const float BLOOM_THRESHOLD = 0.60; // linear-light channel peak -const float BLOOM_SOFTNESS = 0.25; -const float BLOOM_STRENGTH = 0.045; -const float SCAN_PERIOD_PX = 3.0; -const float SCAN_STRENGTH = 0.055; -const float MASK_PERIOD_PX = 3.0; -const float MASK_STRENGTH = 0.015; -const float VIGNETTE_STRENGTH = 0.11; // 11% only at the extreme corners -const float HUM_STRENGTH = 0.0025; -const float NOISE_STRENGTH = 0.0020; -const float EXPOSURE = 1.012; - -vec3 srgbToLinear(vec3 c) { - vec3 low = c / 12.92; - vec3 high = pow((c + 0.055) / 1.055, vec3(2.4)); - return mix(high, low, lessThanEqual(c, vec3(0.04045))); -} - -vec3 linearToSrgb(vec3 c) { - c = max(c, vec3(0.0)); - vec3 low = c * 12.92; - vec3 high = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055; - return mix(high, low, lessThanEqual(c, vec3(0.0031308))); -} - -vec3 sceneLinear(vec2 uv) { - return srgbToLinear(texture(u_scene, uv).rgb); -} - -vec3 brightPass(vec2 uv) { - vec3 c = sceneLinear(uv); - float peak = max(c.r, max(c.g, c.b)); - return c * smoothstep(BLOOM_THRESHOLD, BLOOM_THRESHOLD + BLOOM_SOFTNESS, peak); -} - -float noiseAt(vec2 p) { - vec3 q = fract(vec3(p.xyx) * 0.1031); - q += dot(q, q.yzx + 33.33); - return fract((q.x + q.y) * q.z); -} - -// The non-CRT scene effects deliberately distort the source coordinates, -// rather than remapping the final colors. Themes retain their palette and the -// effects remain composable with the linear-light CRT treatment below. -vec2 sceneUv(vec2 uv) { - if (u_crt.effects.y > 0.5) { - vec2 centered = uv - 0.5; - float radius = length(centered); - float envelope = 1.0 - smoothstep(0.04, 0.72, radius); - float wave = sin(radius * 42.0 - u_crt.time_seconds * 5.2); - uv += centered / max(radius, 0.00001) * wave * envelope * 0.0032; - } - if (u_crt.effects.z > 0.5) { - float band = floor(uv.y * 96.0); - float seed = noiseAt(vec2(band, floor(u_crt.time_seconds * 13.0))); - float tear = smoothstep(0.955, 1.0, seed); - uv.x += (noiseAt(vec2(seed, band)) - 0.5) * tear * 0.020; - } - return uv; -} - -// The scene, ALPHA INCLUDED: with a see-through ground the grid arrives -// premultiplied and partly empty, and a pass that dropped .a would turn the -// desktop behind a `*_transparent` theme into opaque black the moment Crt, -// Ripple or Glitch came on. -vec4 sceneSrgb(vec2 uv) { - vec2 warped = sceneUv(uv); - vec4 center = texture(u_scene, warped); - if (u_crt.effects.z <= 0.5) return center; - - float band = floor(warped.y * 96.0); - float seed = noiseAt(vec2(band, floor(u_crt.time_seconds * 13.0))); - float tear = smoothstep(0.955, 1.0, seed); - vec2 chroma = vec2((0.35 + 1.25 * tear) / u_crt.viewport.x, 0.0); - return vec4( - texture(u_scene, warped + chroma).r, - center.g, - texture(u_scene, warped - chroma).b, - center.a - ); -} - -vec3 effectSceneLinear(vec2 uv) { - return srgbToLinear(sceneSrgb(uv).rgb); -} - -void main() { - vec2 size = u_crt.viewport; - vec2 texel = 1.0 / size; - vec2 cc = gl_FragCoord.xy / size * 2.0 - 1.0; - float r2 = dot(cc, cc); - vec2 uv = cc * (1.0 + BARREL * r2 * u_crt.effects.x) * 0.5 + 0.5; - - // Ripple and glitch are useful without the CRT. They only move samples; - // skip every tube-specific light/mask/exposure adjustment. - if (u_crt.effects.x <= 0.5) { - // The scene texture is already sRGB. Avoid a needless transfer-curve - // round trip so a coordinate-only effect preserves theme bytes. - vec4 s = sceneSrgb(uv); - o_col = vec4(clamp(s.rgb, 0.0, 1.0), clamp(s.a, 0.0, 1.0)); - return; - } - - vec3 col = vec3(0.0); - // Outside the tube nothing is drawn, so nothing is covered either: the - // old opaque black there would have punched a rectangle out of the - // compositor's backdrop. - float alpha = 0.0; - float edge = min(min(uv.x, uv.y), min(1.0 - uv.x, 1.0 - uv.y)); - if (edge > 0.0) { - vec3 center = effectSceneLinear(uv); - vec2 chroma = cc * (CHROMA_PX * r2) * texel; - // Each radial sample follows the same ripple/glitch path from its own - // pre-warp coordinate. This is also how the Metal kernel composes the - // effects; sampling a once-warped center here made combined effects - // diverge between the two GUI backends. - col = vec3( - effectSceneLinear(uv + chroma).r, - center.g, - effectSceneLinear(uv - chroma).b - ); - - vec2 bloom_px = texel * BLOOM_RADIUS_PX; - vec3 bloom = brightPass(uv + vec2(bloom_px.x, 0.0)) + - brightPass(uv - vec2(bloom_px.x, 0.0)) + - brightPass(uv + vec2(0.0, bloom_px.y)) + - brightPass(uv - vec2(0.0, bloom_px.y)); - col += bloom * (0.25 * BLOOM_STRENGTH); - - // The mask has unit mean and the scan loss stays small; neither needs - // the old global lift that remapped every theme toward white. - float scan_wave = 0.5 + 0.5 * cos(gl_FragCoord.y * TAU / SCAN_PERIOD_PX); - col *= 1.0 - SCAN_STRENGTH * scan_wave * scan_wave; - vec3 mask = 1.0 + MASK_STRENGTH * cos( - gl_FragCoord.x * TAU / MASK_PERIOD_PX + vec3(0.0, TAU / 3.0, 2.0 * TAU / 3.0) - ); - col *= mask; - - vec2 edge_px = min(uv, 1.0 - uv) * size; - float tube = smoothstep(0.0, EDGE_SOFTNESS_PX, min(edge_px.x, edge_px.y)); - float vignette = 1.0 - VIGNETTE_STRENGTH * pow(clamp(r2 * 0.5, 0.0, 1.0), 1.6); - float hum = 1.0 + HUM_STRENGTH * sin((uv.y * 1.35 - u_crt.time_seconds * 0.11) * TAU); - col *= tube * vignette * hum * EXPOSURE; - // The tube's edge falloff dims coverage as well as colour; everything - // else here is a light adjustment and leaves it alone. - alpha = sceneSrgb(uv).a * tube; - - float luma = dot(col, vec3(0.2126, 0.7152, 0.0722)); - vec2 noise_seed = gl_FragCoord.xy + vec2(u_crt.frame * 17.0, u_crt.frame * 59.0); - col += (noiseAt(noise_seed) - 0.5) * NOISE_STRENGTH * (0.15 + 0.85 * clamp(luma, 0.0, 1.0)); - } - o_col = vec4(linearToSrgb(clamp(col, 0.0, 1.0)), clamp(alpha, 0.0, 1.0)); -} diff --git a/shaders/prebuilt/crt.frag.spv b/shaders/prebuilt/crt.frag.spv deleted file mode 100644 index f0e14627..00000000 Binary files a/shaders/prebuilt/crt.frag.spv and /dev/null differ diff --git a/shaders/prebuilt/crt.vert.glsl b/shaders/prebuilt/crt.vert.glsl deleted file mode 100644 index 4e15fcdf..00000000 --- a/shaders/prebuilt/crt.vert.glsl +++ /dev/null @@ -1,9 +0,0 @@ -#version 450 - -// CRT post-process pass: one fullscreen triangle from gl_VertexIndex, no -// vertex buffer. The fragment shader derives uv from gl_FragCoord. - -void main() { - vec2 p = vec2(float((gl_VertexIndex << 1) & 2), float(gl_VertexIndex & 2)); - gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); -} diff --git a/shaders/prebuilt/crt.vert.spv b/shaders/prebuilt/crt.vert.spv deleted file mode 100644 index 8a75ebb3..00000000 Binary files a/shaders/prebuilt/crt.vert.spv and /dev/null differ diff --git a/shaders/prebuilt/post-crt.frag.glsl b/shaders/prebuilt/post-crt.frag.glsl new file mode 100644 index 00000000..11b95272 --- /dev/null +++ b/shaders/prebuilt/post-crt.frag.glsl @@ -0,0 +1,146 @@ +#version 450 +// pardes's Shadertoy prefix (docs/render-pipeline.md §6), put before every +// post pass: a bundled one in shaders/post/ at build time, a user's file at +// run time. The uniform names, types and std140 order are ghostty 1.3.2's +// (src/renderer/shaders/shadertoy_prefix.glsl), so a shader written for +// ghostty runs here; only the bindings are SDL GPU's (a fragment shader's +// textures in set 2, its uniform blocks in set 3). Y is down everywhere, as +// on ghostty's Metal renderer: fragCoord, iChannel0's uv and the cursor +// rectangles share one top-left space, and iCurrentCursor is (left x, +// BOTTOM-edge y, width, height) in framebuffer pixels. + +layout(set = 3, binding = 0, std140) uniform Globals { + uniform vec3 iResolution; + uniform float iTime; + uniform float iTimeDelta; + uniform float iFrameRate; + uniform int iFrame; + uniform float iChannelTime[4]; + uniform vec3 iChannelResolution[4]; + uniform vec4 iMouse; + uniform vec4 iDate; + uniform float iSampleRate; + uniform vec4 iCurrentCursor; + uniform vec4 iPreviousCursor; + uniform vec4 iCurrentCursorColor; + uniform vec4 iPreviousCursorColor; + uniform int iCurrentCursorStyle; + uniform int iPreviousCursorStyle; + uniform int iCursorVisible; + uniform float iTimeCursorChange; + uniform float iTimeFocus; + uniform int iFocus; + uniform vec3 iPalette[256]; + uniform vec3 iBackgroundColor; + uniform vec3 iForegroundColor; + uniform vec3 iCursorColor; + uniform vec3 iCursorText; + uniform vec3 iSelectionForegroundColor; + uniform vec3 iSelectionBackgroundColor; +}; + +// pardes's own: 1 when the window is opaque, so a pass cannot punch holes +// in it through its alpha. +layout(set = 3, binding = 1, std140) uniform PardesPost { + float pardesOpaque; +}; + +#define CURSORSTYLE_BLOCK 0 +#define CURSORSTYLE_BLOCK_HOLLOW 1 +#define CURSORSTYLE_BAR 2 +#define CURSORSTYLE_UNDERLINE 3 +#define CURSORSTYLE_LOCK 4 + +layout(set = 2, binding = 0) uniform sampler2D iChannel0; + +layout(location = 0) out vec4 _fragColor; + +#define texture2D texture + +void mainImage(out vec4 fragColor, in vec2 fragCoord); +void main() { + mainImage(_fragColor, gl_FragCoord.xy); + if (pardesOpaque > 0.5) _fragColor.a = 1.0; +} + +// The shader's own lines count from 1, so glslc's errors name them. +#line 1 +// Crt: a slot-mask tube's light, with nothing that moves a pixel. There is +// no barrel: every click lands where it is drawn, rails and grips at the +// window's edges included. What is left is light: a beam profile for the +// scanlines, an aperture mask, a soft bloom of the brightest ink, a faint +// vignette and the hum of a live tube. Everything is worked out in linear +// light and dithered back into 8 bits, so the vignette never bands. It is a +// Shadertoy file: pardes puts its prefix before it (shaders/post/prefix.glsl). + +const float TAU = 6.28318530718; +const float CHROMA_PX = 0.35; // red and blue apart, at the corners +const float BLOOM_RADIUS_PX = 1.75; +const float BLOOM_THRESHOLD = 0.60; // linear-light channel peak +const float BLOOM_SOFTNESS = 0.25; +const float BLOOM_STRENGTH = 0.05; +const float SCAN_PERIOD_PX = 3.0; +const float SCAN_STRENGTH = 0.07; +const float MASK_PERIOD_PX = 3.0; +const float MASK_STRENGTH = 0.018; +const float VIGNETTE_STRENGTH = 0.12; // at the far corners only +const float HUM_STRENGTH = 0.004; +const float HUM_HZ = 0.11; +const float EXPOSURE = 1.015; + +vec3 toLinear(vec3 c) { + return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); +} + +vec3 toSrgb(vec3 c) { + c = max(c, vec3(0.0)); + return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308))); +} + +vec3 scene(vec2 uv) { + return toLinear(texture(iChannel0, uv).rgb); +} + +vec3 brightPass(vec2 uv) { + vec3 c = scene(uv); + float peak = max(c.r, max(c.g, c.b)); + return c * smoothstep(BLOOM_THRESHOLD, BLOOM_THRESHOLD + BLOOM_SOFTNESS, peak); +} + +// Interleaved gradient noise: a dither with no visible pattern. +float ign(vec2 p) { + return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 size = iResolution.xy; + vec2 texel = 1.0 / size; + vec2 uv = fragCoord / size; + vec2 cc = uv * 2.0 - 1.0; + float r2 = dot(cc, cc); + + // The beam converges least at the corners: red and blue drift apart + // there, by a third of a pixel. + vec2 chroma = cc * (CHROMA_PX * r2 * 0.5) * texel; + vec3 col = vec3(scene(uv + chroma).r, scene(uv).g, scene(uv - chroma).b); + + vec2 b = texel * BLOOM_RADIUS_PX; + vec3 bloom = brightPass(uv + vec2(b.x, 0.0)) + brightPass(uv - vec2(b.x, 0.0)) + + brightPass(uv + vec2(0.0, b.y)) + brightPass(uv - vec2(0.0, b.y)); + col += bloom * (0.25 * BLOOM_STRENGTH); + + // A beam is brightest on its line and falls off between lines; the + // mask is three phosphor stripes a triad. Both average to one, so the + // theme keeps its brightness. + float line = 0.5 + 0.5 * cos(fragCoord.y * TAU / SCAN_PERIOD_PX); + col *= 1.0 - SCAN_STRENGTH * (line * line - 0.375) / 0.625; + col *= 1.0 + MASK_STRENGTH * cos(fragCoord.x * TAU / MASK_PERIOD_PX + vec3(0.0, TAU / 3.0, 2.0 * TAU / 3.0)); + + float vignette = 1.0 - VIGNETTE_STRENGTH * pow(clamp(r2 * 0.5, 0.0, 1.0), 1.6); + float hum = 1.0 + HUM_STRENGTH * sin((uv.y * 1.35 - iTime * HUM_HZ) * TAU); + col *= vignette * hum * EXPOSURE; + + vec3 srgb = toSrgb(col); + srgb += (ign(fragCoord + float(iFrame % 64) * vec2(5.588238, 5.588238)) - 0.5) / 255.0; + fragColor = vec4(clamp(srgb, 0.0, 1.0), texture(iChannel0, uv).a); +} diff --git a/shaders/prebuilt/post-crt.frag.spv b/shaders/prebuilt/post-crt.frag.spv new file mode 100644 index 00000000..e4d98138 Binary files /dev/null and b/shaders/prebuilt/post-crt.frag.spv differ diff --git a/shaders/prebuilt/post-glitch.frag.glsl b/shaders/prebuilt/post-glitch.frag.glsl new file mode 100644 index 00000000..46538452 --- /dev/null +++ b/shaders/prebuilt/post-glitch.frag.glsl @@ -0,0 +1,127 @@ +#version 450 +// pardes's Shadertoy prefix (docs/render-pipeline.md §6), put before every +// post pass: a bundled one in shaders/post/ at build time, a user's file at +// run time. The uniform names, types and std140 order are ghostty 1.3.2's +// (src/renderer/shaders/shadertoy_prefix.glsl), so a shader written for +// ghostty runs here; only the bindings are SDL GPU's (a fragment shader's +// textures in set 2, its uniform blocks in set 3). Y is down everywhere, as +// on ghostty's Metal renderer: fragCoord, iChannel0's uv and the cursor +// rectangles share one top-left space, and iCurrentCursor is (left x, +// BOTTOM-edge y, width, height) in framebuffer pixels. + +layout(set = 3, binding = 0, std140) uniform Globals { + uniform vec3 iResolution; + uniform float iTime; + uniform float iTimeDelta; + uniform float iFrameRate; + uniform int iFrame; + uniform float iChannelTime[4]; + uniform vec3 iChannelResolution[4]; + uniform vec4 iMouse; + uniform vec4 iDate; + uniform float iSampleRate; + uniform vec4 iCurrentCursor; + uniform vec4 iPreviousCursor; + uniform vec4 iCurrentCursorColor; + uniform vec4 iPreviousCursorColor; + uniform int iCurrentCursorStyle; + uniform int iPreviousCursorStyle; + uniform int iCursorVisible; + uniform float iTimeCursorChange; + uniform float iTimeFocus; + uniform int iFocus; + uniform vec3 iPalette[256]; + uniform vec3 iBackgroundColor; + uniform vec3 iForegroundColor; + uniform vec3 iCursorColor; + uniform vec3 iCursorText; + uniform vec3 iSelectionForegroundColor; + uniform vec3 iSelectionBackgroundColor; +}; + +// pardes's own: 1 when the window is opaque, so a pass cannot punch holes +// in it through its alpha. +layout(set = 3, binding = 1, std140) uniform PardesPost { + float pardesOpaque; +}; + +#define CURSORSTYLE_BLOCK 0 +#define CURSORSTYLE_BLOCK_HOLLOW 1 +#define CURSORSTYLE_BAR 2 +#define CURSORSTYLE_UNDERLINE 3 +#define CURSORSTYLE_LOCK 4 + +layout(set = 2, binding = 0) uniform sampler2D iChannel0; + +layout(location = 0) out vec4 _fragColor; + +#define texture2D texture + +void mainImage(out vec4 fragColor, in vec2 fragCoord); +void main() { + mainImage(_fragColor, gl_FragCoord.xy); + if (pardesOpaque > 0.5) _fragColor.a = 1.0; +} + +// The shader's own lines count from 1, so glslc's errors name them. +#line 1 +// Glitch: now and then the signal slips. Every couple of seconds a short +// burst tears a few horizontal bands sideways and pulls their red and blue +// apart; the bands are sized in pixels and the burst eases in and out over +// a fraction of a second, so it reads as one event rather than a flicker, +// and it looks the same at 60 or 144 Hz because every step is keyed to +// iTime, not to frames. Between bursts the page is left exactly as it is. +// A torn band's brightness dips in linear light. It is a Shadertoy file: +// pardes puts its prefix before it. + +const float PERIOD = 2.4; // seconds between bursts, on average +const float BURST = 0.28; // seconds a burst lasts +const float STEP_HZ = 24.0; // how often a burst re-tears +const float SHIFT_PX = 14.0; +const float SPLIT_PX = 2.5; +const float DIP = 0.12; + +float hash(vec2 p) { + vec3 q = fract(vec3(p.xyx) * 0.1031); + q += dot(q, q.yzx + 33.33); + return fract((q.x + q.y) * q.z); +} + +vec3 toLinear(vec3 c) { + return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); +} + +vec3 toSrgb(vec3 c) { + c = max(c, vec3(0.0)); + return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308))); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 size = iResolution.xy; + vec2 uv = fragCoord / size; + // Which burst window this is, when in it the burst starts, and how far + // into the burst we are: 0 outside one. + float window = floor(iTime / PERIOD); + float start = window * PERIOD + hash(vec2(window, 7.0)) * (PERIOD - BURST); + float t = (iTime - start) / BURST; + float live = step(0.0, t) * step(t, 1.0); + float envelope = live * sin(3.14159265 * clamp(t, 0.0, 1.0)); + if (envelope <= 0.0) { + fragColor = texture(iChannel0, uv); + return; + } + // Bands of 6 to 40 px, re-cut at STEP_HZ during the burst; about one + // band in four tears. + float tick = floor(iTime * STEP_HZ); + float band_px = mix(6.0, 40.0, hash(vec2(tick, 3.0))); + float band = floor(fragCoord.y / band_px); + float roll = hash(vec2(band, tick)); + float tear = smoothstep(0.72, 0.9, roll) * envelope; + float shift = (hash(vec2(tick, band + 11.0)) - 0.5) * 2.0 * SHIFT_PX * tear; + vec2 at = vec2(fragCoord.x + shift, fragCoord.y); + vec2 split = vec2(SPLIT_PX * tear, 0.0); + vec4 center = texture(iChannel0, at / size); + vec3 col = vec3(texture(iChannel0, (at + split) / size).r, center.g, texture(iChannel0, (at - split) / size).b); + col = toSrgb(toLinear(col) * (1.0 - DIP * tear)); + fragColor = vec4(col, center.a); +} diff --git a/shaders/prebuilt/post-glitch.frag.spv b/shaders/prebuilt/post-glitch.frag.spv new file mode 100644 index 00000000..8a224958 Binary files /dev/null and b/shaders/prebuilt/post-glitch.frag.spv differ diff --git a/shaders/prebuilt/post-ripple.frag.glsl b/shaders/prebuilt/post-ripple.frag.glsl new file mode 100644 index 00000000..28059def --- /dev/null +++ b/shaders/prebuilt/post-ripple.frag.glsl @@ -0,0 +1,116 @@ +#version 450 +// pardes's Shadertoy prefix (docs/render-pipeline.md §6), put before every +// post pass: a bundled one in shaders/post/ at build time, a user's file at +// run time. The uniform names, types and std140 order are ghostty 1.3.2's +// (src/renderer/shaders/shadertoy_prefix.glsl), so a shader written for +// ghostty runs here; only the bindings are SDL GPU's (a fragment shader's +// textures in set 2, its uniform blocks in set 3). Y is down everywhere, as +// on ghostty's Metal renderer: fragCoord, iChannel0's uv and the cursor +// rectangles share one top-left space, and iCurrentCursor is (left x, +// BOTTOM-edge y, width, height) in framebuffer pixels. + +layout(set = 3, binding = 0, std140) uniform Globals { + uniform vec3 iResolution; + uniform float iTime; + uniform float iTimeDelta; + uniform float iFrameRate; + uniform int iFrame; + uniform float iChannelTime[4]; + uniform vec3 iChannelResolution[4]; + uniform vec4 iMouse; + uniform vec4 iDate; + uniform float iSampleRate; + uniform vec4 iCurrentCursor; + uniform vec4 iPreviousCursor; + uniform vec4 iCurrentCursorColor; + uniform vec4 iPreviousCursorColor; + uniform int iCurrentCursorStyle; + uniform int iPreviousCursorStyle; + uniform int iCursorVisible; + uniform float iTimeCursorChange; + uniform float iTimeFocus; + uniform int iFocus; + uniform vec3 iPalette[256]; + uniform vec3 iBackgroundColor; + uniform vec3 iForegroundColor; + uniform vec3 iCursorColor; + uniform vec3 iCursorText; + uniform vec3 iSelectionForegroundColor; + uniform vec3 iSelectionBackgroundColor; +}; + +// pardes's own: 1 when the window is opaque, so a pass cannot punch holes +// in it through its alpha. +layout(set = 3, binding = 1, std140) uniform PardesPost { + float pardesOpaque; +}; + +#define CURSORSTYLE_BLOCK 0 +#define CURSORSTYLE_BLOCK_HOLLOW 1 +#define CURSORSTYLE_BAR 2 +#define CURSORSTYLE_UNDERLINE 3 +#define CURSORSTYLE_LOCK 4 + +layout(set = 2, binding = 0) uniform sampler2D iChannel0; + +layout(location = 0) out vec4 _fragColor; + +#define texture2D texture + +void mainImage(out vec4 fragColor, in vec2 fragCoord); +void main() { + mainImage(_fragColor, gl_FragCoord.xy); + if (pardesOpaque > 0.5) _fragColor.a = 1.0; +} + +// The shader's own lines count from 1, so glslc's errors name them. +#line 1 +// Ripple: the page as a still pool a slow swell crosses. Rings travel out +// from the middle of the window at a fixed speed in pixels, so they look +// the same at any size and frame rate; the displacement is at most a couple +// of pixels, so text stays readable, and it eases in over its first half +// second instead of snapping on. Each ring catches the light a little on its +// rising side, in linear light, and the result is dithered so the shading +// never bands. It is a Shadertoy file: pardes puts its prefix before it. + +const float TAU = 6.28318530718; +const float WAVELENGTH_PX = 150.0; +const float SPEED_PX = 55.0; // outward, per second +const float AMPLITUDE_PX = 2.2; +const float SHADE = 0.035; // light on a slope, linear +const float EASE_IN = 0.5; // seconds + +vec3 toLinear(vec3 c) { + return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); +} + +vec3 toSrgb(vec3 c) { + c = max(c, vec3(0.0)); + return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308))); +} + +float ign(vec2 p) { + return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 size = iResolution.xy; + vec2 center = 0.5 * size; + vec2 offset = fragCoord - center; + float radius = length(offset); + float reach = 0.5 * length(size); + // Strongest a third of the way out, fading to nothing at the middle + // and at the edges, so the window's frame never wobbles. + float envelope = smoothstep(0.0, 0.18, radius / reach) * (1.0 - smoothstep(0.35, 0.95, radius / reach)); + float ease = smoothstep(0.0, EASE_IN, iTime); + float phase = (radius - iTime * SPEED_PX) * TAU / WAVELENGTH_PX; + float height = sin(phase); + float slope = cos(phase); + vec2 dir = offset / max(radius, 1e-3); + vec2 moved = fragCoord + dir * height * AMPLITUDE_PX * envelope * ease; + + vec4 s = texture(iChannel0, moved / size); + vec3 col = toLinear(s.rgb) * (1.0 + SHADE * slope * envelope * ease); + vec3 srgb = toSrgb(col) + (ign(fragCoord + float(iFrame % 64) * 5.588238) - 0.5) / 255.0; + fragColor = vec4(clamp(srgb, 0.0, 1.0), s.a); +} diff --git a/shaders/prebuilt/post-ripple.frag.spv b/shaders/prebuilt/post-ripple.frag.spv new file mode 100644 index 00000000..1a105a33 Binary files /dev/null and b/shaders/prebuilt/post-ripple.frag.spv differ diff --git a/shaders/prebuilt/post.vert.glsl b/shaders/prebuilt/post.vert.glsl new file mode 100644 index 00000000..86739a65 --- /dev/null +++ b/shaders/prebuilt/post.vert.glsl @@ -0,0 +1,10 @@ +#version 450 + +// A post pass (docs/render-pipeline.md §6): one fullscreen triangle from +// gl_VertexIndex, no vertex buffer. The Shadertoy fragment shader works from +// gl_FragCoord, y down, as its fragCoord. + +void main() { + vec2 p = vec2(float((gl_VertexIndex << 1) & 2), float(gl_VertexIndex & 2)); + gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); +} diff --git a/shaders/prebuilt/post.vert.spv b/shaders/prebuilt/post.vert.spv new file mode 100644 index 00000000..8a75ebb3 Binary files /dev/null and b/shaders/prebuilt/post.vert.spv differ diff --git a/src/animation.zig b/src/animation.zig index d6ef1ec3..948a6679 100644 --- a/src/animation.zig +++ b/src/animation.zig @@ -77,12 +77,6 @@ pub const Transition = enum(u8) { } }; -pub const SceneEffect = struct { - crt: bool = false, - ripple: bool = false, - glitch: bool = false, -}; - pub const Phase = enum(u8) { opening = 0, moving = 1, diff --git a/src/builtins.zig b/src/builtins.zig index 6bfc9b26..1864086f 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -156,7 +156,7 @@ pub const registry = struct { if (@intFromEnum(b) == i) return switch (setting.action) { // a switch flips bare, and DumpDir bare is the default .toggle, .transition, .scene, .dump_dir => false, - .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms => true, + .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .shader, .shader_animation => true, }; unreachable; } diff --git a/src/config.zig b/src/config.zig index 122c0e75..1d4043ab 100644 --- a/src/config.zig +++ b/src/config.zig @@ -131,6 +131,8 @@ pub const leader_path = paths: { table.set(.Crt, "tr"); table.set(.Ripple, "tR"); table.set(.Glitch, "tg"); + table.set(.Shader, null); + table.set(.ShaderAnimation, null); } if (builtins.EffectCode.enabled) table.set(.EffectCode, null); if (builtins.TreeContext.enabled) table.set(.TreeContext, null); @@ -611,7 +613,64 @@ pub const Runtime = struct { } = .{}, panel_transition: animation.Transition = .off, - scene_effects: animation.SceneEffect = .{}, + /// The post chain (docs/render-pipeline.md §6), in the order it runs. + post: Post = .{}, + /// When the chain redraws on its own, past the frames it post-processes: + /// never, while the window has the focus, or always (ghostty's + /// custom-shader-animation). + shader_animation: ShaderAnimation = .on, + + pub const ShaderAnimation = enum { off, on, always }; + + /// Each pass a bundled effect (Crt, Ripple, Glitch) or a Shadertoy file. + pub const Post = struct { + pub const max = 8; + pub const Entry = struct { + scene: ?Scene = null, + path: Text(limits.host_path_cap) = .{}, + }; + entries: [max]Entry = @splat(.{}), + len: u8 = 0, + /// Bumped on every change: a shell compiles the chain again when it + /// moves. + generation: u32 = 0, + + pub fn list(post: *const Post) []const Entry { + return post.entries[0..post.len]; + } + + pub fn has(post: *const Post, scene: Scene) bool { + for (post.list()) |entry| if (entry.scene == scene) return true; + return false; + } + + /// Puts the pass in the chain, at its end, or takes it out; `want` + /// null flips it. False when the chain is full or the path too long. + pub fn set(post: *Post, scene: ?Scene, path: []const u8, want: ?bool) bool { + const at: ?usize = for (post.list(), 0..) |entry, i| { + if (entry.scene == scene and std.mem.eql(u8, entry.path.get(), path)) break i; + } else null; + const on = want orelse (at == null); + if (on == (at != null)) return true; + post.generation +%= 1; + if (at) |i| { + std.mem.copyForwards(Entry, post.entries[i .. post.len - 1], post.entries[i + 1 .. post.len]); + post.len -= 1; + return true; + } + if (post.len == max) return false; + post.entries[post.len] = .{ .scene = scene }; + if (!post.entries[post.len].path.set(path)) return false; + post.len += 1; + return true; + } + + pub fn clear(post: *Post) void { + if (post.len == 0) return; + post.len = 0; + post.generation +%= 1; + } + }; pub fn toggleTransition(state: *Runtime, effect: animation.Transition) void { std.debug.assert(effect != .off); @@ -690,7 +749,8 @@ pub const Runtime = struct { pub const MessageTime = enum { linger, fall, dissolve }; pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, column_tags, syntax_bold, verbose, message_animation, ligatures }; - pub const Scene = std.meta.FieldEnum(animation.SceneEffect); + /// The bundled post passes, each a Shadertoy file in shaders/post/. + pub const Scene = enum { crt, ripple, glitch }; pub const Action = union(enum) { toggle: Toggle, @@ -704,6 +764,10 @@ pub const Runtime = struct { dump_dir, transition: animation.Transition, scene: Scene, + /// A Shadertoy file into the post chain or out of it; `off` empties + /// it of files. + shader, + shader_animation, }; pub const Setting = struct { @@ -756,6 +820,8 @@ pub const Runtime = struct { .{ .word = "Crt", .action = .{ .scene = .crt }, .availability = .scene_shaders }, .{ .word = "Ripple", .action = .{ .scene = .ripple }, .availability = .scene_shaders }, .{ .word = "Glitch", .action = .{ .scene = .glitch }, .availability = .scene_shaders }, + .{ .word = "Shader", .action = .shader, .availability = .scene_shaders }, + .{ .word = "ShaderAnimation", .action = .shader_animation, .availability = .scene_shaders }, }; pub fn find(name: []const u8) ?Setting { @@ -789,7 +855,7 @@ pub const Runtime = struct { @compileError("window blur must use the window-blur capability"), .transition => if (setting.availability != .panel_transitions) @compileError("panel effects must use the panel-transition capability"), - .scene => if (setting.availability != .scene_shaders) + .scene, .shader, .shader_animation => if (setting.availability != .scene_shaders) @compileError("scene effects must use the scene-shader capability"), // A toggle is plain state any shell can hold, except one for // what only some shells draw: that one exists where they do. @@ -815,9 +881,9 @@ pub const Runtime = struct { for (std.enums.values(Scene)) |effect| { if (actionCount(.{ .scene = effect }) != 1) @compileError("scene effect must occur exactly once: " ++ @tagName(effect)); - if (@FieldType(animation.SceneEffect, @tagName(effect)) != bool) - @compileError("scene effect fields must be booleans: " ++ @tagName(effect)); } + if (actionCount(.shader) != 1 or actionCount(.shader_animation) != 1) + @compileError("Shader and ShaderAnimation must each occur exactly once"); } pub fn apply(state: *Runtime, setting: Setting, argument: ?[]const u8) bool { @@ -884,12 +950,24 @@ pub const Runtime = struct { }, .scene => |effect| { const want = switchedTo(argument) catch return false; - switch (effect) { - inline else => |field| { - const value = &@field(state.scene_effects, @tagName(field)); - value.* = want orelse !value.*; - }, + return state.post.set(effect, "", want); + }, + .shader => { + const path = std.mem.trim(u8, argument orelse return false, " \t\r\n"); + if (path.len == 0) return false; + if (std.mem.eql(u8, path, "off")) { + var i: usize = 0; + while (i < state.post.len) { + const entry = state.post.entries[i]; + if (entry.scene == null) _ = state.post.set(null, entry.path.get(), false) else i += 1; + } + return true; } + return state.post.set(null, path, null); + }, + .shader_animation => { + const text = std.mem.trim(u8, argument orelse return false, " \t\r\n"); + state.shader_animation = std.meta.stringToEnum(ShaderAnimation, text) orelse return false; }, .theme, .font => return false, } @@ -1041,11 +1119,24 @@ pub const Runtime = struct { try out.print("{s} unsupported\n", .{setting.word}); continue; } - const enabled = switch (effect) { - inline else => |field| @field(state.scene_effects, @tagName(field)), + try out.print("{s} {s}\n", .{ setting.word, onOff(state.post.has(effect)) }); + }, + .shader => { + if (!setting.enabled(context.capabilities)) { + try out.print("{s} unsupported\n", .{setting.word}); + continue; + } + var files: usize = 0; + for (state.post.list()) |entry| if (entry.scene == null) { + try out.print("{s} {s}\n", .{ setting.word, entry.path.get() }); + files += 1; }; - try out.print("{s} {s}\n", .{ setting.word, onOff(enabled) }); + if (files == 0) try out.print("{s} off\n", .{setting.word}); }, + .shader_animation => if (setting.enabled(context.capabilities)) + try out.print("{s} {s}\n", .{ setting.word, @tagName(state.shader_animation) }) + else + try out.print("{s} unsupported\n", .{setting.word}), }; if (context.startup_config_path) |path| @@ -1092,9 +1183,7 @@ pub const Runtime = struct { const scene = findAction(.{ .scene = std.enums.values(Scene)[0] }).?; try std.testing.expect(apply(&state, scene, "on")); try std.testing.expect(apply(&state, scene, "on")); - switch (std.enums.values(Scene)[0]) { - inline else => |f| try std.testing.expect(@field(state.scene_effects, @tagName(f))), - } + try std.testing.expect(state.post.has(std.enums.values(Scene)[0])); } test "a toggle flips when bare and sets when told on or off" { @@ -1131,7 +1220,30 @@ pub const Runtime = struct { try std.testing.expect(apply(&state, find("PanelAscii").?, null)); try std.testing.expectEqual(animation.Transition.off, state.panel_transition); try std.testing.expect(apply(&state, find("Crt").?, null)); - try std.testing.expect(state.scene_effects.crt); + try std.testing.expect(state.post.has(.crt)); + } + + test "the post chain runs in the order its passes were put in, and Shader off empties it of files" { + var state: Runtime = .{}; + try std.testing.expect(apply(&state, find("Shader").?, " ~/a.glsl\n")); + try std.testing.expect(apply(&state, find("Glitch").?, "on")); + try std.testing.expect(apply(&state, find("Shader").?, "/b.glsl")); + const chain = state.post.list(); + try std.testing.expectEqual(@as(usize, 3), chain.len); + try std.testing.expectEqualStrings("~/a.glsl", chain[0].path.get()); + try std.testing.expectEqual(Scene.glitch, chain[1].scene.?); + try std.testing.expectEqualStrings("/b.glsl", chain[2].path.get()); + const moved = state.post.generation; + // A file named again leaves; the others keep their order. + try std.testing.expect(apply(&state, find("Shader").?, "~/a.glsl")); + try std.testing.expect(state.post.generation != moved); + try std.testing.expectEqual(Scene.glitch, state.post.list()[0].scene.?); + try std.testing.expect(apply(&state, find("Shader").?, "off")); + try std.testing.expectEqual(@as(usize, 1), state.post.len); + try std.testing.expect(!apply(&state, find("Shader").?, null)); + try std.testing.expect(apply(&state, find("ShaderAnimation").?, "always")); + try std.testing.expectEqual(ShaderAnimation.always, state.shader_animation); + try std.testing.expect(!apply(&state, find("ShaderAnimation").?, "sometimes")); } test "tagline size validates before mutating live state" { @@ -1252,6 +1364,8 @@ pub const Runtime = struct { .window_blur => "40", .message_ms => "1500", .dump_dir => "~/dumps", + .shader => "~/crt.glsl", + .shader_animation => "always", else => null, }; try std.testing.expect(apply(&state, setting, argument)); @@ -1302,6 +1416,8 @@ pub const Runtime = struct { .ripple => "Ripple on\n", .glitch => "Glitch on\n", }, + .shader => "Shader ~/crt.glsl\n", + .shader_animation => "ShaderAnimation always\n", .theme, .font => unreachable, }; try std.testing.expect(std.mem.indexOf(u8, report, expected) != null); diff --git a/src/effect_sources.zig b/src/effect_sources.zig index 08680860..6ce11160 100644 --- a/src/effect_sources.zig +++ b/src/effect_sources.zig @@ -11,8 +11,11 @@ const ui_vertex_path = shader_dir ++ "ui.vert.glsl"; const ui_fragment_path = shader_dir ++ "ui.frag.glsl"; const image_vertex_path = shader_dir ++ "image.vert.glsl"; const image_fragment_path = shader_dir ++ "image.frag.glsl"; -const scene_vertex_path = shader_dir ++ "crt.vert.glsl"; -const scene_fragment_path = shader_dir ++ "crt.frag.glsl"; +const post_vertex_path = shader_dir ++ "post.vert.glsl"; +// The bundled post passes as compiled: the prefix and the pass together. +const post_crt_path = shader_dir ++ "post-crt.frag.glsl"; +const post_ripple_path = shader_dir ++ "post-ripple.frag.glsl"; +const post_glitch_path = shader_dir ++ "post-glitch.frag.glsl"; pub const Backend = @TypeOf(build_config.platform); pub const backend: Backend = build_config.platform; @@ -45,13 +48,15 @@ pub const files = switch (backend) { }, .gui => [_]filesystem.Source{ .{ .path = "src/gui/gui.zig", .contents = @embedFile("gui/gui.zig") }, - .{ .path = "src/gui/crt.zig", .contents = @embedFile("gui/crt.zig") }, + .{ .path = "src/gui/Post.zig", .contents = @embedFile("gui/Post.zig") }, .{ .path = ui_vertex_path, .contents = @embedFile("effect-source-ui.vert.glsl") }, .{ .path = ui_fragment_path, .contents = @embedFile("effect-source-ui.frag.glsl") }, .{ .path = image_vertex_path, .contents = @embedFile("effect-source-image.vert.glsl") }, .{ .path = image_fragment_path, .contents = @embedFile("effect-source-image.frag.glsl") }, - .{ .path = scene_vertex_path, .contents = @embedFile("effect-source-crt.vert.glsl") }, - .{ .path = scene_fragment_path, .contents = @embedFile("effect-source-crt.frag.glsl") }, + .{ .path = post_vertex_path, .contents = @embedFile("effect-source-post.vert.glsl") }, + .{ .path = post_crt_path, .contents = @embedFile("effect-source-post-crt.frag.glsl") }, + .{ .path = post_ripple_path, .contents = @embedFile("effect-source-post-ripple.frag.glsl") }, + .{ .path = post_glitch_path, .contents = @embedFile("effect-source-post-glitch.frag.glsl") }, }, .macos => [_]filesystem.Source{ .{ .path = "src/macos/Sources/PardesView.swift", .contents = @embedFile("macos/Sources/PardesView.swift") }, @@ -69,8 +74,12 @@ pub fn forSetting(setting: config.Runtime.Setting) ?[]const []const u8 { .macos => &.{ "src/layout.zig", "src/pardes.zig", "src/macos/Sources/PardesView.swift", "src/macos/Sources/ScenePostprocessor.swift", "shaders/crt.ci.metal" }, .web, .esp32p4 => null, }, - .scene => switch (backend) { - .gui => &.{ "src/gui/crt.zig", "src/gui/gui.zig", scene_vertex_path, scene_fragment_path }, + .scene => |effect| switch (backend) { + .gui => switch (effect) { + .crt => &.{ "src/gui/Post.zig", "src/gui/gui.zig", post_vertex_path, post_crt_path }, + .ripple => &.{ "src/gui/Post.zig", "src/gui/gui.zig", post_vertex_path, post_ripple_path }, + .glitch => &.{ "src/gui/Post.zig", "src/gui/gui.zig", post_vertex_path, post_glitch_path }, + }, .macos => &.{ "src/macos/Sources/PardesView.swift", "src/macos/Sources/ScenePostprocessor.swift", "shaders/crt.ci.metal" }, .tty, .web, .esp32p4 => null, }, diff --git a/src/gui/Post.zig b/src/gui/Post.zig new file mode 100644 index 00000000..d92623d7 --- /dev/null +++ b/src/gui/Post.zig @@ -0,0 +1,559 @@ +//! The post chain (docs/render-pipeline.md §6): Shadertoy passes over the +//! finished frame, each reading the one before it (the frame first) and the +//! last one writing the window. A pass is bundled (Crt, Ripple, Glitch: +//! shaders/post/, compiled with the build) or a user's file (Shader ), +//! compiled when it joins the chain by the build's own glslc invocation on a +//! thread of its own, never on the frame path. A file whose compile fails +//! keeps its last good pipeline, and glslc missing is said once. +//! +//! The uniforms are ghostty's (Uniforms), so a shader written for ghostty runs +//! here. Their clock is the shell's and moves only when the chain draws: +//! with a frame of the core's, or on its own while ShaderAnimation asks for +//! it (redraw level A, §5.5: the chain alone, over the retained frame). +const std = @import("std"); +const libc = std.c; +const ghostty_vt = @import("ghostty-vt"); +const pardes = @import("../pardes.zig"); +const filesystem = @import("../fs.zig"); +const host_io = @import("../host_io.zig"); +const gui = @import("gui.zig"); +const c = gui.c; + +const Post = @This(); +const Scene = pardes.config.Runtime.Scene; +const Chain = pardes.config.Runtime.Post; +const Pipeline = c.SDL_GPUGraphicsPipeline; +pub const max = Chain.max; + +const prefix = @embedFile("post-prefix.glsl"); +const vert_spv = @embedFile("post.vert.spv"); +const bundled_spv = std.EnumArray(Scene, []const u8).init(.{ + .crt = @embedFile("post-crt.frag.spv"), + .ripple = @embedFile("post-ripple.frag.spv"), + .glitch = @embedFile("post-glitch.frag.spv"), +}); + +/// ghostty's Shadertoy uniform block, member for member in the GLSL block's +/// order and std140 layout (shaders/post/prefix.glsl). Filled by the GLSL +/// names: ghostty's own Zig mirror calls the second-to-last slot +/// selection_background, so there the two selection colours arrive swapped +/// (docs §6, open point 1); here each is what its GLSL name says. +pub const Uniforms = extern struct { + resolution: [3]f32 align(16) = .{ 0, 0, 1 }, + time: f32 align(4) = 0, + time_delta: f32 align(4) = 0, + frame_rate: f32 align(4) = 60, + frame: i32 align(4) = 0, + channel_time: [4][4]f32 align(16) = @splat(@splat(0)), + channel_resolution: [4][4]f32 align(16) = @splat(@splat(0)), + mouse: [4]f32 align(16) = @splat(0), + date: [4]f32 align(16) = @splat(0), + sample_rate: f32 align(4) = 44100, + current_cursor: [4]f32 align(16) = @splat(0), + previous_cursor: [4]f32 align(16) = @splat(0), + current_cursor_color: [4]f32 align(16) = @splat(0), + previous_cursor_color: [4]f32 align(16) = @splat(0), + current_cursor_style: i32 align(4) = 0, + previous_cursor_style: i32 align(4) = 0, + cursor_visible: i32 align(4) = 0, + cursor_change_time: f32 align(4) = 0, + time_focus: f32 align(4) = 0, + focus: i32 align(4) = 1, + palette: [256][4]f32 align(16) = @splat(@splat(0)), + background_color: [4]f32 align(16) = @splat(0), + foreground_color: [4]f32 align(16) = @splat(0), + cursor_color: [4]f32 align(16) = @splat(0), + cursor_text: [4]f32 align(16) = @splat(0), + selection_foreground_color: [4]f32 align(16) = @splat(0), + selection_background_color: [4]f32 align(16) = @splat(0), +}; + +pub const Pass = struct { + scene: ?Scene = null, + /// A user's file, owned; empty for a bundled pass. + path: []u8 = &.{}, + pipeline: ?*Pipeline = null, +}; + +/// One compile of a chain's new files, on its own thread. +const Job = struct { + const Result = union(enum) { none, spirv: []u8, failed: []u8, missing }; + io: std.Io, + gpa: std.mem.Allocator, + paths: [max][]u8 = @splat(&.{}), + results: [max]Result = @splat(.none), + len: usize = 0, + done: std.atomic.Value(bool) = .init(false), + thread: std.Thread = undefined, +}; + +passes: [max]Pass = @splat(.{}), +len: usize = 0, +/// The chain generation `passes` follow; null before the first. +generation: ?u32 = null, +bundled: std.EnumArray(Scene, ?*Pipeline) = .initFill(null), +job: ?*Job = null, +/// Said once: without glslc, files never compile. +missing_said: bool = false, +ping: [2]?*c.SDL_GPUTexture = .{ null, null }, +ping_w: u32 = 0, +ping_h: u32 = 0, +uniforms: Uniforms = .{}, +started_ns: ?u64 = null, +last_ns: u64 = 0, + +/// Brings the passes to the chain the core holds, and takes in a finished +/// compile. Called each loop step, off the frame path. +pub fn sync(post: *Post, gpa: std.mem.Allocator, io: std.Io, device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, core: *pardes.Pardes) void { + if (post.job) |job| { + if (!job.done.load(.acquire)) return; + job.thread.join(); + post.job = null; + post.collect(gpa, device, format, core, job); + } + const chain = &core.settings.post; + if (post.generation == chain.generation) return; + post.generation = chain.generation; + var next: [max]Pass = @splat(.{}); + for (chain.list(), 0..) |entry, i| { + if (entry.scene) |scene| { + if (post.bundled.get(scene) == null) post.bundled.set(scene, makePipeline(device, format, bundled_spv.get(scene)) catch null); + next[i] = .{ .scene = scene, .pipeline = post.bundled.get(scene) }; + continue; + } + // A file already in the chain keeps its pipeline and its path. + for (post.passes[0..post.len]) |*pass| { + if (pass.scene != null or !std.mem.eql(u8, pass.path, entry.path.get())) continue; + next[i] = pass.*; + pass.* = .{}; + break; + } else next[i] = .{ .path = gpa.dupe(u8, entry.path.get()) catch &.{} }; + } + for (post.passes[0..post.len]) |pass| { + if (pass.scene == null) if (pass.pipeline) |pipeline| c.SDL_ReleaseGPUGraphicsPipeline(device, pipeline); + gpa.free(pass.path); + } + post.passes = next; + post.len = chain.len; + // An emptied chain's clock starts again with the next pass put in it. + if (post.len == 0) { + post.started_ns = null; + post.last_ns = 0; + post.uniforms.frame = 0; + } + // The new files compile together, on a thread of their own. + const job = gpa.create(Job) catch return; + job.* = .{ .io = io, .gpa = gpa }; + for (post.passes[0..post.len]) |pass| { + if (pass.scene != null or pass.pipeline != null or pass.path.len == 0) continue; + job.paths[job.len] = gpa.dupe(u8, pass.path) catch continue; + job.len += 1; + } + if (job.len == 0) return gpa.destroy(job); + job.thread = std.Thread.spawn(.{}, compile, .{job}) catch { + for (job.paths[0..job.len]) |path| gpa.free(path); + return gpa.destroy(job); + }; + post.job = job; +} + +fn collect(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, core: *pardes.Pardes, job: *Job) void { + defer gpa.destroy(job); + for (job.paths[0..job.len], job.results[0..job.len]) |path, result| { + defer gpa.free(path); + const pass = for (post.passes[0..post.len]) |*pass| { + if (pass.scene == null and std.mem.eql(u8, pass.path, path)) break pass; + } else null; + var buf: [512]u8 = undefined; + switch (result) { + .none => {}, + .missing => if (!post.missing_said) { + post.missing_said = true; + core.setMessage(core.active, "Shader: glslc is not on PATH, so Shadertoy files do not run (Crt, Ripple and Glitch do)"); + }, + .failed => |text| { + defer gpa.free(text); + // glslc's first line says where: in the file's own lines. + const first = std.mem.sliceTo(text, '\n'); + core.setMessage(core.active, std.fmt.bufPrint(&buf, "Shader {s}: {s}", .{ path, first[0..@min(first.len, 400)] }) catch "Shader: compile failed"); + }, + .spirv => |spirv| { + defer gpa.free(spirv); + const target = pass orelse continue; + const made = makePipeline(device, format, spirv) catch { + core.setMessage(core.active, std.fmt.bufPrint(&buf, "Shader {s}: the GPU refused it", .{path}) catch "Shader: the GPU refused it"); + continue; + }; + if (target.pipeline) |old| c.SDL_ReleaseGPUGraphicsPipeline(device, old); + target.pipeline = made; + }, + } + } +} + +/// The compile thread: each file behind the prefix, through glslc exactly as +/// the build calls it (build.zig compileGlsl), its SPIR-V on stdout. +fn compile(job: *Job) void { + defer job.done.store(true, .release); + defer { + var event = std.mem.zeroes(c.SDL_Event); + event.type = c.SDL_EVENT_USER; + _ = c.SDL_PushEvent(&event); + } + for (job.paths[0..job.len], 0..) |path, i| job.results[i] = compileOne(job.gpa, job.io, path, i); +} + +fn compileOne(gpa: std.mem.Allocator, io: std.Io, path: []const u8, index: usize) Job.Result { + var home_buf: [4096]u8 = undefined; + const expanded = if (std.mem.startsWith(u8, path, "~/")) if (libc.getenv("HOME")) |home| + std.fmt.bufPrint(&home_buf, "{s}/{s}", .{ std.mem.span(home), path[2..] }) catch path + else + path else path; + const body = filesystem.readFile(gpa, expanded) catch |err| + return .{ .failed = std.fmt.allocPrint(gpa, "cannot read it ({t})", .{err}) catch return .none }; + defer gpa.free(body); + const dir = if (libc.getenv("XDG_RUNTIME_DIR")) |d| std.mem.span(d) else "/tmp"; + var source_buf: [4096]u8 = undefined; + const source = std.fmt.bufPrintSentinel(&source_buf, "{s}/pardes-post-{d}-{d}.frag.glsl", .{ dir, libc.getpid(), index }, 0) catch return .none; + defer _ = libc.unlink(source); + { + const fd = libc.open(source, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o600)); + if (fd < 0) return .{ .failed = gpa.dupe(u8, "cannot write its source for glslc") catch return .none }; + defer _ = libc.close(fd); + if (!host_io.writeFd(fd, prefix) or !host_io.writeFd(fd, body)) + return .{ .failed = gpa.dupe(u8, "cannot write its source for glslc") catch return .none }; + } + const run = std.process.run(gpa, io, .{ + .argv = &.{ "glslc", "-fshader-stage=fragment", source, "-o", "-" }, + .stdout_limit = .limited(16 << 20), + .stderr_limit = .limited(64 << 10), + }) catch |err| return switch (err) { + error.FileNotFound => .missing, + else => .{ .failed = std.fmt.allocPrint(gpa, "glslc did not run ({t})", .{err}) catch return .none }, + }; + const clean = switch (run.term) { + .exited => |code| code == 0, + else => false, + }; + if (clean) { + gpa.free(run.stderr); + return .{ .spirv = run.stdout }; + } + gpa.free(run.stdout); + // glslc names the temporary source; the file is what a person knows. + defer gpa.free(run.stderr); + const named = std.mem.replaceOwned(u8, gpa, run.stderr, source, path) catch return .{ .failed = gpa.dupe(u8, "glslc failed") catch return .none }; + return .{ .failed = named }; +} + +fn makePipeline(device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, frag: []const u8) !*Pipeline { + const vs = try gui.makeShader(device, vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0, 0); + defer c.SDL_ReleaseGPUShader(device, vs); + const fs = try gui.makeShader(device, frag, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 2); + defer c.SDL_ReleaseGPUShader(device, fs); + var target = c.SDL_GPUColorTargetDescription{ .format = 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 = &target, .num_color_targets = 1 }; + return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate; +} + +/// A pass is ready to draw. +pub fn ready(post: *const Post) bool { + for (post.passes[0..post.len]) |pass| if (pass.pipeline != null) return true; + return false; +} + +/// The chain redraws on its own (level A) as ShaderAnimation says. +pub fn animating(post: *const Post, mode: pardes.config.Runtime.ShaderAnimation) bool { + return post.ready() and switch (mode) { + .off => false, + .on => post.uniforms.focus != 0, + .always => true, + }; +} + +/// What a frame of the core's tells the passes: where its cursor is drawn +/// and in what, and the theme's colours. +pub const Frame = struct { + pub const Cursor = struct { x0: f32, y0: f32, x1: f32, y1: f32, rgb: [3]u8, text: [3]u8, bar: bool }; + cursor: ?Cursor, + background: [3]u8, + foreground: [3]u8, + selection_foreground: [3]u8, + selection_background: [3]u8, +}; + +fn rgba(rgb: [3]u8) [4]f32 { + return .{ @as(f32, @floatFromInt(rgb[0])) / 255, @as(f32, @floatFromInt(rgb[1])) / 255, @as(f32, @floatFromInt(rgb[2])) / 255, 1 }; +} + +/// Takes in a frame of the core's. The cursor's rectangle is (left, BOTTOM +/// edge, width, height) in framebuffer pixels, y down; a move or a new +/// colour makes the old one the previous and stamps the change. +pub fn describe(post: *Post, frame: Frame) void { + const u = &post.uniforms; + u.background_color = rgba(frame.background); + u.foreground_color = rgba(frame.foreground); + u.selection_foreground_color = rgba(frame.selection_foreground); + u.selection_background_color = rgba(frame.selection_background); + if (u.palette[1][3] == 0) for (&u.palette, 0..) |*slot, i| { + const color = ghostty_vt.color.default[i]; + slot.* = rgba(.{ color.r, color.g, color.b }); + }; + u.cursor_visible = @intFromBool(frame.cursor != null); + const cursor = frame.cursor orelse return; + const rect: [4]f32 = .{ cursor.x0, cursor.y1, cursor.x1 - cursor.x0, cursor.y1 - cursor.y0 }; + const style: i32 = if (cursor.bar) 2 else 0; + const color = rgba(cursor.rgb); + u.cursor_color = color; + u.cursor_text = rgba(cursor.text); + if (std.meta.eql(rect, u.current_cursor) and std.meta.eql(color, u.current_cursor_color) and style == u.current_cursor_style) return; + u.previous_cursor = u.current_cursor; + u.previous_cursor_color = u.current_cursor_color; + u.previous_cursor_style = u.current_cursor_style; + u.current_cursor = rect; + u.current_cursor_color = color; + u.current_cursor_style = style; + u.cursor_change_time = u.time; +} + +/// The window gained or lost the focus. +pub fn focus(post: *Post, focused: bool) void { + if (focused and post.uniforms.focus == 0) post.uniforms.time_focus = post.uniforms.time; + post.uniforms.focus = @intFromBool(focused); +} + +/// The pointer, in framebuffer pixels: Shadertoy's iMouse, xy where it is, +/// zw where the last press was, negative once released. +pub fn mouse(post: *Post, x: f32, y: f32, press: ?bool) void { + const m = &post.uniforms.mouse; + m[0] = x; + m[1] = y; + if (press) |down| { + if (down) { + m[2] = x; + m[3] = y; + } else { + m[2] = -@abs(m[2]); + m[3] = -@abs(m[3]); + } + } +} + +/// Runs the passes over `source`, the finished frame, into `target`: each +/// reads the one before, the ones between write two ping-pong textures that +/// start cleared, and iTime and iFrame move by this one draw. +pub fn draw(post: *Post, device: *c.SDL_GPUDevice, cmd: *c.SDL_GPUCommandBuffer, sampler: *c.SDL_GPUSampler, format: c.SDL_GPUTextureFormat, source: *c.SDL_GPUTexture, target: *c.SDL_GPUTexture, w: u32, h: u32, now_ns: u64, opaque_window: bool) !void { + var live: usize = 0; + for (post.passes[0..post.len]) |pass| live += @intFromBool(pass.pipeline != null); + if (live == 0) return; + if (live > 1 and (post.ping[0] == null or post.ping_w != w or post.ping_h != h)) { + for (&post.ping) |*texture| { + if (texture.*) |old| c.SDL_ReleaseGPUTexture(device, old); + var info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); + info.type = c.SDL_GPU_TEXTURETYPE_2D; + info.format = format; + info.usage = c.SDL_GPU_TEXTUREUSAGE_COLOR_TARGET | c.SDL_GPU_TEXTUREUSAGE_SAMPLER; + info.width = w; + info.height = h; + info.layer_count_or_depth = 1; + info.num_levels = 1; + info.sample_count = c.SDL_GPU_SAMPLECOUNT_1; + texture.* = c.SDL_CreateGPUTexture(device, &info) orelse return error.GpuCreate; + } + post.ping_w = w; + post.ping_h = h; + } + const u = &post.uniforms; + const started = post.started_ns orelse now_ns; + post.started_ns = started; + const elapsed = @as(f64, @floatFromInt(now_ns -| started)) / std.time.ns_per_s; + const delta: f32 = if (post.last_ns == 0) 0 else @floatCast(@as(f64, @floatFromInt(now_ns -| post.last_ns)) / std.time.ns_per_s); + post.last_ns = now_ns; + u.time = @floatCast(elapsed); + u.time_delta = delta; + u.frame_rate = if (delta > 0) 1 / delta else 60; + u.resolution = .{ @floatFromInt(w), @floatFromInt(h), 1 }; + u.channel_resolution[0] = .{ @floatFromInt(w), @floatFromInt(h), 1, 0 }; + // UTC: the year, the month from 0, the day, the second of the day. + var wall: libc.timespec = undefined; + if (libc.clock_gettime(.REALTIME, &wall) != 0) wall.sec = 0; + const epoch: std.time.epoch.EpochSeconds = .{ .secs = @intCast(@max(0, wall.sec)) }; + const day = epoch.getEpochDay().calculateYearDay(); + const month = day.calculateMonthDay(); + u.date = .{ @floatFromInt(day.year), @floatFromInt(month.month.numeric() - 1), @floatFromInt(month.day_index + 1), @floatFromInt(epoch.getDaySeconds().secs) }; + const own: [4]f32 = .{ @floatFromInt(@intFromBool(opaque_window)), 0, 0, 0 }; + + var input = source; + var index: usize = 0; + for (post.passes[0..post.len]) |pass| { + const pipeline = pass.pipeline orelse continue; + index += 1; + const last = index == live; + const output = if (last) target else post.ping[index % 2].?; + var info = std.mem.zeroes(c.SDL_GPUColorTargetInfo); + info.texture = output; + info.load_op = if (last) c.SDL_GPU_LOADOP_DONT_CARE else c.SDL_GPU_LOADOP_CLEAR; + info.store_op = c.SDL_GPU_STOREOP_STORE; + const pass_rp = c.SDL_BeginGPURenderPass(cmd, &info, 1, null); + c.SDL_BindGPUGraphicsPipeline(pass_rp, pipeline); + const binding = c.SDL_GPUTextureSamplerBinding{ .texture = input, .sampler = sampler }; + c.SDL_BindGPUFragmentSamplers(pass_rp, 0, &binding, 1); + c.SDL_PushGPUFragmentUniformData(cmd, 0, u, @sizeOf(Uniforms)); + c.SDL_PushGPUFragmentUniformData(cmd, 1, &own, @sizeOf(@TypeOf(own))); + c.SDL_DrawGPUPrimitives(pass_rp, 3, 1, 0, 0); + c.SDL_EndGPURenderPass(pass_rp); + input = output; + } + u.frame +%= 1; +} + +pub fn deinit(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice) void { + if (post.job) |job| { + job.thread.join(); + for (job.paths[0..job.len], job.results[0..job.len]) |path, result| { + gpa.free(path); + switch (result) { + .spirv, .failed => |bytes| gpa.free(bytes), + .none, .missing => {}, + } + } + gpa.destroy(job); + } + for (post.passes[0..post.len]) |pass| { + if (pass.scene == null) if (pass.pipeline) |pipeline| c.SDL_ReleaseGPUGraphicsPipeline(device, pipeline); + gpa.free(pass.path); + } + for (post.bundled.values) |pipeline| if (pipeline) |p| c.SDL_ReleaseGPUGraphicsPipeline(device, p); + for (post.ping) |texture| if (texture) |t| c.SDL_ReleaseGPUTexture(device, t); +} + +/// ghostty 1.3.2's Uniforms (src/renderer/shadertoy.zig), copied: the offsets +/// ours must match. Its last two names are swapped against its own GLSL. +const GhosttyUniforms = extern struct { + resolution: [3]f32 align(16), + time: f32 align(4), + time_delta: f32 align(4), + frame_rate: f32 align(4), + frame: i32 align(4), + channel_time: [4][4]f32 align(16), + channel_resolution: [4][4]f32 align(16), + mouse: [4]f32 align(16), + date: [4]f32 align(16), + sample_rate: f32 align(4), + current_cursor: [4]f32 align(16), + previous_cursor: [4]f32 align(16), + current_cursor_color: [4]f32 align(16), + previous_cursor_color: [4]f32 align(16), + current_cursor_style: i32 align(4), + previous_cursor_style: i32 align(4), + cursor_visible: i32 align(4), + cursor_change_time: f32 align(4), + time_focus: f32 align(4), + focus: i32 align(4), + palette: [256][4]f32 align(16), + background_color: [4]f32 align(16), + foreground_color: [4]f32 align(16), + cursor_color: [4]f32 align(16), + cursor_text: [4]f32 align(16), + selection_background_color: [4]f32 align(16), + selection_foreground_color: [4]f32 align(16), +}; + +test "the uniform block is ghostty's, offset for offset, and the prefix names it in that order" { + const ours = @typeInfo(Uniforms).@"struct".fields; + const theirs = @typeInfo(GhosttyUniforms).@"struct".fields; + try std.testing.expectEqual(theirs.len, ours.len); + try std.testing.expectEqual(@sizeOf(GhosttyUniforms), @sizeOf(Uniforms)); + inline for (ours, theirs) |a, b| { + try std.testing.expectEqual(@offsetOf(GhosttyUniforms, b.name), @offsetOf(Uniforms, a.name)); + try std.testing.expectEqual(b.type, a.type); + } + // By NAME: iSelectionForegroundColor sits where ghostty's Zig says + // selection_background, which is where its GLSL puts the foreground. + try std.testing.expectEqual(@offsetOf(GhosttyUniforms, "selection_background_color"), @offsetOf(Uniforms, "selection_foreground_color")); + // The prefix's block, member by member, in ghostty's GLSL names and types. + const glsl = [_]struct { []const u8, []const u8 }{ + .{ "vec3", "iResolution" }, .{ "float", "iTime" }, .{ "float", "iTimeDelta" }, + .{ "float", "iFrameRate" }, .{ "int", "iFrame" }, .{ "float", "iChannelTime[4]" }, + .{ "vec3", "iChannelResolution[4]" }, .{ "vec4", "iMouse" }, .{ "vec4", "iDate" }, + .{ "float", "iSampleRate" }, .{ "vec4", "iCurrentCursor" }, .{ "vec4", "iPreviousCursor" }, + .{ "vec4", "iCurrentCursorColor" }, .{ "vec4", "iPreviousCursorColor" }, .{ "int", "iCurrentCursorStyle" }, + .{ "int", "iPreviousCursorStyle" }, .{ "int", "iCursorVisible" }, .{ "float", "iTimeCursorChange" }, + .{ "float", "iTimeFocus" }, .{ "int", "iFocus" }, .{ "vec3", "iPalette[256]" }, + .{ "vec3", "iBackgroundColor" }, .{ "vec3", "iForegroundColor" }, .{ "vec3", "iCursorColor" }, + .{ "vec3", "iCursorText" }, .{ "vec3", "iSelectionForegroundColor" }, .{ "vec3", "iSelectionBackgroundColor" }, + }; + try std.testing.expectEqual(ours.len, glsl.len); + const block = prefix[std.mem.indexOf(u8, prefix, "uniform Globals {").?..]; + var lines = std.mem.tokenizeScalar(u8, block[0..std.mem.indexOfScalar(u8, block, '}').?], '\n'); + _ = lines.next(); + for (glsl) |member| { + var words = std.mem.tokenizeAny(u8, lines.next().?, " ;"); + try std.testing.expectEqualStrings("uniform", words.next().?); + try std.testing.expectEqualStrings(member[0], words.next().?); + try std.testing.expectEqualStrings(member[1], words.next().?); + } + try std.testing.expect(lines.next() == null); +} + +test "a moved cursor becomes the previous one, stamped with the time of the move" { + var post: Post = .{}; + const frame: Frame = .{ .cursor = .{ .x0 = 10, .y0 = 20, .x1 = 11, .y1 = 40, .rgb = .{ 255, 0, 0 }, .text = .{ 0, 0, 0 }, .bar = true }, .background = .{ 0, 0, 0 }, .foreground = .{ 255, 255, 255 }, .selection_foreground = .{ 1, 2, 3 }, .selection_background = .{ 4, 5, 6 } }; + post.uniforms.time = 1.5; + post.describe(frame); + // Left, BOTTOM edge, width, height, in framebuffer pixels, y down. + try std.testing.expectEqual([4]f32{ 10, 40, 1, 20 }, post.uniforms.current_cursor); + try std.testing.expectEqual(@as(i32, 2), post.uniforms.current_cursor_style); + try std.testing.expectEqual(@as(f32, 1.5), post.uniforms.cursor_change_time); + post.uniforms.time = 2; + post.describe(frame); + try std.testing.expectEqual(@as(f32, 1.5), post.uniforms.cursor_change_time); + var moved = frame; + moved.cursor.?.x0 = 20; + moved.cursor.?.x1 = 21; + post.describe(moved); + try std.testing.expectEqual([4]f32{ 10, 40, 1, 20 }, post.uniforms.previous_cursor); + try std.testing.expectEqual(@as(f32, 2), post.uniforms.cursor_change_time); + try std.testing.expectEqual([4]f32{ 1.0 / 255.0, 2.0 / 255.0, 3.0 / 255.0, 1 }, post.uniforms.selection_foreground_color); +} + +test "ghostty's test shaders compile behind the prefix, and its invalid one fails with glslc's words" { + const gpa = std.testing.allocator; + const io = std.testing.io; + // ghostty's own test shaders, from its package in zig-pkg. + var pkg = std.Io.Dir.cwd().openDir(io, "zig-pkg", .{ .iterate = true }) catch return error.SkipZigTest; + defer pkg.close(io); + var it = pkg.iterate(); + const ghostty = while (it.next(io) catch null) |entry| { + if (std.mem.startsWith(u8, entry.name, "ghostty-")) break entry.name; + } else return error.SkipZigTest; + var buf: [512]u8 = undefined; + for ([_][]const u8{ "crt", "focus", "invalid" }, 0..) |name, i| { + const path = try std.fmt.bufPrint(&buf, "zig-pkg/{s}/src/renderer/shaders/test_shadertoy_{s}.glsl", .{ ghostty, name }); + const result = compileOne(gpa, io, path, i); + switch (result) { + .missing => return error.SkipZigTest, // no glslc here + .spirv => |spirv| { + defer gpa.free(spirv); + try std.testing.expect(!std.mem.eql(u8, name, "invalid")); + // SPIR-V's magic number, little-endian. + try std.testing.expectEqual(@as(u32, 0x07230203), std.mem.readInt(u32, spirv[0..4], .little)); + }, + .failed => |text| { + defer gpa.free(text); + try std.testing.expectEqualStrings("invalid", name); + // Named by the file, not by the temporary source glslc read. + try std.testing.expect(std.mem.indexOf(u8, text, path) != null); + try std.testing.expect(std.mem.indexOf(u8, text, "pardes-post-") == null); + }, + .none => return error.TestUnexpectedResult, + } + } +} diff --git a/src/gui/crt.zig b/src/gui/crt.zig deleted file mode 100644 index 2a5ea7e4..00000000 --- a/src/gui/crt.zig +++ /dev/null @@ -1,101 +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.016; - -pub const Point = struct { x: f32, y: f32 }; -pub const Effects = struct { crt: bool = false, ripple: bool = false, glitch: bool = false }; -pub const Frame = struct { effects: Effects = .{}, time_seconds: f32 = 0 }; - -fn fract(x: f32) f32 { - return x - @floor(x); -} - -fn smoothstep(lo: f32, hi: f32, value: f32) f32 { - const t = std.math.clamp((value - lo) / (hi - lo), 0, 1); - return t * t * (3 - 2 * t); -} - -/// Match `sceneUv` in crt.frag.glsl. This is a forward screen→sample mapping: -/// a physical pointer is sent to the logical scene pixel displayed under it. -pub fn mapScene(x: f32, y: f32, w: f32, h: f32, effects: Effects, time_seconds: f32) ?Point { - const safe_w = @max(1, w); - const safe_h = @max(1, h); - const ccx = x / safe_w * 2.0 - 1.0; - const ccy = y / safe_h * 2.0 - 1.0; - const s = 1.0 + barrel * (ccx * ccx + ccy * ccy) * @as(f32, @floatFromInt(@intFromBool(effects.crt))); - var u = ccx * s * 0.5 + 0.5; - var v = ccy * s * 0.5 + 0.5; - // The CRT shader paints no scene when this barrel-mapped coordinate lies - // on or beyond the tube edge. There is no logical edge cell under those - // black pixels, so input must be inert there rather than clamped. - if (effects.crt and (u <= 0 or u >= 1 or v <= 0 or v >= 1)) return null; - if (effects.ripple) { - const cx = u - 0.5; - const cy = v - 0.5; - const radius = @sqrt(cx * cx + cy * cy); - const envelope = 1.0 - smoothstep(0.04, 0.72, radius); - const wave = @sin(radius * 42.0 - time_seconds * 5.2); - const len = @max(radius, 0.00001); - u += cx / len * wave * envelope * 0.0032; - v += cy / len * wave * envelope * 0.0032; - } - if (effects.glitch) { - const band = @floor(v * 96.0); - const tick = @floor(time_seconds * 13.0); - const seed = noiseAt(band, tick); - const tear = smoothstep(0.955, 1.0, seed); - u += (noiseAt(seed, band) - 0.5) * tear * 0.020; - } - return .{ - .x = std.math.clamp(u * safe_w, 0, safe_w - 1), - .y = std.math.clamp(v * safe_h, 0, safe_h - 1), - }; -} - -fn noiseAt(x: f32, y: f32) f32 { - var qx = fract(x * 0.1031); - var qy = fract(y * 0.1031); - var qz = fract(x * 0.1031); - const d = qx * (qy + 33.33) + qy * (qz + 33.33) + qz * (qx + 33.33); - qx += d; - qy += d; - qz += d; - return fract((qx + qy) * qz); -} - -test "map matches the shader's barrel formula" { - // center is a fixed point - const center = mapScene(500, 250, 1000, 500, .{ .crt = true }, 0).?; - try std.testing.expectApproxEqAbs(@as(f32, 500), center.x, 0.001); - try std.testing.expectApproxEqAbs(@as(f32, 250), center.y, 0.001); - // Corners barrel beyond the tube and are black/inert in the shader. - try std.testing.expect(mapScene(0, 0, 1000, 500, .{ .crt = true }, 0) == null); - try std.testing.expect(mapScene(1000, 500, 1000, 500, .{ .crt = true }, 0) == null); - // (100,100) of 1000x500: cc=(-0.8,-0.6), r2=1.0, s=1.016 -> - // cc'=(-0.8128,-0.6096) -> (93.6, 97.6) - const near_edge = mapScene(100, 100, 1000, 500, .{ .crt = true }, 0).?; - try std.testing.expectApproxEqAbs(@as(f32, 93.6), near_edge.x, 0.01); - try std.testing.expectApproxEqAbs(@as(f32, 97.6), near_edge.y, 0.01); - // (900,250): cc=(0.8,0), r2=0.64, s=1.01024 -> x'=904.096 - const side = mapScene(900, 250, 1000, 500, .{ .crt = true }, 0).?; - try std.testing.expectApproxEqAbs(@as(f32, 904.096), side.x, 0.01); -} - -test "scene mapping composes ripple and deterministic glitch" { - const plain = mapScene(300, 200, 800, 600, .{}, 2.5).?; - try std.testing.expectApproxEqAbs(@as(f32, 300), plain.x, 0.001); - try std.testing.expectApproxEqAbs(@as(f32, 200), plain.y, 0.001); - const ripple = mapScene(300, 200, 800, 600, .{ .ripple = true }, 2.5).?; - try std.testing.expect(ripple.x != plain.x or ripple.y != plain.y); - try std.testing.expectEqual( - mapScene(300, 200, 800, 600, .{ .glitch = true }, 2.5).?, - mapScene(300, 200, 800, 600, .{ .glitch = true }, 2.5).?, - ); -} diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 2cde5edf..ab1875b7 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -12,7 +12,7 @@ const look = @import("../look.zig"); const message = pardes.Messages.Message; const file_watch = @import("../file_watch.zig"); const deck = @import("deck.zig"); -const crt = @import("crt.zig"); +const Post = @import("Post.zig"); const pet = @import("pet.zig"); const fonts = @import("../fonts.zig"); // the Font builtin's half of the seam const selection_pipe = @import("../selection_pipe.zig"); @@ -43,8 +43,6 @@ const overlay_vert_spv = @embedFile("overlay.vert.spv"); const overlay_frag_spv = @embedFile("overlay.frag.spv"); const image_vert_spv = @embedFile("image.vert.spv"); const image_frag_spv = @embedFile("image.frag.spv"); -const crt_vert_spv = @embedFile("crt.vert.spv"); -const crt_frag_spv = @embedFile("crt.frag.spv"); const bg_default = [3]u8{ 18, 18, 18 }; const fg_default = [3]u8{ 204, 204, 204 }; @@ -202,18 +200,17 @@ const touch_click_flash_max_frames: u8 = 14; const touch_click_flash_vertices: usize = 1400; const touch_scroll_tick: f32 = 0.02; -test "a persistent scene presents a new panel's frame zero before advancing it" { +test "a new panel's frame zero is presented before it advances" { const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer core.deinit(); var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); const frame = pardes.animation.frame_ns; - core.settings.scene_effects.crt = true; core.advance(100); - const scene_frame = try core.render(arena.allocator()); - core.acknowledgePanelPresentation(scene_frame.panelTracks()); - try std.testing.expectEqual(@as(?u64, 100 + frame), core.nextWake()); + const idle = try core.render(arena.allocator()); + core.acknowledgePanelPresentation(idle.panelTracks()); + try std.testing.expectEqual(@as(?u64, null), core.nextWake()); core.settings.panel_transition = .slide; core.update(.{ .command = "Newcol" }); @@ -235,10 +232,9 @@ test "a persistent scene presents a new panel's frame zero before advancing it" for (second.panelTracks()) |track| try std.testing.expectEqual(@as(u16, 1), track.frame); } -// Per pane: background, rule, two grip strokes, two square rails, and the -// rule under each of its notice chips. -const pane_chrome_vertices: usize = 6 * (6 + pardes.Pane.Notices.max); -const max_overlay_vertices: usize = 24 + pet.max_vertices + pardes.MAX_COLS * 6 + pardes.MAX_PANES * pane_chrome_vertices + touch_click_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices); +// The triangle overlay holds only the pet and the touch view: the chrome is +// decor. +const max_overlay_vertices: usize = pet.max_vertices + touch_click_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices); const font_px_step: f32 = 2.0; const font_px_min: f32 = 8.0; @@ -1765,7 +1761,6 @@ const Gui = struct { decor_pipeline: *c.SDL_GPUGraphicsPipeline, overlay_pipeline: *c.SDL_GPUGraphicsPipeline, image_pipeline: *c.SDL_GPUGraphicsPipeline, - crt_pipeline: *c.SDL_GPUGraphicsPipeline, atlas_tex: *c.SDL_GPUTexture, atlas_sampler: *c.SDL_GPUSampler, linear_sampler: *c.SDL_GPUSampler, @@ -1838,7 +1833,8 @@ const Gui = struct { scene_tex_h: u32 = 0, scene_failures: u8 = 0, scene_target_failed: bool = false, - presented_scene: crt.Frame = .{}, + /// The post chain, run over the frame in scene_tex. + post: Post = .{}, pointer_present: bool = false, arrow_cursor: ?*c.SDL_Cursor = null, link_cursor: ?*c.SDL_Cursor = null, @@ -1977,7 +1973,9 @@ fn windowCells(g: *const Gui) GridCells { }; } -fn windowPointToPixels(geometry: WindowGeometry, x: f32, y: f32) crt.Point { +const Point = struct { x: f32, y: f32 }; + +fn windowPointToPixels(geometry: WindowGeometry, x: f32, y: f32) Point { return .{ .x = x * geometry.pixel_w / geometry.window_w, .y = y * geometry.pixel_h / geometry.window_h, @@ -1991,7 +1989,7 @@ test "high-density window coordinates map once to render pixels" { .pixel_w = 1120, .pixel_h = 720, }; - try std.testing.expectEqual(crt.Point{ .x = 700, .y = 240 }, windowPointToPixels(geometry, 350, 120)); + try std.testing.expectEqual(Point{ .x = 700, .y = 240 }, windowPointToPixels(geometry, 350, 120)); } fn fixedCellLayout(g: *const Gui) CellLayout { @@ -2397,7 +2395,6 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u const decor_pipeline = try makePipeline(device, swapchain_format, decor_frag_spv, 0, backgroundLayerBlend()); const overlay_pipeline = try makeOverlayPipeline(device, swapchain_format); const image_pipeline = try makeImagePipeline(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 }; @@ -2427,7 +2424,6 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u .decor_pipeline = decor_pipeline, .overlay_pipeline = overlay_pipeline, .image_pipeline = image_pipeline, - .crt_pipeline = crt_pipeline, .atlas_tex = atlas_tex, .atlas_sampler = atlas_sampler, .linear_sampler = linear_sampler, @@ -2464,7 +2460,6 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u c.SDL_ReleaseGPUGraphicsPipeline(device, g.pipeline); c.SDL_ReleaseGPUGraphicsPipeline(device, g.decor_pipeline); c.SDL_ReleaseGPUGraphicsPipeline(device, g.overlay_pipeline); - c.SDL_ReleaseGPUGraphicsPipeline(device, g.crt_pipeline); c.SDL_ReleaseGPUTexture(device, g.atlas_tex); c.SDL_ReleaseGPUSampler(device, g.atlas_sampler); c.SDL_ReleaseGPUSampler(device, g.linear_sampler); @@ -2490,6 +2485,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u g.prepared_images.deinit(gpa); g.cover.deinit(gpa); g.decor.deinit(gpa); + g.post.deinit(gpa, device); } defer c.SDL_ReleaseGPUTransferBuffer(device, g.image_vxfer); defer c.SDL_ReleaseGPUBuffer(device, g.image_vbuf); @@ -2796,7 +2792,6 @@ fn paintAttached(g: *Gui, gpa: std.mem.Allocator, client: *detached_client.Clien gpa, null, &surface, - .{}, false, ) catch |err| blk: { log.err("render: {t}", .{err}); @@ -3065,23 +3060,14 @@ const StdinFeed = struct { f.fill = len - seq_start; break; }; - if (parseFinger(body[0..end])) |finger_in| { - var finger = finger_in; + if (parseFinger(body[0..end])) |finger| { 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 (mapScenePoint(gp, finger.x * win_w, finger.y * win_h, win_w, win_h)) |m| { - finger.x = m.x / win_w; - finger.y = m.y / win_h; - handleFinger(&gp.touch, in, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h), @floatFromInt(gp.tagline_width)); - } else { - finger.kind = .cancel; - handleFinger(&gp.touch, in, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h), @floatFromInt(gp.tagline_width)); - in.post(.pointer_leave); - } + handleFinger(&gp.touch, in, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h), @floatFromInt(gp.tagline_width)); } else if (in.core) |core| { handleFinger(&grid_touch, in, finger, @floatFromInt(core.screen_w), @floatFromInt(core.screen_h), 1, 1, 1); } @@ -3288,6 +3274,20 @@ const Input = struct { }; fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { + // What the post chain sees of the pointer and the focus. + switch (sev.type) { + c.SDL_EVENT_MOUSE_MOTION => { + const at = windowPointToPixels(windowGeometry(g.window), sev.motion.x, sev.motion.y); + g.post.mouse(at.x, at.y, null); + }, + c.SDL_EVENT_MOUSE_BUTTON_DOWN, c.SDL_EVENT_MOUSE_BUTTON_UP => { + const at = windowPointToPixels(windowGeometry(g.window), sev.button.x, sev.button.y); + g.post.mouse(at.x, at.y, sev.button.down); + }, + c.SDL_EVENT_WINDOW_FOCUS_GAINED => g.post.focus(true), + c.SDL_EVENT_WINDOW_FOCUS_LOST => g.post.focus(false), + else => {}, + } switch (sev.type) { c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => in.close(), c.SDL_EVENT_WINDOW_MOUSE_LEAVE => { @@ -3460,7 +3460,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { }, 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 = .{ + const finger: Finger = .{ .kind = switch (sev.type) { c.SDL_EVENT_FINGER_DOWN => .down, c.SDL_EVENT_FINGER_MOTION => .motion, @@ -3479,15 +3479,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { const win_h: f32 = @floatFromInt(@max(ph, 1)); const fcw: f32 = @floatFromInt(g.cell_w); const fch: f32 = @floatFromInt(g.cell_h); - if (mapScenePoint(g, finger.x * win_w, finger.y * win_h, win_w, win_h)) |m| { - finger.x = m.x / win_w; - finger.y = m.y / win_h; - handleFinger(&g.touch, in, finger, win_w, win_h, fcw, fch, @floatFromInt(g.tagline_width)); - } else { - finger.kind = .cancel; - handleFinger(&g.touch, in, finger, win_w, win_h, fcw, fch, @floatFromInt(g.tagline_width)); - in.post(.pointer_leave); - } + handleFinger(&g.touch, in, finger, win_w, win_h, fcw, fch, @floatFromInt(g.tagline_width)); }, c.SDL_EVENT_PINCH_BEGIN, c.SDL_EVENT_PINCH_UPDATE => in.post(.{ .pinch = sev.pinch.scale }), c.SDL_EVENT_GAMEPAD_ADDED => { @@ -3747,14 +3739,30 @@ test "tagline pointer mapping retains physical grips and carries logical text co try std.testing.expectEqual(pardes.TagKind.workspace, topbar.tag_hit.?.kind); } -fn mapScenePoint(g: *const Gui, x: f32, y: f32, w: f32, h: f32) ?crt.Point { - return crt.mapScene(x, y, w, h, g.presented_scene.effects, g.presented_scene.time_seconds); -} - +test "a click at the window's corner lands in the corner cell with a post chain on" { + const core = try pardes.Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); + defer core.deinit(); + try std.testing.expect(core.settings.post.set(.crt, "", true)); + var g: Gui = undefined; + g.cell_w = 10; + g.cell_h = 20; + g.tagline_width = 6; + g.tagline_height = 12; + const geometry: WindowGeometry = .{ .window_w = 400, .window_h = 240, .pixel_w = 800, .pixel_h = 480 }; + const last = mouseCellWithGeometry(&g, core, 399.9, 239.9, geometry).?; + try std.testing.expectEqual(@as(u16, 79), last.col); + try std.testing.expectEqual(@as(u16, 23), last.row); + const first = mouseCellWithGeometry(&g, core, 0, 0, geometry).?; + try std.testing.expectEqual(@as(u16, 0), first.col); + try std.testing.expectEqual(@as(u16, 0), first.row); +} + +/// Where a window point lands: the cell drawn under it. Input is identity: +/// no post pass moves a pixel (the bundled Crt has no barrel), so a click +/// is exactly where it looks. fn mouseCellWithGeometry(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32, geometry: WindowGeometry) ?MouseCell { const physical = windowPointToPixels(geometry, x, y); - const mapped = mapScenePoint(g, physical.x, physical.y, geometry.pixel_w, geometry.pixel_h) orelse return null; - return gridCellAtPixels(g, core, mapped.x, mapped.y); + return gridCellAtPixels(g, core, physical.x, physical.y); } fn mouseCell(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32) ?MouseCell { @@ -4064,6 +4072,7 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { // shell's queue raises for a terminal's output, a completion, a file // system request. What the shell moves or times itself polls every // frame: a smooth scroll, a gamepad's sticks, the test stdin feed, + // a post chain that moves on its own (ShaderAnimation), // a retired shell waiting to be killed, a present to retry. A // minimized window sleeps through the core's animation too: nothing // shows it, and it is caught up at once when the window is back. @@ -4072,7 +4081,8 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { } else for (s.ptys) |slot| { if (slot) |pt| if (pt.kill_at != 0) break true; } else false; - const polls = g.scroll_lag != 0 or g.scroll_delta != 0 or g.gamepad != null or s.test_mode or core.needs_frame or retiring; + const polls = g.scroll_lag != 0 or g.scroll_delta != 0 or g.gamepad != null or s.test_mode or core.needs_frame or retiring or + g.post.animating(core.settings.shader_animation); const minimized = !s.test_mode and c.SDL_GetWindowFlags(g.window) & (c.SDL_WINDOW_MINIMIZED | c.SDL_WINDOW_OCCLUDED) != 0; // A latency trace is fed on stdin, whose poll below paces the loop: // waiting here too would make every tick cost two frames. @@ -4157,6 +4167,10 @@ fn pollFrame(ctx: ?*anyopaque) void { const geom = windowCells(g); if (updateCoreResize(core, geom.cols, geom.rows, g.cell_w, g.cell_h, g.tagline_width, g.tagline_height)) resetScroll(g); stepScroll(g, core, s.gpa); + g.post.sync(s.gpa, s.io, g.device, g.swapchain_format, core); + // Between the core's frames, a chain that moves on its own redraws alone. + if (g.post.animating(core.settings.shader_animation) and !core.needs_frame and s.presented and + c.SDL_GetTicksNS() -| g.post.last_ns >= pardes.animation.frame_ns) redrawPost(g, s.gpa); } fn finishWindowOpacity(core: *pardes.Pardes, applied: *u8, failure: ?[]const u8) void { @@ -4216,15 +4230,13 @@ fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void { frame_started_ns = monoNs(); const frame = @constCast(surface); s.surface = frame; - const scene_requested = core.settings.scene_effects.crt or - core.settings.scene_effects.ripple or core.settings.scene_effects.glitch; + const scene_requested = g.post.ready(); if (!scene_requested) g.scene_failures = 0; s.presented = renderFrame( g, s.gpa, core, frame, - core.settings.scene_effects, core.settings.debug, ) catch |err| blk: { log.err("render: {t}", .{err}); @@ -5190,7 +5202,6 @@ fn renderFrame( gpa: std.mem.Allocator, core: ?*const pardes.Pardes, surface: *pardes.Surface, - scene_effects: pardes.animation.SceneEffect, debug_on: bool, ) !bool { if (surface.pointer_shape != g.pointer_shape) { @@ -5205,48 +5216,19 @@ fn renderFrame( defer if (!command_consumed) { _ = c.SDL_SubmitGPUCommandBuffer(cmd); }; - var sw: u32 = 0; - var sh: u32 = 0; - var target: *c.SDL_GPUTexture = undefined; - if (g.capture) { - sw = @as(u32, surface.cols) * g.cell_w + g.capture_pad; - sh = @as(u32, surface.rows) * g.cell_h + g.capture_pad; - if (sw == 0 or sh == 0) { - command_consumed = true; - _ = c.SDL_SubmitGPUCommandBuffer(cmd); - return false; - } - try ensureCaptureTexture(g, sw, sh); - target = g.capture_tex.?; - } else if (g.soft_present) { - var pw: c_int = 0; - var ph: c_int = 0; - if (!c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph) or pw <= 0 or ph <= 0) { - command_consumed = true; - _ = c.SDL_SubmitGPUCommandBuffer(cmd); - return false; - } - sw = @intCast(pw); - sh = @intCast(ph); - 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)) { - command_consumed = true; - _ = c.SDL_SubmitGPUCommandBuffer(cmd); - return false; - } - target = swap_tex orelse { // swapchain busy: skip the frame - command_consumed = true; - _ = c.SDL_SubmitGPUCommandBuffer(cmd); - return false; - }; - } + const frame_target = try acquireTarget(g, cmd, @as(u32, surface.cols) * g.cell_w + g.capture_pad, @as(u32, surface.rows) * g.cell_h + g.capture_pad) orelse { + command_consumed = true; + _ = c.SDL_SubmitGPUCommandBuffer(cmd); + return false; + }; + const target = frame_target.texture; + const sw = frame_target.w; + const sh = frame_target.h; const win_w: f32 = @floatFromInt(sw); const win_h: f32 = @floatFromInt(sh); - var scene_on = scene_effects.crt or scene_effects.ripple or scene_effects.glitch; + // The post chain runs over a local frame, from the scene texture. + var scene_on = core != null and g.post.ready(); if (scene_on) ensureSceneTexture(g, sw, sh) catch { g.scene_target_failed = true; scene_on = false; @@ -5559,64 +5541,87 @@ fn renderFrame( drawOverlayGpu(g, cmd, rp, overlay_count); c.SDL_EndGPURenderPass(rp); - var rendered_scene: crt.Frame = .{}; if (scene_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 crt_pass = c.SDL_BeginGPURenderPass(cmd, &crt_target, 1, null); - c.SDL_BindGPUGraphicsPipeline(crt_pass, g.crt_pipeline); - const samp_binding = c.SDL_GPUTextureSamplerBinding{ .texture = g.scene_tex.?, .sampler = g.linear_sampler }; - c.SDL_BindGPUFragmentSamplers(crt_pass, 0, &samp_binding, 1); - const now_ns = c.SDL_GetTicksNS(); - const time_wrap_ns: u64 = 4096 * std.time.ns_per_s; - const time_seconds: f32 = @floatCast( - @as(f64, @floatFromInt(now_ns % time_wrap_ns)) / @as(f64, std.time.ns_per_s), - ); - rendered_scene = .{ - .effects = .{ - .crt = scene_effects.crt, - .ripple = scene_effects.ripple, - .glitch = scene_effects.glitch, - }, - .time_seconds = time_seconds, - }; - const crt_uniforms = [8]f32{ - time_seconds, - @floatFromInt((now_ns / (std.time.ns_per_s / 60)) % 65_536), - @floatFromInt(sw), - @floatFromInt(sh), - @floatFromInt(@intFromBool(scene_effects.crt)), - @floatFromInt(@intFromBool(scene_effects.ripple)), - @floatFromInt(@intFromBool(scene_effects.glitch)), - 0, + if (core) |p| { + var boxes: [max_cursors]CursorBox = undefined; + const cursors = if (cursors_shown) cursorBoxes(g, surface, layout, win_h, &boxes) else 0; + g.post.describe(.{ + .cursor = if (cursors > 0) boxes[0] else null, + .background = chrome.page orelse bg_default, + .foreground = chrome.fg orelse fg_default, + .selection_foreground = p.theme().sel_fg, + .selection_background = p.theme().sel_bg, + }); + } + const chain_zone = tracy.zone(@src(), "post chain"); + defer chain_zone.end(); + g.post.draw(g.device, cmd, g.linear_sampler, g.swapchain_format, g.scene_tex.?, target, sw, sh, c.SDL_GetTicksNS(), background_opacity >= 1) catch { + g.scene_target_failed = true; }; - c.SDL_PushGPUFragmentUniformData(cmd, 0, &crt_uniforms, @sizeOf(@TypeOf(crt_uniforms))); - c.SDL_DrawGPUPrimitives(crt_pass, 3, 1, 0, 0); - c.SDL_EndGPURenderPass(crt_pass); } + command_consumed = true; + const shown = try finishFrame(g, gpa, cmd, frame_target, if (groups.tracks != 0) groups.items[1].track.?.frame else null); + if (shown and groups.tracks == 0) rememberPresentedImages(g, gpa, surface); + return shown; +} + +const Target = struct { texture: *c.SDL_GPUTexture, w: u32, h: u32 }; + +/// What this frame draws into: the capture texture in test mode (the grid's +/// size plus PARDES_TEST_PAD) or with a soft presenter, else the swapchain's +/// image; null when there is none to be had this time. +fn acquireTarget(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, capture_w: u32, capture_h: u32) !?Target { if (g.capture) { - command_consumed = true; - try captureFrame(g, gpa, cmd, target, sw, sh, if (groups.tracks != 0) groups.items[1].track.?.frame else null); - g.presented_scene = rendered_scene; - if (groups.tracks == 0) rememberPresentedImages(g, gpa, surface); + if (capture_w == 0 or capture_h == 0) return null; + try ensureCaptureTexture(g, capture_w, capture_h); + return .{ .texture = g.capture_tex.?, .w = capture_w, .h = capture_h }; + } + if (g.soft_present) { + var pw: c_int = 0; + var ph: c_int = 0; + if (!c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph) or pw <= 0 or ph <= 0) return null; + try ensureCaptureTexture(g, @intCast(pw), @intCast(ph)); + return .{ .texture = g.capture_tex.?, .w = @intCast(pw), .h = @intCast(ph) }; + } + var swap_tex: ?*c.SDL_GPUTexture = null; + var sw: u32 = 0; + var sh: u32 = 0; + if (!c.SDL_AcquireGPUSwapchainTexture(cmd, g.window, &swap_tex, &sw, &sh)) return null; + // A busy swapchain skips the frame. + return .{ .texture = swap_tex orelse return null, .w = sw, .h = sh }; +} + +/// Submits the frame: captured, handed to the soft presenter, or presented. +fn finishFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, target: Target, transition: ?u16) !bool { + if (g.capture) { + try captureFrame(g, gpa, cmd, target.texture, target.w, target.h, transition); return true; } if (g.soft_present) { - command_consumed = true; - try softPresentFrame(g, cmd, target, sw, sh); - g.presented_scene = rendered_scene; - if (groups.tracks == 0) rememberPresentedImages(g, gpa, surface); + try softPresentFrame(g, cmd, target.texture, target.w, target.h); return true; } - command_consumed = true; - const submitted = c.SDL_SubmitGPUCommandBuffer(cmd); - if (submitted) { - g.presented_scene = rendered_scene; - if (groups.tracks == 0) rememberPresentedImages(g, gpa, surface); + return c.SDL_SubmitGPUCommandBuffer(cmd); +} + +/// Redraw level A (docs §5.5): the post chain alone, over the frame the core +/// last drew, for a pass that moves on its own. No core render, no cell. +fn redrawPost(g: *Gui, gpa: std.mem.Allocator) void { + const scene = g.scene_tex orelse return; + const cmd = c.SDL_AcquireGPUCommandBuffer(g.device) orelse return; + const target = (acquireTarget(g, cmd, g.scene_tex_w, g.scene_tex_h) catch null) orelse { + _ = c.SDL_SubmitGPUCommandBuffer(cmd); + return; + }; + // A window the retained frame no longer fits waits for the core's. + if (target.w != g.scene_tex_w or target.h != g.scene_tex_h) { + _ = c.SDL_SubmitGPUCommandBuffer(cmd); + return; } - return submitted; + const zone = tracy.zone(@src(), "post chain redraw"); + g.post.draw(g.device, cmd, g.linear_sampler, g.swapchain_format, scene, target.texture, target.w, target.h, c.SDL_GetTicksNS(), backgroundOpacity(g.applied_window_opacity) >= 1) catch {}; + zone.end(); + _ = finishFrame(g, gpa, cmd, target, null) catch {}; } const ResolvedCell = struct { @@ -7016,25 +7021,7 @@ fn makeImagePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFor return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate; } -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, 0); - defer c.SDL_ReleaseGPUShader(device, vs); - const fs = try makeShader(device, crt_frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 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, n_uniform_buffers: u32) !*c.SDL_GPUShader { +pub fn makeShader(device: *c.SDL_GPUDevice, code: []const u8, stage: c.SDL_GPUShaderStage, n_samplers: u32, n_uniform_buffers: u32) !*c.SDL_GPUShader { var info = std.mem.zeroes(c.SDL_GPUShaderCreateInfo); info.code = code.ptr; info.code_size = code.len; @@ -7519,25 +7506,32 @@ test "decor is read from the regions, each pane's in its own group" { try std.testing.expectEqual(@as(u32, 0), groups.items[groups.ink].decor_count); } -/// The bar cursors: the grid's where no layer has the cell, and each layer's -/// at its own pitch. -fn cursorDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, layout: CellLayout, win_w: f32, win_h: f32) !void { +const CursorBox = Post.Frame.Cursor; +const max_cursors = 1 + pardes.MAX_PANES + pardes.MAX_TAG_LAYERS; + +/// Every cursor on the frame where it is drawn, in window pixels: the grid's +/// where no layer has its cell, and each layer's at that layer's pitch. A bar +/// is an eighth of its cell wide, a block the whole cell. +fn cursorBoxes(g: *const Gui, surface: *const pardes.Surface, layout: CellLayout, win_h: f32, out: *[max_cursors]CursorBox) usize { const chrome = &surface.chrome; + const page = chrome.page orelse bg_default; + var n: usize = 0; if (surface.cursor) |cursor| { - if (cursor.bar and cursor.x < surface.cols and cursor.y < surface.rows and bodyLayerAt(surface, cursor.x, cursor.y) == null and tagLayerIn(surface.tagLayers(), cursor.x, cursor.y) == null) { + if (cursor.x < surface.cols and cursor.y < surface.rows and bodyLayerAt(surface, cursor.x, cursor.y) == null and tagLayerIn(surface.tagLayers(), cursor.x, cursor.y) == null) { const cell = &surface.cells[@as(usize, cursor.y) * surface.cols + cursor.x]; const role = cellFontRole(cell); const width: f32 = if (role == .tagline) @floatFromInt(g.tagline_width) else layout.w; const height = std.math.clamp(if (role == .tagline) @as(f32, @floatFromInt(g.tagline_height)) else layout.h, 1.0, layout.h); const x0 = @as(f32, @floatFromInt(cursor.x)) * layout.w; const y0 = @as(f32, @floatFromInt(cursor.y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(cursor.y, win_h, @intFromFloat(layout.h), @intFromFloat(height)))); - try addDecor(g, gpa, x0, y0, x0 + @max(1.0, width / 8.0), y0 + height, cursorColor(chrome, cell, role), 1, null, win_w, win_h); + out[n] = .{ .x0 = x0, .y0 = y0, .x1 = x0 + if (cursor.bar) @max(1.0, width / 8.0) else width, .y1 = y0 + height, .rgb = cursorColor(chrome, cell, role), .text = page, .bar = cursor.bar }; + n += 1; } } for (surface.bodyLayers()) |*layer| { if (layer.rows == 0) continue; const cursor = layer.cursor orelse continue; - if (!cursor.bar or cursor.y >= layer.rows or cursor.x >= layer.cols) continue; + if (cursor.y >= layer.rows or cursor.x >= layer.cols) continue; const left = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w; const bottom = @as(f32, @floatFromInt(layer.viewport.y + layer.viewport.h)) * layout.h; const top = layer.rowTop(cursor.y, layout.h, @floatFromInt(g.tagline_height)); @@ -7547,12 +7541,13 @@ fn cursorDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, const width: f32 = if (role == .tagline) @floatFromInt(g.tagline_width) else layout.w; const x = left + @as(f32, @floatFromInt(cursor.x)) * width; const cell = &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x]; - try addDecor(g, gpa, x, top, x + @max(1, width / 8), @min(bottom, top + height), cursorColor(chrome, cell, role), 1, null, win_w, win_h); + out[n] = .{ .x0 = x, .y0 = top, .x1 = x + if (cursor.bar) @max(1, width / 8) else width, .y1 = @min(bottom, top + height), .rgb = cursorColor(chrome, cell, role), .text = page, .bar = cursor.bar }; + n += 1; } for (surface.tagLayers()) |*layer| { if (layer.rows == 0) continue; const cursor = layer.cursor orelse continue; - if (!cursor.bar or cursor.x >= layer.cols or cursor.y >= layer.rows) continue; + if (cursor.x >= layer.cols or cursor.y >= layer.rows) continue; const width: f32 = @floatFromInt(g.tagline_width); const left = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w; const right = left + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w; @@ -7560,8 +7555,17 @@ fn cursorDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, if (x >= right) continue; const y = layer.viewport.y + cursor.y; const top = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, win_h, g.cell_h, g.tagline_height))); - try addDecor(g, gpa, x, top, @min(right, x + @max(1, width / 8)), top + @as(f32, @floatFromInt(g.tagline_height)), cursorColor(chrome, &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x], .tagline), 1, null, win_w, win_h); + out[n] = .{ .x0 = x, .y0 = top, .x1 = @min(right, x + if (cursor.bar) @max(1, width / 8) else width), .y1 = top + @as(f32, @floatFromInt(g.tagline_height)), .rgb = cursorColor(chrome, &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x], .tagline), .text = page, .bar = cursor.bar }; + n += 1; } + return n; +} + +/// The bar cursors as decor; a block cursor is its cell's own colours. +fn cursorDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, layout: CellLayout, win_w: f32, win_h: f32) !void { + var boxes: [max_cursors]CursorBox = undefined; + for (boxes[0..cursorBoxes(g, surface, layout, win_h, &boxes)]) |box| + if (box.bar) try addDecor(g, gpa, box.x0, box.y0, box.x1, box.y1, box.rgb, 1, null, win_w, win_h); } /// The triangle overlay: the workspace pet and the touch debug view, over diff --git a/src/macos.zig b/src/macos.zig index 4d8f2883..0bad86ee 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -850,16 +850,18 @@ export fn pardes_should_quit() bool { return st.core.quit; } -fn encodeSceneEffects(effects: animation.SceneEffect) u32 { +// The Metal shell keeps its own CRT, ripple and glitch kernel: the bundled +// passes of the GUI's post chain, as flags. +fn encodeSceneEffects(post: *const pardes.config.Runtime.Post) u32 { var flags: u32 = 0; - if (effects.crt) flags |= scene_flag_crt; - if (effects.ripple) flags |= scene_flag_ripple; - if (effects.glitch) flags |= scene_flag_glitch; + if (post.has(.crt)) flags |= scene_flag_crt; + if (post.has(.ripple)) flags |= scene_flag_ripple; + if (post.has(.glitch)) flags |= scene_flag_glitch; return flags; } fn currentSceneFlags(st: *const State) u32 { - return encodeSceneEffects(st.core.settings.scene_effects); + return encodeSceneEffects(&st.core.settings.post); } fn advanceSceneClock(st: *State, elapsed_ns: u64) void { @@ -2912,10 +2914,13 @@ test "pardes.h matches the Zig boundary" { test "scene effect flags and display clock are compact and independent" { const expectEqual = std.testing.expectEqual; - try expectEqual(@as(u32, 0), encodeSceneEffects(.{})); - try expectEqual(scene_flag_crt, encodeSceneEffects(.{ .crt = true })); - try expectEqual(scene_flag_ripple, encodeSceneEffects(.{ .ripple = true })); - try expectEqual(scene_flag_glitch, encodeSceneEffects(.{ .glitch = true })); + var post: pardes.config.Runtime.Post = .{}; + try expectEqual(@as(u32, 0), encodeSceneEffects(&post)); + _ = post.set(.crt, "", true); + try expectEqual(scene_flag_crt, encodeSceneEffects(&post)); + _ = post.set(.ripple, "", true); + _ = post.set(.glitch, "", true); + try expectEqual(scene_flag_crt | scene_flag_ripple | scene_flag_glitch, encodeSceneEffects(&post)); try expectEqual( scene_flag_crt | scene_flag_ripple | scene_flag_glitch, encodeSceneEffects(.{ .crt = true, .ripple = true, .glitch = true }), diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index c1f78bab..ed10dd70 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -263,7 +263,9 @@ fn writeSettings(p: *Pardes, w: *std.Io.Writer) !void { // Bare, it names the default directory. .dump_dir => if (s.dump_dir.len > 0) try w.print("{s} {s}\n", .{ word, s.dump_dir.get() }) else try w.print("{s}\n", .{word}), .transition => |effect| try w.print("{s} {s}\n", .{ word, if (s.panel_transition == effect) "on" else "off" }), - .scene => |effect| try w.print("{s} {s}\n", .{ word, if (@field(s.scene_effects, @tagName(effect))) "on" else "off" }), + .scene => |effect| try w.print("{s} {s}\n", .{ word, if (s.post.has(effect)) "on" else "off" }), + .shader => for (s.post.list()) |entry| if (entry.scene == null) try w.print("{s} {s}\n", .{ word, entry.path.get() }), + .shader_animation => try w.print("{s} {s}\n", .{ word, @tagName(s.shader_animation) }), } } try w.writeAll("LocationsConfig "); diff --git a/src/pardes.zig b/src/pardes.zig index 616747fa..5fa3307b 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -584,18 +584,10 @@ test "startup config runs builtin lines in order and isolates bad lines" { }; } -test "scene effects keep the idle frontend animation clock active" { +test "a post chain keeps no core clock running: the shell animates it" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); - try std.testing.expect(!p.animationActive()); - - p.settings.scene_effects.crt = true; - try std.testing.expect(p.animationActive()); - p.settings.scene_effects = .{ .ripple = true }; - try std.testing.expect(p.animationActive()); - p.settings.scene_effects = .{ .glitch = true }; - try std.testing.expect(p.animationActive()); - p.settings.scene_effects = .{}; + try std.testing.expect(p.settings.post.set(.crt, "", true)); try std.testing.expect(!p.animationActive()); } @@ -5443,10 +5435,10 @@ pub const Pardes = struct { look.cancelLookHover(p); } - /// A native scene pass proved unavailable. Config reads these exact bits, - /// and the idle-clock predicate stops immediately once they are clear. + /// A native post pass proved unavailable: the chain is emptied, which + /// Config then reports. pub fn disableSceneEffects(p: *Pardes) void { - p.settings.scene_effects = .{}; + p.settings.post.clear(); } // ---- doc panes ---- @@ -5744,8 +5736,6 @@ pub const Pardes = struct { pub fn nextWake(p: *const Pardes) ?u64 { const next = p.stepped_ns + animation.frame_ns; if (p.chrome_animation.isActive() or p.presentation.animating()) return next; - const scene = p.settings.scene_effects; - if (scene.crt or scene.ripple or scene.glitch) return next; var frames: ?u64 = null; if (p.look_hover_wait) |waiting| { const delay = config.look_preview_delay_frames orelse return next; @@ -6953,8 +6943,8 @@ test "core animation keeps wall time at 60, 120 and 144 Hz and across a stall" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); const frame = animation.frame_ns; - // Something that animates for as long as it is on. - p.settings.scene_effects.crt = true; + // Something that animates for as long as the test wants: the theme + // fade, held at its start before every step. for ([_]u64{ 60, 120, 144 }) |hz| { const start: u64 = 5 * std.time.ns_per_s; p.clock_started = false; @@ -6963,6 +6953,7 @@ test "core animation keeps wall time at 60, 120 and 144 Hz and across a stall" { var at: u64 = 1; while (at <= hz) : (at += 1) { const before = p.stepped_ns; + p.chrome_animation.step = 0; p.advance(start + at * std.time.ns_per_s / hz); const taken = (p.stepped_ns - before + frame / 2) / frame; // Never two steps in one display frame: that is a visible hitch. @@ -6979,9 +6970,10 @@ test "core animation keeps wall time at 60, 120 and 144 Hz and across a stall" { } // A stall of minutes is not minutes of frames. const before = p.stepped_ns; + p.chrome_animation.step = 0; p.advance(before + 600 * std.time.ns_per_s); try std.testing.expectEqual(before + 600 * std.time.ns_per_s, p.stepped_ns); - p.settings.scene_effects.crt = false; + p.chrome_animation.step = animation.transition_steps; p.advance(p.stepped_ns + frame); try std.testing.expectEqual(@as(?u64, null), p.nextWake()); } -- cgit v1.3 From a007457cb007b03f310bca55c339e8cd5d9baafc Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:44:51 -0300 Subject: Ripple and Glitch are gone; Crt stays The user tried them live and wants them removed entirely: the builtins, their levels, config keys and leader paths (tR, tg), their slots in the built-in chain, their shaders (source and prebuilt), EffectCode paths, and on macOS their scene flags (pardes.h), the Metal kernel's coordinate warps and the Swift pointer mapping that mirrored them. Docs say so (decision 8 in docs/render-pipeline.md); config.md documents Crt's levels, Shader and ShaderAnimation instead. Shared files touched: macos.zig (flags). Not touched: pardes.zig, Messages.zig, mouse.zig, gui.zig, detached/*. --- build.zig | 5 +- docs/config.md | 15 ++-- docs/design.typ | 5 +- docs/macos.md | 21 ++--- docs/render-pipeline.md | 19 ++-- docs/web.md | 2 +- shaders/crt.ci.metal | 75 ++-------------- shaders/post/glitch.glsl | 64 ------------- shaders/post/ripple.glsl | 52 ----------- shaders/prebuilt/post-glitch.frag.glsl | 138 ----------------------------- shaders/prebuilt/post-glitch.frag.spv | Bin 10764 -> 0 bytes shaders/prebuilt/post-ripple.frag.glsl | 126 -------------------------- shaders/prebuilt/post-ripple.frag.spv | Bin 9096 -> 0 bytes src/config.zig | 22 ++--- src/effect_sources.zig | 8 +- src/gui/Post.zig | 8 +- src/macos.zig | 17 +--- src/macos/Sources/PardesView.swift | 2 +- src/macos/Sources/ScenePostprocessor.swift | 48 +--------- src/macos/pardes.h | 5 +- test/output.zig | 2 +- 21 files changed, 61 insertions(+), 573 deletions(-) delete mode 100644 shaders/post/glitch.glsl delete mode 100644 shaders/post/ripple.glsl delete mode 100644 shaders/prebuilt/post-glitch.frag.glsl delete mode 100644 shaders/prebuilt/post-glitch.frag.spv delete mode 100644 shaders/prebuilt/post-ripple.frag.glsl delete mode 100644 shaders/prebuilt/post-ripple.frag.spv (limited to 'build.zig') diff --git a/build.zig b/build.zig index c1117593..97d51a51 100644 --- a/build.zig +++ b/build.zig @@ -51,10 +51,9 @@ const gui_shaders = [_][]const u8{ "overlay.vert", "overlay.frag", "image.vert", "image.frag", "post.vert", - // The bundled post passes: shaders/post/.glsl behind the Shadertoy + // The bundled post pass: shaders/post/crt.glsl behind the Shadertoy // prefix (shaderSource). - "post-crt.frag", "post-ripple.frag", - "post-glitch.frag", + "post-crt.frag", }; pub fn build(b: *std.Build) void { diff --git a/docs/config.md b/docs/config.md index 9c51f329..16fd59e5 100644 --- a/docs/config.md +++ b/docs/config.md @@ -513,18 +513,19 @@ immediately and remains the one authoritative geometry. DOM web intentionally does not expose these builtins: its renderer is selectable HTML/CSS and has no canvas or shader stage. -The scene effects are independent switches and can be combined: +The scene effect is `Crt`, at a level from 0 (off) to 3; `on` is 2: ```text Crt -Ripple -Glitch +Crt 3 ``` -They share one full-scene shader pass in SDL and macOS. With all three off the -pass is bypassed. CRT works in linear light with restrained scanlines, mask, -bloom, curvature, and noise rather than remapping the theme to a strong fixed -palette; Ripple and Glitch primarily perturb sample coordinates. +The SDL GUI runs it as the bundled pass of its post chain, which also takes +Shadertoy files written for ghostty (`Shader ~/crt.glsl`, `Shader off`), and +`ShaderAnimation off|on|always` says when the chain animates on its own. With +the chain empty the pass is bypassed. CRT works in linear light with restrained +scanlines, mask, bloom and vignette, and no curvature, so clicks land where +they are drawn. `EffectCode PanelAscii` or `EffectCode Crt` lists the current backend's build-embedded source paths under `/virtual`. Look opens each full file; diff --git a/docs/design.typ b/docs/design.typ index 234c3692..caa35b37 100644 --- a/docs/design.typ +++ b/docs/design.typ @@ -913,8 +913,9 @@ while its geometry moves and placed again on the last active sample. SDL supplies old/new glyph data, diff flags, final/presented boxes, and effect parameters to the glyph and native-image shaders. Pixel attachments bypass ASCII because they have no character byte. macOS passes the same records across its -plain C ABI and composites old/new panel images in Metal and Core Image. Scene -`Crt`, `Ripple` and `Glitch` bits share one full-window pass in each native GUI. +plain C ABI and composites old/new panel images in Metal and Core Image. The +scene `Crt` is one full-window pass in each native GUI; the SDL GUI's post +chain also runs Shadertoy files (`Shader`). DOM web is a separate platform, not a shader GUI: retaining selectable HTML and CSS is more important than duplicating the renderer in canvas, so it exposes diff --git a/docs/macos.md b/docs/macos.md index e104d3a6..5166e2d5 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -102,8 +102,8 @@ asking for a thin insert caret instead of a block. `Surface.images` crosses separately as `pardes_frame_images` / `pardes_frame_image_list` — see "Pixel attachments" below. -`pardes_scene` is the matching full-window snapshot: a bit each for CRT, -ripple, and glitch, plus the 60 Hz time/frame that animates them. It is returned +`pardes_scene` is the matching full-window snapshot: a bit for CRT, plus the +60 Hz time/frame that animates it. It is returned by value, so the renderer never holds a pointer into live core state. A zero flags word is also the fast-path contract: draw the CoreText frame directly. @@ -662,8 +662,7 @@ longer leaves a dark titlebar over a cream grid. ## Scene effects -`Crt`, `Ripple`, and `Glitch` are three switches over one postprocess, not three -stacked filters. Their canonical macOS source is `shaders/crt.ci.metal`, which +`Crt` is one full-window postprocess. Its canonical macOS source is `shaders/crt.ci.metal`, which the build installs as `pardes.app/Contents/Resources/crt.ci.metal`. The Swift shell loads that exact asset with `CIKernel.kernels(withMetalString:)` and runs it through a `CIContext` created from the system Metal device. The source is @@ -673,14 +672,12 @@ The ordinary CoreText/attachment/cursor pass is one function. With any scene bit or panel track it targets a retained, backing-scale bitmap; kernels then sample the complete frame into the flipped view. PDFs, image panes, taglines, rules, and the caret therefore receive the same effect. With every bit off and -no panel track the bitmap and Core Image context are bypassed entirely. Ripple -and glitch alter sample coordinates only, preserving theme colors; CRT works +no panel track the bitmap and Core Image context are bypassed entirely. CRT works in linear light with restrained bloom, scan/mask, vignette, hum, and noise instead of applying a broad color remap. The scene kernel alone receives a `clampedToExtent` image and the final output -is cropped back to the original finite extent. Coordinate tears and chroma -samples therefore clamp to the edge exactly like SDL's scene sampler instead +is cropped back to the original finite extent. Chroma samples therefore clamp to the edge exactly like SDL's scene sampler instead of acquiring transparent-black seams from Core Image's finite source image. The Zig side owns the clock. `pardes_animation_tick` increments its wrapped @@ -689,8 +686,8 @@ from that integer. Input bursts cannot accelerate the shader. Pointer input follows the same destination-to-source transform as the last presented scene frame before it is divided by the cell metrics. The view keeps -that exact `pardes_scene_s` snapshot and mirrors the Metal barrel, ripple and -glitch sampling arithmetic in `ScenePostprocessor.sourcePoint`; pixels outside +that exact `pardes_scene_s` snapshot and mirrors the Metal barrel arithmetic +in `ScenePostprocessor.sourcePoint`; pixels outside the CRT tube have no cell. The resulting displayed-grid cell then reaches the core, whose panel-track mapping resolves it to canonical pane content. @@ -716,8 +713,8 @@ while `pardesComposedInCore` effects only clip the already-composed core cells; pixel attachments have no character value and pass through unchanged. Because the input is the finished bitmap rather than a glyph-only batch, backgrounds, glyphs, taglines, rules, the caret, PDF pages, and image -panes move and dissolve together. Scene CRT/ripple/glitch runs once after the -panel composition. +panes move and dissolve together. The scene CRT runs once after the panel +composition. With no scene bit and no panel track the retained bitmap, Core Image context, and Metal passes are bypassed. `EffectCode Panel*` links to this Metal diff --git a/docs/render-pipeline.md b/docs/render-pipeline.md index 2fd6045b..7c94fbea 100644 --- a/docs/render-pipeline.md +++ b/docs/render-pipeline.md @@ -505,16 +505,14 @@ Mirror ghostty 1.3.2 (`zig-pkg/ghostty-*/src/renderer/shadertoy.zig`, `custom-shader-animation`): off = redraw only when content changes; on = continuous while the window is focused; always = continuous. Continuous means the shell redraws its retained Surface (3.1), never a core tick or render. -- **Existing crt/ripple/glitch** become bundled Shadertoy files in - `shaders/post/` selected by the same builtins (decision 2: kept, polished); - `scene_effects` and `crt.frag.glsl`/`crt.zig` are deleted. Each bundled pass - takes a level, `Crt 0..3` (0 off, `on` the default 2, `off` and bare still - work), passed as `pardesLevel`: 2 is the polished rewrite, 3 at least as - strong as the old effects (pixels changed by more than 8 in a channel, - against a plain frame: Crt 18.3% vs 17.4% old, Ripple 11.1% vs 9.5%, Glitch - 9.3% a frame vs 8.1%); a Glitch level raises how often it slips as well as - how hard. The chain is - `Crt`, `Ripple`, `Glitch` and `Shader ` in the order they were put in +- **Existing crt** becomes a bundled Shadertoy file in `shaders/post/` under + the same builtin; `scene_effects` and `crt.frag.glsl`/`crt.zig` are deleted. + It takes a level, `Crt 0..3` (0 off, `on` the default 2, `off` and bare + still work), passed as `pardesLevel`: 2 is the polished rewrite, 3 at least + as strong as the old CRT (pixels changed by more than 8 in a channel against + a plain frame: 18.3% vs 17.4%). Ripple and Glitch were rewritten too, then + removed at the user's word after a live look (decision 8). The chain is + `Crt` and `Shader ` in the order they were put in (`Shader off` empties it of files), `ShaderAnimation off|on|always` (default on). The core keeps no clock for it: level A redraws are the shell's. @@ -905,3 +903,4 @@ goldens; ssh/tty byte budgets; wire version bump breaks mixed-version attach. 5. The animation pacing fix goes into the no-visual-change phase. 6. Cursor blink is on by default. 7. The bundled CRT has no barrel distortion, so clicks stay exact. +8. (After trying them live) Ripple and Glitch are removed entirely; Crt stays. diff --git a/docs/web.md b/docs/web.md index 3a68f958..bbbed8a3 100644 --- a/docs/web.md +++ b/docs/web.md @@ -42,7 +42,7 @@ shells, and two comptime capability flags say so once each. It exposes no `PanelAscii`, `PanelVertical`, `PanelEdges`, `PanelFall`, `PanelWave`, `PanelCurtain`, `PanelScramble`, `PanelType`) because `capabilities.panel_transitions` is `pardes.hosted`, and no -`Crt`/`Ripple`/`Glitch` because `capabilities.scene_shaders` is +`Crt` or `Shader` because `capabilities.scene_shaders` is `platform == .gui or platform == .macos` (`builtins.capabilities`; the words themselves carry those availabilities in `config.Runtime.settings`). Applying either faithfully would require a second canvas renderer and give up diff --git a/shaders/crt.ci.metal b/shaders/crt.ci.metal index c7e91a7e..9159af18 100644 --- a/shaders/crt.ci.metal +++ b/shaders/crt.ci.metal @@ -1,7 +1,6 @@ // Native macOS panel and scene postprocess. Panel kernels consume the core's -// plain tracks; the final full-window kernel ports crt.frag.glsl and combines -// CRT, ripple and glitch. Scene constants and sampling geometry stay in step -// with GLSL, while panel easing/noise stays in step with panel_animation.zig. +// plain tracks; the final full-window kernel is the CRT. Panel easing/noise +// stays in step with panel_animation.zig. // // The three entry points are `extern "C" [[stitchable]]`. Both halves are // load-bearing: this source is compiled at runtime by @@ -38,24 +37,6 @@ float pardesCellNoise(float2 serialParts, float colValue, float rowValue) { return float(x & 0xffffu) / 65535.0; } -float2 pardesSceneUV(float2 uv, float timeSeconds, float rippleOn, float glitchOn) { - if (rippleOn > 0.5) { - float2 centered = uv - 0.5; - float radius = length(centered); - // Reversed smoothstep edges are undefined in GLSL and Metal alike. - float envelope = 1.0 - smoothstep(0.04, 0.72, radius); - float wave = sin(radius * 42.0 - timeSeconds * 5.2); - uv += centered / max(radius, 0.00001) * wave * envelope * 0.0032; - } - if (glitchOn > 0.5) { - float band = floor(uv.y * 96.0); - float seed = pardesNoise(float2(band, floor(timeSeconds * 13.0))); - float tear = smoothstep(0.955, 1.0, seed); - uv.x += (pardesNoise(float2(seed, band)) - 0.5) * tear * 0.020; - } - return uv; -} - float4 pardesSceneSample( coreimage::sampler source, float2 origin, @@ -66,42 +47,13 @@ float4 pardesSceneSample( source.sample(source.transform(origin + uv * size))); } -// Keep this ordering identical to crt.frag.glsl's sceneSrgb: every caller -// supplies its own pre-warp coordinate, then ripple/glitch move that sample, -// then glitch splits its red/blue channels around the moved center. -float4 pardesSceneSrgb( - coreimage::sampler source, - float2 origin, - float2 size, - float2 uv, - float timeSeconds, - float rippleOn, - float glitchOn -) { - float2 warped = pardesSceneUV(uv, timeSeconds, rippleOn, glitchOn); - float4 center = pardesSceneSample(source, origin, size, warped); - if (glitchOn <= 0.5) return center; - - float band = floor(warped.y * 96.0); - float seed = pardesNoise(float2(band, floor(timeSeconds * 13.0))); - float tear = smoothstep(0.955, 1.0, seed); - float2 chroma = float2((0.35 + 1.25 * tear) / size.x, 0.0); - float4 red = pardesSceneSample(source, origin, size, warped + chroma); - float4 blue = pardesSceneSample(source, origin, size, warped - chroma); - return float4(red.r, center.g, blue.b, center.a); -} - float3 pardesSceneLinear( coreimage::sampler source, float2 origin, float2 size, - float2 uv, - float timeSeconds, - float rippleOn, - float glitchOn + float2 uv ) { - return coreimage::srgb_to_linear(pardesSceneSrgb( - source, origin, size, uv, timeSeconds, rippleOn, glitchOn).rgb); + return coreimage::srgb_to_linear(pardesSceneSample(source, origin, size, uv).rgb); } bool pardesInside(float2 p, float4 box) { @@ -242,8 +194,6 @@ extern "C" [[stitchable]] float4 pardesScene( float timeSeconds, float frame, float crtOn, - float rippleOn, - float glitchOn, float2 sceneSize, float2 sceneOrigin, coreimage::destination destination @@ -277,24 +227,18 @@ extern "C" [[stitchable]] float4 pardesScene( // transparent theme. if (crtOn > 0.5 && edge <= 0.0) return float4(0.0, 0.0, 0.0, 1.0); - float4 center = pardesSceneSrgb( - source, origin, size, uv, timeSeconds, rippleOn, glitchOn); + float4 center = pardesSceneSample(source, origin, size, uv); float3 scene = center.rgb; - // Ripple/glitch alone are coordinate effects. Returning the sampled sRGB - // values directly keeps exact theme colors exact. + // Without the CRT the scene passes through, exact. if (crtOn <= 0.5) { return coreimage::premultiply(float4(clamp(scene, 0.0, 1.0), center.a)); } float3 centerLinear = coreimage::srgb_to_linear(scene); float2 radialChroma = cc * (chromaPx * r2) * texel; - float3 redSample = pardesSceneLinear( - source, origin, size, uv + radialChroma, - timeSeconds, rippleOn, glitchOn); - float3 blueSample = pardesSceneLinear( - source, origin, size, uv - radialChroma, - timeSeconds, rippleOn, glitchOn); + float3 redSample = pardesSceneLinear(source, origin, size, uv + radialChroma); + float3 blueSample = pardesSceneLinear(source, origin, size, uv - radialChroma); float3 col = float3( redSample.r, centerLinear.g, @@ -308,8 +252,7 @@ extern "C" [[stitchable]] float4 pardesScene( float2(0.0, bloom.y), float2(0.0, -bloom.y) }; for (uint i = 0; i < 4; i++) { - // Deliberately raw, like GLSL brightPass: bloom belongs to the source - // image and does not recursively warp through the scene effects. + // Deliberately raw, like GLSL brightPass. float4 sampleValue = pardesSceneSample( source, origin, size, uv + bloomOffsets[i]); float3 linearValue = coreimage::srgb_to_linear(sampleValue.rgb); diff --git a/shaders/post/glitch.glsl b/shaders/post/glitch.glsl deleted file mode 100644 index 8f420d9c..00000000 --- a/shaders/post/glitch.glsl +++ /dev/null @@ -1,64 +0,0 @@ -// Glitch: now and then the signal slips. At the default level, every couple -// of seconds a short -// burst tears a few horizontal bands sideways and pulls their red and blue -// apart; the bands are sized in pixels and the burst eases in and out over -// a fraction of a second, so it reads as one event rather than a flicker, -// and it looks the same at 60 or 144 Hz because every step is keyed to -// iTime, not to frames. Between bursts the page is left exactly as it is. -// A torn band's brightness dips in linear light. It is a Shadertoy file: -// pardes puts its prefix before it. - -// Each at levels 1, 2 and 3 (`Glitch 1`..`Glitch 3`); 2 is the default. A -// higher level slips more often and harder. -float period() { return pardesByLevel(4.5, 2.4, 0.55); } // seconds between bursts, on average -float burst() { return pardesByLevel(0.2, 0.28, 0.5); } // seconds a burst lasts -float stepHz() { return pardesByLevel(20.0, 24.0, 30.0); } // how often a burst re-tears -float shiftPx() { return pardesByLevel(8.0, 14.0, 34.0); } -float splitPx() { return pardesByLevel(1.5, 2.5, 6.0); } -float dip() { return pardesByLevel(0.08, 0.12, 0.22); } -float tearFrom() { return pardesByLevel(0.8, 0.72, 0.35); } // how few bands tear - -float hash(vec2 p) { - vec3 q = fract(vec3(p.xyx) * 0.1031); - q += dot(q, q.yzx + 33.33); - return fract((q.x + q.y) * q.z); -} - -vec3 toLinear(vec3 c) { - return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); -} - -vec3 toSrgb(vec3 c) { - c = max(c, vec3(0.0)); - return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308))); -} - -void mainImage(out vec4 fragColor, in vec2 fragCoord) { - vec2 size = iResolution.xy; - vec2 uv = fragCoord / size; - // Which burst window this is, when in it the burst starts, and how far - // into the burst we are: 0 outside one. - float window = floor(iTime / period()); - float start = window * period() + hash(vec2(window, 7.0)) * (period() - burst()); - float t = (iTime - start) / burst(); - float live = step(0.0, t) * step(t, 1.0); - float envelope = live * sin(3.14159265 * clamp(t, 0.0, 1.0)); - if (envelope <= 0.0) { - fragColor = texture(iChannel0, uv); - return; - } - // Bands of 6 to 40 px, re-cut at stepHz() during the burst; about one - // band in four tears. - float tick = floor(iTime * stepHz()); - float band_px = mix(6.0, 40.0, hash(vec2(tick, 3.0))); - float band = floor(fragCoord.y / band_px); - float roll = hash(vec2(band, tick)); - float tear = smoothstep(tearFrom(), tearFrom() + 0.18, roll) * envelope; - float shift = (hash(vec2(tick, band + 11.0)) - 0.5) * 2.0 * shiftPx() * tear; - vec2 at = vec2(fragCoord.x + shift, fragCoord.y); - vec2 split = vec2(splitPx() * tear, 0.0); - vec4 center = texture(iChannel0, at / size); - vec3 col = vec3(texture(iChannel0, (at + split) / size).r, center.g, texture(iChannel0, (at - split) / size).b); - col = toSrgb(toLinear(col) * (1.0 - dip() * tear)); - fragColor = vec4(col, center.a); -} diff --git a/shaders/post/ripple.glsl b/shaders/post/ripple.glsl deleted file mode 100644 index 3236d339..00000000 --- a/shaders/post/ripple.glsl +++ /dev/null @@ -1,52 +0,0 @@ -// Ripple: the page as a still pool a slow swell crosses. Rings travel out -// from the middle of the window at a fixed speed in pixels, so they look -// the same at any size and frame rate; at the default level the displacement -// is at most a couple of pixels, so text stays readable, and it eases in over its first half -// second instead of snapping on. Each ring catches the light a little on its -// rising side, in linear light, and the result is dithered so the shading -// never bands. It is a Shadertoy file: pardes puts its prefix before it. - -const float TAU = 6.28318530718; -const float EASE_IN = 0.5; // seconds - -// Each at levels 1, 2 and 3 (`Ripple 1`..`Ripple 3`); 2 is the default. -float wavelengthPx() { return pardesByLevel(170.0, 150.0, 120.0); } -float speedPx() { return pardesByLevel(40.0, 55.0, 80.0); } // outward, per second -float amplitudePx() { return pardesByLevel(1.2, 2.2, 5.0); } -float shade() { return pardesByLevel(0.02, 0.035, 0.09); } // light on a slope, linear -float reachEnd() { return pardesByLevel(0.85, 0.95, 1.3); } // how far out it dies away - -vec3 toLinear(vec3 c) { - return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); -} - -vec3 toSrgb(vec3 c) { - c = max(c, vec3(0.0)); - return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308))); -} - -float ign(vec2 p) { - return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); -} - -void mainImage(out vec4 fragColor, in vec2 fragCoord) { - vec2 size = iResolution.xy; - vec2 center = 0.5 * size; - vec2 offset = fragCoord - center; - float radius = length(offset); - float reach = 0.5 * length(size); - // Strongest a third of the way out, fading to nothing at the middle - // and at the edges, so the window's frame never wobbles. - float envelope = smoothstep(0.0, 0.18, radius / reach) * (1.0 - smoothstep(0.35, reachEnd(), radius / reach)); - float ease = smoothstep(0.0, EASE_IN, iTime); - float phase = (radius - iTime * speedPx()) * TAU / wavelengthPx(); - float height = sin(phase); - float slope = cos(phase); - vec2 dir = offset / max(radius, 1e-3); - vec2 moved = fragCoord + dir * height * amplitudePx() * envelope * ease; - - vec4 s = texture(iChannel0, moved / size); - vec3 col = toLinear(s.rgb) * (1.0 + shade() * slope * envelope * ease); - vec3 srgb = toSrgb(col) + (ign(fragCoord + float(iFrame % 64) * 5.588238) - 0.5) / 255.0; - fragColor = vec4(clamp(srgb, 0.0, 1.0), s.a); -} diff --git a/shaders/prebuilt/post-glitch.frag.glsl b/shaders/prebuilt/post-glitch.frag.glsl deleted file mode 100644 index 7b288237..00000000 --- a/shaders/prebuilt/post-glitch.frag.glsl +++ /dev/null @@ -1,138 +0,0 @@ -#version 450 -// pardes's Shadertoy prefix (docs/render-pipeline.md §6), put before every -// post pass: a bundled one in shaders/post/ at build time, a user's file at -// run time. The uniform names, types and std140 order are ghostty 1.3.2's -// (src/renderer/shaders/shadertoy_prefix.glsl), so a shader written for -// ghostty runs here; only the bindings are SDL GPU's (a fragment shader's -// textures in set 2, its uniform blocks in set 3). Y is down everywhere, as -// on ghostty's Metal renderer: fragCoord, iChannel0's uv and the cursor -// rectangles share one top-left space, and iCurrentCursor is (left x, -// BOTTOM-edge y, width, height) in framebuffer pixels. - -layout(set = 3, binding = 0, std140) uniform Globals { - uniform vec3 iResolution; - uniform float iTime; - uniform float iTimeDelta; - uniform float iFrameRate; - uniform int iFrame; - uniform float iChannelTime[4]; - uniform vec3 iChannelResolution[4]; - uniform vec4 iMouse; - uniform vec4 iDate; - uniform float iSampleRate; - uniform vec4 iCurrentCursor; - uniform vec4 iPreviousCursor; - uniform vec4 iCurrentCursorColor; - uniform vec4 iPreviousCursorColor; - uniform int iCurrentCursorStyle; - uniform int iPreviousCursorStyle; - uniform int iCursorVisible; - uniform float iTimeCursorChange; - uniform float iTimeFocus; - uniform int iFocus; - uniform vec3 iPalette[256]; - uniform vec3 iBackgroundColor; - uniform vec3 iForegroundColor; - uniform vec3 iCursorColor; - uniform vec3 iCursorText; - uniform vec3 iSelectionForegroundColor; - uniform vec3 iSelectionBackgroundColor; -}; - -// pardes's own: pardesOpaque is 1 when the window is opaque, so a pass -// cannot punch holes in it through its alpha; pardesLevel is a bundled -// pass's strength, 1 to 3 (`Crt 3`), and 0 for a user's file. -layout(set = 3, binding = 1, std140) uniform PardesPost { - float pardesOpaque; - float pardesLevel; -}; - -// A bundled pass's constant at levels 1, 2 and 3. -float pardesByLevel(float one, float two, float three) { - return pardesLevel < 1.5 ? one : (pardesLevel < 2.5 ? two : three); -} - -#define CURSORSTYLE_BLOCK 0 -#define CURSORSTYLE_BLOCK_HOLLOW 1 -#define CURSORSTYLE_BAR 2 -#define CURSORSTYLE_UNDERLINE 3 -#define CURSORSTYLE_LOCK 4 - -layout(set = 2, binding = 0) uniform sampler2D iChannel0; - -layout(location = 0) out vec4 _fragColor; - -#define texture2D texture - -void mainImage(out vec4 fragColor, in vec2 fragCoord); -void main() { - mainImage(_fragColor, gl_FragCoord.xy); - if (pardesOpaque > 0.5) _fragColor.a = 1.0; -} - -// The shader's own lines count from 1, so glslc's errors name them. -#line 1 -// Glitch: now and then the signal slips. At the default level, every couple -// of seconds a short -// burst tears a few horizontal bands sideways and pulls their red and blue -// apart; the bands are sized in pixels and the burst eases in and out over -// a fraction of a second, so it reads as one event rather than a flicker, -// and it looks the same at 60 or 144 Hz because every step is keyed to -// iTime, not to frames. Between bursts the page is left exactly as it is. -// A torn band's brightness dips in linear light. It is a Shadertoy file: -// pardes puts its prefix before it. - -// Each at levels 1, 2 and 3 (`Glitch 1`..`Glitch 3`); 2 is the default. A -// higher level slips more often and harder. -float period() { return pardesByLevel(4.5, 2.4, 0.55); } // seconds between bursts, on average -float burst() { return pardesByLevel(0.2, 0.28, 0.5); } // seconds a burst lasts -float stepHz() { return pardesByLevel(20.0, 24.0, 30.0); } // how often a burst re-tears -float shiftPx() { return pardesByLevel(8.0, 14.0, 34.0); } -float splitPx() { return pardesByLevel(1.5, 2.5, 6.0); } -float dip() { return pardesByLevel(0.08, 0.12, 0.22); } -float tearFrom() { return pardesByLevel(0.8, 0.72, 0.35); } // how few bands tear - -float hash(vec2 p) { - vec3 q = fract(vec3(p.xyx) * 0.1031); - q += dot(q, q.yzx + 33.33); - return fract((q.x + q.y) * q.z); -} - -vec3 toLinear(vec3 c) { - return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); -} - -vec3 toSrgb(vec3 c) { - c = max(c, vec3(0.0)); - return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308))); -} - -void mainImage(out vec4 fragColor, in vec2 fragCoord) { - vec2 size = iResolution.xy; - vec2 uv = fragCoord / size; - // Which burst window this is, when in it the burst starts, and how far - // into the burst we are: 0 outside one. - float window = floor(iTime / period()); - float start = window * period() + hash(vec2(window, 7.0)) * (period() - burst()); - float t = (iTime - start) / burst(); - float live = step(0.0, t) * step(t, 1.0); - float envelope = live * sin(3.14159265 * clamp(t, 0.0, 1.0)); - if (envelope <= 0.0) { - fragColor = texture(iChannel0, uv); - return; - } - // Bands of 6 to 40 px, re-cut at stepHz() during the burst; about one - // band in four tears. - float tick = floor(iTime * stepHz()); - float band_px = mix(6.0, 40.0, hash(vec2(tick, 3.0))); - float band = floor(fragCoord.y / band_px); - float roll = hash(vec2(band, tick)); - float tear = smoothstep(tearFrom(), tearFrom() + 0.18, roll) * envelope; - float shift = (hash(vec2(tick, band + 11.0)) - 0.5) * 2.0 * shiftPx() * tear; - vec2 at = vec2(fragCoord.x + shift, fragCoord.y); - vec2 split = vec2(splitPx() * tear, 0.0); - vec4 center = texture(iChannel0, at / size); - vec3 col = vec3(texture(iChannel0, (at + split) / size).r, center.g, texture(iChannel0, (at - split) / size).b); - col = toSrgb(toLinear(col) * (1.0 - dip() * tear)); - fragColor = vec4(col, center.a); -} diff --git a/shaders/prebuilt/post-glitch.frag.spv b/shaders/prebuilt/post-glitch.frag.spv deleted file mode 100644 index 619616a8..00000000 Binary files a/shaders/prebuilt/post-glitch.frag.spv and /dev/null differ diff --git a/shaders/prebuilt/post-ripple.frag.glsl b/shaders/prebuilt/post-ripple.frag.glsl deleted file mode 100644 index d29a4c22..00000000 --- a/shaders/prebuilt/post-ripple.frag.glsl +++ /dev/null @@ -1,126 +0,0 @@ -#version 450 -// pardes's Shadertoy prefix (docs/render-pipeline.md §6), put before every -// post pass: a bundled one in shaders/post/ at build time, a user's file at -// run time. The uniform names, types and std140 order are ghostty 1.3.2's -// (src/renderer/shaders/shadertoy_prefix.glsl), so a shader written for -// ghostty runs here; only the bindings are SDL GPU's (a fragment shader's -// textures in set 2, its uniform blocks in set 3). Y is down everywhere, as -// on ghostty's Metal renderer: fragCoord, iChannel0's uv and the cursor -// rectangles share one top-left space, and iCurrentCursor is (left x, -// BOTTOM-edge y, width, height) in framebuffer pixels. - -layout(set = 3, binding = 0, std140) uniform Globals { - uniform vec3 iResolution; - uniform float iTime; - uniform float iTimeDelta; - uniform float iFrameRate; - uniform int iFrame; - uniform float iChannelTime[4]; - uniform vec3 iChannelResolution[4]; - uniform vec4 iMouse; - uniform vec4 iDate; - uniform float iSampleRate; - uniform vec4 iCurrentCursor; - uniform vec4 iPreviousCursor; - uniform vec4 iCurrentCursorColor; - uniform vec4 iPreviousCursorColor; - uniform int iCurrentCursorStyle; - uniform int iPreviousCursorStyle; - uniform int iCursorVisible; - uniform float iTimeCursorChange; - uniform float iTimeFocus; - uniform int iFocus; - uniform vec3 iPalette[256]; - uniform vec3 iBackgroundColor; - uniform vec3 iForegroundColor; - uniform vec3 iCursorColor; - uniform vec3 iCursorText; - uniform vec3 iSelectionForegroundColor; - uniform vec3 iSelectionBackgroundColor; -}; - -// pardes's own: pardesOpaque is 1 when the window is opaque, so a pass -// cannot punch holes in it through its alpha; pardesLevel is a bundled -// pass's strength, 1 to 3 (`Crt 3`), and 0 for a user's file. -layout(set = 3, binding = 1, std140) uniform PardesPost { - float pardesOpaque; - float pardesLevel; -}; - -// A bundled pass's constant at levels 1, 2 and 3. -float pardesByLevel(float one, float two, float three) { - return pardesLevel < 1.5 ? one : (pardesLevel < 2.5 ? two : three); -} - -#define CURSORSTYLE_BLOCK 0 -#define CURSORSTYLE_BLOCK_HOLLOW 1 -#define CURSORSTYLE_BAR 2 -#define CURSORSTYLE_UNDERLINE 3 -#define CURSORSTYLE_LOCK 4 - -layout(set = 2, binding = 0) uniform sampler2D iChannel0; - -layout(location = 0) out vec4 _fragColor; - -#define texture2D texture - -void mainImage(out vec4 fragColor, in vec2 fragCoord); -void main() { - mainImage(_fragColor, gl_FragCoord.xy); - if (pardesOpaque > 0.5) _fragColor.a = 1.0; -} - -// The shader's own lines count from 1, so glslc's errors name them. -#line 1 -// Ripple: the page as a still pool a slow swell crosses. Rings travel out -// from the middle of the window at a fixed speed in pixels, so they look -// the same at any size and frame rate; at the default level the displacement -// is at most a couple of pixels, so text stays readable, and it eases in over its first half -// second instead of snapping on. Each ring catches the light a little on its -// rising side, in linear light, and the result is dithered so the shading -// never bands. It is a Shadertoy file: pardes puts its prefix before it. - -const float TAU = 6.28318530718; -const float EASE_IN = 0.5; // seconds - -// Each at levels 1, 2 and 3 (`Ripple 1`..`Ripple 3`); 2 is the default. -float wavelengthPx() { return pardesByLevel(170.0, 150.0, 120.0); } -float speedPx() { return pardesByLevel(40.0, 55.0, 80.0); } // outward, per second -float amplitudePx() { return pardesByLevel(1.2, 2.2, 5.0); } -float shade() { return pardesByLevel(0.02, 0.035, 0.09); } // light on a slope, linear -float reachEnd() { return pardesByLevel(0.85, 0.95, 1.3); } // how far out it dies away - -vec3 toLinear(vec3 c) { - return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); -} - -vec3 toSrgb(vec3 c) { - c = max(c, vec3(0.0)); - return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308))); -} - -float ign(vec2 p) { - return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); -} - -void mainImage(out vec4 fragColor, in vec2 fragCoord) { - vec2 size = iResolution.xy; - vec2 center = 0.5 * size; - vec2 offset = fragCoord - center; - float radius = length(offset); - float reach = 0.5 * length(size); - // Strongest a third of the way out, fading to nothing at the middle - // and at the edges, so the window's frame never wobbles. - float envelope = smoothstep(0.0, 0.18, radius / reach) * (1.0 - smoothstep(0.35, reachEnd(), radius / reach)); - float ease = smoothstep(0.0, EASE_IN, iTime); - float phase = (radius - iTime * speedPx()) * TAU / wavelengthPx(); - float height = sin(phase); - float slope = cos(phase); - vec2 dir = offset / max(radius, 1e-3); - vec2 moved = fragCoord + dir * height * amplitudePx() * envelope * ease; - - vec4 s = texture(iChannel0, moved / size); - vec3 col = toLinear(s.rgb) * (1.0 + shade() * slope * envelope * ease); - vec3 srgb = toSrgb(col) + (ign(fragCoord + float(iFrame % 64) * 5.588238) - 0.5) / 255.0; - fragColor = vec4(clamp(srgb, 0.0, 1.0), s.a); -} diff --git a/shaders/prebuilt/post-ripple.frag.spv b/shaders/prebuilt/post-ripple.frag.spv deleted file mode 100644 index 5f05f9f3..00000000 Binary files a/shaders/prebuilt/post-ripple.frag.spv and /dev/null differ diff --git a/src/config.zig b/src/config.zig index e8c5a705..d1e5f23d 100644 --- a/src/config.zig +++ b/src/config.zig @@ -129,8 +129,6 @@ pub const leader_path = paths: { } if (builtins.capabilities.scene_shaders) { table.set(.Crt, "tr"); - table.set(.Ripple, "tR"); - table.set(.Glitch, "tg"); table.set(.Shader, null); table.set(.ShaderAnimation, null); } @@ -622,7 +620,7 @@ pub const Runtime = struct { pub const ShaderAnimation = enum { off, on, always }; - /// Each pass a bundled effect (Crt, Ripple, Glitch) or a Shadertoy file. + /// Each pass the bundled Crt or a Shadertoy file. pub const Post = struct { pub const max = 8; pub const Entry = struct { @@ -774,8 +772,8 @@ pub const Runtime = struct { pub const MessageTime = enum { linger, fall, dissolve }; pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, column_tags, syntax_bold, verbose, message_animation, ligatures }; - /// The bundled post passes, each a Shadertoy file in shaders/post/. - pub const Scene = enum { crt, ripple, glitch }; + /// The bundled post pass, a Shadertoy file in shaders/post/. + pub const Scene = enum { crt }; pub const Action = union(enum) { toggle: Toggle, @@ -843,8 +841,6 @@ pub const Runtime = struct { .{ .word = "PanelScramble", .action = .{ .transition = .scramble }, .availability = .panel_transitions }, .{ .word = "PanelType", .action = .{ .transition = .typewriter }, .availability = .panel_transitions }, .{ .word = "Crt", .action = .{ .scene = .crt }, .availability = .scene_shaders }, - .{ .word = "Ripple", .action = .{ .scene = .ripple }, .availability = .scene_shaders }, - .{ .word = "Glitch", .action = .{ .scene = .glitch }, .availability = .scene_shaders }, .{ .word = "Shader", .action = .shader, .availability = .scene_shaders }, .{ .word = "ShaderAnimation", .action = .shader_animation, .availability = .scene_shaders }, }; @@ -1263,18 +1259,18 @@ pub const Runtime = struct { test "the post chain runs in the order its passes were put in, and Shader off empties it of files" { var state: Runtime = .{}; try std.testing.expect(apply(&state, find("Shader").?, " ~/a.glsl\n")); - try std.testing.expect(apply(&state, find("Glitch").?, "on")); + try std.testing.expect(apply(&state, find("Crt").?, "on")); try std.testing.expect(apply(&state, find("Shader").?, "/b.glsl")); const chain = state.post.list(); try std.testing.expectEqual(@as(usize, 3), chain.len); try std.testing.expectEqualStrings("~/a.glsl", chain[0].path.get()); - try std.testing.expectEqual(Scene.glitch, chain[1].scene.?); + try std.testing.expectEqual(Scene.crt, chain[1].scene.?); try std.testing.expectEqualStrings("/b.glsl", chain[2].path.get()); const moved = state.post.generation; // A file named again leaves; the others keep their order. try std.testing.expect(apply(&state, find("Shader").?, "~/a.glsl")); try std.testing.expect(state.post.generation != moved); - try std.testing.expectEqual(Scene.glitch, state.post.list()[0].scene.?); + try std.testing.expectEqual(Scene.crt, state.post.list()[0].scene.?); try std.testing.expect(apply(&state, find("Shader").?, "off")); try std.testing.expectEqual(@as(usize, 1), state.post.len); try std.testing.expect(!apply(&state, find("Shader").?, null)); @@ -1288,7 +1284,7 @@ pub const Runtime = struct { const crt = find("Crt").?; try std.testing.expect(apply(&state, crt, "on")); try std.testing.expectEqual(@as(u8, Post.default_level), state.post.level(.crt)); - try std.testing.expect(apply(&state, find("Glitch").?, "1")); + try std.testing.expect(apply(&state, find("Shader").?, "/after.glsl")); const moved = state.post.generation; try std.testing.expect(apply(&state, crt, "3")); try std.testing.expectEqual(@as(u8, 3), state.post.level(.crt)); @@ -1472,8 +1468,6 @@ pub const Runtime = struct { }, .scene => |effect| switch (effect) { .crt => "Crt 2\n", - .ripple => "Ripple 2\n", - .glitch => "Glitch 2\n", }, .shader => "Shader ~/crt.glsl\n", .shader_animation => "ShaderAnimation always\n", @@ -1544,8 +1538,6 @@ pub const Runtime = struct { try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font requested") == null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Panel transition off\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Crt unsupported\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Ripple unsupported\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Glitch unsupported\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "TaglineSize unsupported\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Look hover delay: off\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Native images: off\n") != null); diff --git a/src/effect_sources.zig b/src/effect_sources.zig index 6ce11160..83bd1313 100644 --- a/src/effect_sources.zig +++ b/src/effect_sources.zig @@ -12,10 +12,8 @@ const ui_fragment_path = shader_dir ++ "ui.frag.glsl"; const image_vertex_path = shader_dir ++ "image.vert.glsl"; const image_fragment_path = shader_dir ++ "image.frag.glsl"; const post_vertex_path = shader_dir ++ "post.vert.glsl"; -// The bundled post passes as compiled: the prefix and the pass together. +// The bundled post pass as compiled: the prefix and the pass together. const post_crt_path = shader_dir ++ "post-crt.frag.glsl"; -const post_ripple_path = shader_dir ++ "post-ripple.frag.glsl"; -const post_glitch_path = shader_dir ++ "post-glitch.frag.glsl"; pub const Backend = @TypeOf(build_config.platform); pub const backend: Backend = build_config.platform; @@ -55,8 +53,6 @@ pub const files = switch (backend) { .{ .path = image_fragment_path, .contents = @embedFile("effect-source-image.frag.glsl") }, .{ .path = post_vertex_path, .contents = @embedFile("effect-source-post.vert.glsl") }, .{ .path = post_crt_path, .contents = @embedFile("effect-source-post-crt.frag.glsl") }, - .{ .path = post_ripple_path, .contents = @embedFile("effect-source-post-ripple.frag.glsl") }, - .{ .path = post_glitch_path, .contents = @embedFile("effect-source-post-glitch.frag.glsl") }, }, .macos => [_]filesystem.Source{ .{ .path = "src/macos/Sources/PardesView.swift", .contents = @embedFile("macos/Sources/PardesView.swift") }, @@ -77,8 +73,6 @@ pub fn forSetting(setting: config.Runtime.Setting) ?[]const []const u8 { .scene => |effect| switch (backend) { .gui => switch (effect) { .crt => &.{ "src/gui/Post.zig", "src/gui/gui.zig", post_vertex_path, post_crt_path }, - .ripple => &.{ "src/gui/Post.zig", "src/gui/gui.zig", post_vertex_path, post_ripple_path }, - .glitch => &.{ "src/gui/Post.zig", "src/gui/gui.zig", post_vertex_path, post_glitch_path }, }, .macos => &.{ "src/macos/Sources/PardesView.swift", "src/macos/Sources/ScenePostprocessor.swift", "shaders/crt.ci.metal" }, .tty, .web, .esp32p4 => null, diff --git a/src/gui/Post.zig b/src/gui/Post.zig index d9d3d53e..f715965f 100644 --- a/src/gui/Post.zig +++ b/src/gui/Post.zig @@ -1,7 +1,7 @@ //! The post chain (docs/render-pipeline.md §6): Shadertoy passes over the //! finished frame, each reading the one before it (the frame first) and the -//! last one writing the window. A pass is bundled (Crt, Ripple, Glitch: -//! shaders/post/, compiled with the build) or a user's file (Shader ), +//! last one writing the window. A pass is the bundled Crt (shaders/post/, +//! compiled with the build) or a user's file (Shader ), //! compiled when it joins the chain by the build's own glslc invocation on a //! thread of its own, never on the frame path. A file whose compile fails //! keeps its last good pipeline, and glslc missing is said once. @@ -28,8 +28,6 @@ const prefix = @embedFile("post-prefix.glsl"); const vert_spv = @embedFile("post.vert.spv"); const bundled_spv = std.EnumArray(Scene, []const u8).init(.{ .crt = @embedFile("post-crt.frag.spv"), - .ripple = @embedFile("post-ripple.frag.spv"), - .glitch = @embedFile("post-glitch.frag.spv"), }); /// ghostty's Shadertoy uniform block, member for member in the GLSL block's @@ -170,7 +168,7 @@ fn collect(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice, format .none => {}, .missing => if (!post.missing_said) { post.missing_said = true; - core.setMessage(core.active, "Shader: glslc is not on PATH, so Shadertoy files do not run (Crt, Ripple and Glitch do)"); + core.setMessage(core.active, "Shader: glslc is not on PATH, so Shadertoy files do not run (Crt does)"); }, .failed => |text| { defer gpa.free(text); diff --git a/src/macos.zig b/src/macos.zig index 0bad86ee..6cd7838f 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -112,8 +112,6 @@ const cell_flag_default: u8 = 1; const cell_flag_tagline: u8 = 2; const cell_flag_hover: u8 = 4; const scene_flag_crt: u32 = 1 << 0; -const scene_flag_ripple: u32 = 1 << 1; -const scene_flag_glitch: u32 = 1 << 2; const scene_frame_hz: u32 = 60; const scene_wrap_ns: u64 = 4096 * std.time.ns_per_s; @@ -850,13 +848,11 @@ export fn pardes_should_quit() bool { return st.core.quit; } -// The Metal shell keeps its own CRT, ripple and glitch kernel: the bundled -// passes of the GUI's post chain, as flags. +// The Metal shell keeps its own CRT kernel: the GUI's bundled post pass, as +// a flag. fn encodeSceneEffects(post: *const pardes.config.Runtime.Post) u32 { var flags: u32 = 0; if (post.has(.crt)) flags |= scene_flag_crt; - if (post.has(.ripple)) flags |= scene_flag_ripple; - if (post.has(.glitch)) flags |= scene_flag_glitch; return flags; } @@ -2842,8 +2838,6 @@ test "pardes.h matches the Zig boundary" { try expectEqual(@as(u8, c.PARDES_CELL_HOVER), cell_flag_hover); try expectEqual(@as(u16, c.PARDES_TOPBAR_H), pardes.TOPBAR_H); try expectEqual(@as(u32, c.PARDES_SCENE_CRT), scene_flag_crt); - try expectEqual(@as(u32, c.PARDES_SCENE_RIPPLE), scene_flag_ripple); - try expectEqual(@as(u32, c.PARDES_SCENE_GLITCH), scene_flag_glitch); try expectEqual(@as(u8, c.PARDES_PANEL_OPENING), @intFromEnum(animation.Phase.opening)); try expectEqual(@as(u8, c.PARDES_PANEL_MOVING), @intFromEnum(animation.Phase.moving)); try expectEqual(@as(u8, c.PARDES_PANEL_CLOSING), @intFromEnum(animation.Phase.closing)); @@ -2918,13 +2912,6 @@ test "scene effect flags and display clock are compact and independent" { try expectEqual(@as(u32, 0), encodeSceneEffects(&post)); _ = post.set(.crt, "", true); try expectEqual(scene_flag_crt, encodeSceneEffects(&post)); - _ = post.set(.ripple, "", true); - _ = post.set(.glitch, "", true); - try expectEqual(scene_flag_crt | scene_flag_ripple | scene_flag_glitch, encodeSceneEffects(&post)); - try expectEqual( - scene_flag_crt | scene_flag_ripple | scene_flag_glitch, - encodeSceneEffects(.{ .crt = true, .ripple = true, .glitch = true }), - ); var st: State = undefined; st.scene_ns = scene_wrap_ns - (std.time.ns_per_ms * 5); diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift index 70f467e3..8ba9c84b 100644 --- a/src/macos/Sources/PardesView.swift +++ b/src/macos/Sources/PardesView.swift @@ -684,7 +684,7 @@ final class PardesView: NSView { private var latchedButton: pardes_mouse_button_e? private var latchedCell: GridPoint? /// Last physical point delivered by AppKit and its mapping through the - /// accepted scene. Ripple/glitch can move a source cell under a stationary + /// accepted scene. The CRT's barrel moves a source cell under a stationary /// hand, so successful scene presentations reconcile this point again. private var pointerPoint: CGPoint? private var pointerCell: GridPoint? diff --git a/src/macos/Sources/ScenePostprocessor.swift b/src/macos/Sources/ScenePostprocessor.swift index cf7892b3..41d54e34 100644 --- a/src/macos/Sources/ScenePostprocessor.swift +++ b/src/macos/Sources/ScenePostprocessor.swift @@ -205,11 +205,9 @@ final class ScenePostprocessor { if scene.flags != 0 { let crt: Float = scene.flags & UInt32(PARDES_SCENE_CRT) != 0 ? 1 : 0 - let ripple: Float = scene.flags & UInt32(PARDES_SCENE_RIPPLE) != 0 ? 1 : 0 - let glitch: Float = scene.flags & UInt32(PARDES_SCENE_GLITCH) != 0 ? 1 : 0 // SDL samples the scene with CLAMP_TO_EDGE. A finite Core Image // instead supplies transparent black outside its extent, which - // would make ripple/glitch tear dark seams at the window edge. + // would tear dark seams at the window edge. // Clamp the input only; the kernel still emits true black before // sampling when a destination lies outside the CRT tube. let sceneInput = filtered.clampedToExtent() @@ -218,7 +216,7 @@ final class ScenePostprocessor { roiCallback: { _, _ in extent }, arguments: [ sceneInput, scene.time_seconds, Float(scene.frame), - crt, ripple, glitch, + crt, CIVector(x: extent.width, y: extent.height), CIVector(x: extent.minX, y: extent.minY), ]) @@ -291,7 +289,7 @@ final class ScenePostprocessor { /// The scene kernel is a destination-to-source mapping. Pointer input must /// apply that same mapping before the grid and its panel tracks see a cell, - /// otherwise a visible word under ripple/glitch is not the word Look gets. + /// otherwise a visible word under the barrel is not the word Look gets. static func sourcePoint( _ point: CGPoint, size: CGSize, @@ -311,27 +309,8 @@ final class ScenePostprocessor { ccY *= barrel uvX = ccX * 0.5 + 0.5 uvY = ccY * 0.5 + 0.5 - // Only CRT has a black tube. Ripple/glitch without CRT sample through - // the texture's clamp-to-edge policy and remain clickable there. + // The CRT's black tube has no cell under it. if crt > 0.5 && min(min(uvX, uvY), min(1 - uvX, 1 - uvY)) <= 0 { return nil } - - let time = scene.time_seconds - if scene.flags & UInt32(PARDES_SCENE_RIPPLE) != 0 { - let centeredX = uvX - 0.5 - let centeredY = uvY - 0.5 - let radius = sqrtf(centeredX * centeredX + centeredY * centeredY) - let envelope = 1 - smoothstep(0.04, 0.72, radius) - let wave = sinf(radius * 42 - time * 5.2) - let travel = wave * envelope * 0.0032 / max(radius, 0.00001) - uvX += centeredX * travel - uvY += centeredY * travel - } - if scene.flags & UInt32(PARDES_SCENE_GLITCH) != 0 { - let band = floorf(uvY * 96) - let seed = noise(band, floorf(time * 13)) - let tear = smoothstep(0.955, 1, seed) - uvX += (noise(seed, band) - 0.5) * tear * 0.020 - } uvX = min(max(uvX, 0), max(0, 1 - 1 / Float(size.width))) uvY = min(max(uvY, 0), max(0, 1 - 1 / Float(size.height))) return CGPoint( @@ -339,23 +318,4 @@ final class ScenePostprocessor { y: CGFloat(1 - uvY) * size.height) } - private static func smoothstep(_ edge0: Float, _ edge1: Float, _ x: Float) -> Float { - let t = min(max((x - edge0) / (edge1 - edge0), 0), 1) - return t * t * (3 - 2 * t) - } - - /// Scalar spelling of pardesNoise in crt.ci.metal. Float arithmetic is - /// deliberate: the shader's band decision must not move at a Double round. - private static func noise(_ x: Float, _ y: Float) -> Float { - var qx = fract(x * 0.1031) - var qy = fract(y * 0.1031) - var qz = fract(x * 0.1031) - let d = qx * (qy + 33.33) + qy * (qz + 33.33) + qz * (qx + 33.33) - qx += d - qy += d - qz += d - return fract((qx + qy) * qz) - } - - private static func fract(_ x: Float) -> Float { x - floorf(x) } } diff --git a/src/macos/pardes.h b/src/macos/pardes.h index f74330e5..29f8a37d 100644 --- a/src/macos/pardes.h +++ b/src/macos/pardes.h @@ -82,8 +82,7 @@ typedef struct { // from the background colour, which a real selection shares. #define PARDES_CELL_HOVER 0x04u -// One coherent snapshot for the full-window shader pass. The bitmask lets the -// host run CRT, ripple and glitch in one kernel instead of stacking filters; +// One coherent snapshot for the full-window shader pass (CRT); // time/frame advance only on pardes_animation_tick's display clock. typedef struct { uint32_t flags; @@ -92,8 +91,6 @@ typedef struct { } pardes_scene_s; #define PARDES_SCENE_CRT 0x01u -#define PARDES_SCENE_RIPPLE 0x02u -#define PARDES_SCENE_GLITCH 0x04u // One backend-neutral pane transition. Boxes are logical cell rectangles; // the native shader converts them to backing pixels using the same measured diff --git a/test/output.zig b/test/output.zig index 0fdd9f4d..e49ec4ff 100644 --- a/test/output.zig +++ b/test/output.zig @@ -669,7 +669,7 @@ test "every enabled setting builtin mutates the State Config reports" { try std.testing.expect(std.mem.indexOf(u8, report, transition) != null); // A bare word turns a bundled pass on at its default level. const scene_status = if (builtins.capabilities.scene_shaders) "2" else "unsupported"; - for ([_][]const u8{ "Crt", "Ripple", "Glitch" }) |name| { + for ([_][]const u8{"Crt"}) |name| { const line = try std.fmt.bufPrint(&buf, "{s} {s}\n", .{ name, scene_status }); try std.testing.expect(std.mem.indexOf(u8, report, line) != null); } -- cgit v1.3 From df4d11bb95bc7ea9554b55d2211c4704a2594f08 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 17:04:53 -0300 Subject: ]f, mif and the other textobject keys use helix's own queries helix's textobjects.scm for pardes's grammars are vendored in vendor/queries under MPL-2.0, each with a header naming its source and what it inherits, inlined; a README says what is there and what is not. syntax.objectAt and objectNext run them over the file's kept parse, with their #eq? and #match? predicates, for ]f [f ]t [t ]a [a ]c [c ]T [T ]e [e ]x [x and mi/ma with f t a c T e x. erlang's and zig's queries do not compile against the grammar versions pardes builds and are left out; fortran, markdown and powershell have none in helix. A test replays sixteen Python and JSON cases whose results were taken from the installed hx, and another holds that every vendored query compiles. Co-Authored-By: Claude Opus 5.5 --- build.zig | 4 + docs/helix-keys.md | 2 +- src/config.zig | 7 + src/edit.zig | 19 ++ src/modal.zig | 12 + src/normal.zig | 86 ++++++- src/syntax.zig | 144 ++++++++++++ vendor/queries/README.md | 15 ++ vendor/queries/ada/textobjects.scm | 26 +++ vendor/queries/bash/textobjects.scm | 17 ++ vendor/queries/c/textobjects.scm | 32 +++ vendor/queries/c_sharp/textobjects.scm | 25 ++ vendor/queries/clojure/textobjects.scm | 76 ++++++ vendor/queries/cpp/textobjects.scm | 39 ++++ vendor/queries/css/textobjects.scm | 27 +++ vendor/queries/elixir/textobjects.scm | 40 ++++ vendor/queries/go/textobjects.scm | 39 ++++ vendor/queries/haskell/textobjects.scm | 29 +++ vendor/queries/helix.LICENSE | 373 ++++++++++++++++++++++++++++++ vendor/queries/html/textobjects.scm | 14 ++ vendor/queries/java/textobjects.scm | 49 ++++ vendor/queries/javascript/textobjects.scm | 8 + vendor/queries/json/textobjects.scm | 13 ++ vendor/queries/kotlin/textobjects.scm | 50 ++++ vendor/queries/ocaml/textobjects.scm | 37 +++ vendor/queries/pascal/textobjects.scm | 14 ++ vendor/queries/php/textobjects.scm | 63 +++++ vendor/queries/python/textobjects.scm | 40 ++++ vendor/queries/ruby/textobjects.scm | 61 +++++ vendor/queries/rust/textobjects.scm | 90 +++++++ vendor/queries/scala/textobjects.scm | 70 ++++++ vendor/queries/typst/textobjects.scm | 28 +++ 32 files changed, 1540 insertions(+), 9 deletions(-) create mode 100644 vendor/queries/README.md create mode 100644 vendor/queries/ada/textobjects.scm create mode 100644 vendor/queries/bash/textobjects.scm create mode 100644 vendor/queries/c/textobjects.scm create mode 100644 vendor/queries/c_sharp/textobjects.scm create mode 100644 vendor/queries/clojure/textobjects.scm create mode 100644 vendor/queries/cpp/textobjects.scm create mode 100644 vendor/queries/css/textobjects.scm create mode 100644 vendor/queries/elixir/textobjects.scm create mode 100644 vendor/queries/go/textobjects.scm create mode 100644 vendor/queries/haskell/textobjects.scm create mode 100644 vendor/queries/helix.LICENSE create mode 100644 vendor/queries/html/textobjects.scm create mode 100644 vendor/queries/java/textobjects.scm create mode 100644 vendor/queries/javascript/textobjects.scm create mode 100644 vendor/queries/json/textobjects.scm create mode 100644 vendor/queries/kotlin/textobjects.scm create mode 100644 vendor/queries/ocaml/textobjects.scm create mode 100644 vendor/queries/pascal/textobjects.scm create mode 100644 vendor/queries/php/textobjects.scm create mode 100644 vendor/queries/python/textobjects.scm create mode 100644 vendor/queries/ruby/textobjects.scm create mode 100644 vendor/queries/rust/textobjects.scm create mode 100644 vendor/queries/scala/textobjects.scm create mode 100644 vendor/queries/typst/textobjects.scm (limited to 'build.zig') diff --git a/build.zig b/build.zig index f4a3b542..51c4f76d 100644 --- a/build.zig +++ b/build.zig @@ -384,6 +384,10 @@ pub fn build(b: *std.Build) void { const query_path = dep.path(g.query); const query = std.Io.Dir.cwd().readFileAlloc(io, query_path.getPath(b), b.allocator, .limited(0x100000)) catch @panic("read " ++ g.name ++ " highlights"); ts_queries.addOption([]const u8, g.name ++ "_highlights", query); + // helix's textobject queries, vendored (vendor/queries); a + // grammar without one gets none + const objects = std.Io.Dir.cwd().readFileAlloc(io, b.pathFromRoot("vendor/queries/" ++ g.name ++ "/textobjects.scm"), b.allocator, .limited(0x100000)) catch ""; + ts_queries.addOption([]const u8, g.name ++ "_textobjects", objects); const lib = b.addLibrary(.{ .name = "tree-sitter-" ++ g.name, diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 1fb1c67a..b6ac6475 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -218,6 +218,7 @@ found. Pure text math in `modal.zig`: `findChar`, `matchBracket`, | `Alt-;` | flip anchor and head | vsel: swap cursor ↔ anchor; msel: cursor to the other end (r0/r1 swapped to keep the cursor-at-r1 invariant) | helix-verified (phase 5) | | `Alt-:` | make every range point forward | helix `ensure_selections_forward` | helix-verified | | `Alt-o`/`Alt-up`, `Alt-i`/`Alt-down`, `Alt-p`/`Alt-left`, `Alt-right`, `Alt-a`, `Alt-I`, `Alt-e`, `Alt-b` | walk the file's syntax tree from every range: grow to the enclosing node (and remember what it grew from), shrink back to that or to the first child, the previous / next sibling (anonymous ones included, so often a comma), every named sibling or child, the enclosing named node's end / start (stretching to it in select mode) | helix `expand_selection` and kin, on a whole-file parse (`File.node_tree`) made on the first such key after an edit and reused until the next; a range the tree says nothing about, and every range of a file without a grammar, stays. Checked against the installed hx on JSON (`normal.zig` test): the harness runs without grammars, so it only proves the plain-text no-op | helix-verified (plain text), hx-checked (JSON) | +| `]f [f ]t [t ]a [a ]c [c ]T [T ]e [e ]x [x`, `mi`/`ma` + `f t a c T e x` | function, class, argument, comment, test, entry, element: jump to the next one starting after the cursor / the previous one ending before it (count times; select mode stretches to it), or select the smallest one around the cursor, inside or around | helix `goto_ts_object` / `textobject_treesitter`, from helix's own `textobjects.scm` vendored in `vendor/queries` (MPL-2.0), with its `#eq?`/`#match?` predicates; `syntax.objectAt`/`objectNext` on the file's kept parse. Grammars without a query (fortran, markdown, powershell) or whose query does not compile against pardes's grammar (erlang, zig) have none, and the keys do nothing there. Checked against the installed hx on Python and JSON (`normal.zig` test) | hx-checked | | `%` | select whole buffer | vsel anchor 0,0, cursor on the buffer's last char | helix-verified (phase 5) | | `X` | snap selection to line bounds | vsel → msel over its row span; bare cursor → 1-line msel; msel: already line-wise, no-op | helix-verified (phase 5) | | `Alt-x` | shrink selection to line bounds | vsel only: partial first/last lines drop out; nothing left collapses the selection; msel: no-op | helix-verified (phase 5) | @@ -357,7 +358,6 @@ text. | Picker table (all rows), Prompt table (all rows) | picker / prompt internals | pickers — pardes' tag line is its own one-line editor | | `gn` `gp` `ga` `gm` | next/prev/alternate buffer | buffer nav — pardes panes aren't a buffer list | | Window mode table: `Ctrl-w` + `w v s t f F h j k l q o H J K L ns nv` (+ Ctrl variants) | splits/window management | window mode — pardes has its own Ctrl-w focus + Alt-n/Alt-c + mouse layout drags | -| `]f [f ]t [t ]a [a ]c [c ]e [e ]T [T ]x [x` (and `mif`/`maf`…) | TS unimpaired jumps | open question: they read a grammar's `textobjects.scm`, and of pardes's 29 grammars only Ada ships one; the rest need queries vendored from helix (MPL-2.0) or nvim-treesitter-textobjects (Apache-2.0). The node walks they build on are in A | | `]g [g ]G [G` | git change jumps | **deferred by decision**: needs a diff base per file (jj `@-` or git HEAD, fetched by the host on open and save) and line hunks from diffz; the design is written up, the work waits | | insert `Ctrl-x` | completion menu | completion exists, but not as a popup: insert-mode Tab straight after a `.` opens a buffer of candidate DECLARATIONS (section A). helix's menu itself is skipped | | `Ctrl-s` (normal) | save jumplist position | jumplist itself is implemented (`Ctrl-o`/`Ctrl-i`, section A); only the explicit save point is skipped | diff --git a/src/config.zig b/src/config.zig index 3739d3f4..78787ad2 100644 --- a/src/config.zig +++ b/src/config.zig @@ -511,6 +511,13 @@ pub const goto_paragraph: []const Chord = &.{.{ .cp = 'p' }}; pub const add_newline: []const Chord = &.{.{ .cp = ' ' }}; pub const goto_diagnostic: []const Chord = &.{.{ .cp = 'd' }}; pub const goto_diagnostic_end: []const Chord = &.{.{ .cp = 'D' }}; +pub const goto_function: []const Chord = &.{.{ .cp = 'f' }}; +pub const goto_class: []const Chord = &.{.{ .cp = 't' }}; +pub const goto_parameter: []const Chord = &.{.{ .cp = 'a' }}; +pub const goto_comment: []const Chord = &.{.{ .cp = 'c' }}; +pub const goto_test: []const Chord = &.{.{ .cp = 'T' }}; +pub const goto_entry: []const Chord = &.{.{ .cp = 'e' }}; +pub const goto_xml_element: []const Chord = &.{.{ .cp = 'x' }}; pub const insert: []const Chord = &.{.{ .cp = 'i' }}; pub const append: []const Chord = &.{.{ .cp = 'a' }}; diff --git a/src/edit.zig b/src/edit.zig index 5437a67d..1b947749 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -1858,6 +1858,25 @@ fn pairFor(ch: u21) ?struct { o: u8, c: u8 } { pub fn textobjectSelect(p: *Pardes, t: *Text, text: []const u8, obj: u21, around: bool) void { const range = t.primaryRange(text, 0); + // helix's tree-sitter objects, from the grammar's textobjects query + const object: ?modal.Normal.Object = switch (obj) { + 'f' => .function, + 't' => .class, + 'a' => .parameter, + 'c' => .comment, + 'T' => .@"test", + 'e' => .entry, + 'x' => .xml_element, + else => null, + }; + if (object) |o| { + if (t.what != .body) return; + const pane = t.pane() orelse return; + const f = if (pane.file) |*file| file else return; + const tree = normal.fileTree(p, f) orelse return; + const s = syntax.objectAt(f.path, tree, text, o, around, modal.selectionCursor(text, range)) orelse return; + return t.setRange(text, 0, .{ .anchor = s.from, .head = s.to }, false); + } switch (obj) { 'w', 'W' => { const r = modal.selectWord(text, range, around, obj == 'W'); diff --git a/src/modal.zig b/src/modal.zig index 6371475c..cc7f1ebd 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -54,6 +54,13 @@ pub const Normal = struct { add_newline, goto_diagnostic, goto_diagnostic_end, + goto_function, + goto_class, + goto_parameter, + goto_comment, + goto_test, + goto_entry, + goto_xml_element, move_left, move_right, @@ -229,6 +236,8 @@ pub const Normal = struct { }; /// helix's tree-sitter selection keys: where a range goes in the tree + /// helix's tree-sitter textobjects, by what their queries call them + pub const Object = enum { function, class, parameter, comment, @"test", entry, xml_element }; pub const NodeWalk = enum { expand, shrink, next_sibling, prev_sibling, all_siblings, all_children, parent_end, parent_start }; /// what the s/S/K/Alt-K prompt does with its pattern's matches pub const RegexMode = enum { select, split, keep, remove }; @@ -351,6 +360,7 @@ pub const Normal = struct { macro_record, macro_replay: u32, node: NodeWalk, + object_jump: struct { object: Object, direction: Direction, count: u32 }, repeat_insert: u32, pub fn scope(value: Action) Scope { @@ -540,6 +550,8 @@ pub const Normal = struct { if (key.has(.add_newline)) return resultAction(.{ .add_newline = .{ .direction = direction, .count = count } }); if (key.has(.goto_diagnostic)) return resultAction(.{ .diagnostic = .{ .direction = direction, .endpoint = false } }); if (key.has(.goto_diagnostic_end)) return resultAction(.{ .diagnostic = .{ .direction = direction, .endpoint = true } }); + inline for (.{ .{ .goto_function, .function }, .{ .goto_class, .class }, .{ .goto_parameter, .parameter }, .{ .goto_comment, .comment }, .{ .goto_test, .@"test" }, .{ .goto_entry, .entry }, .{ .goto_xml_element, .xml_element } }) |pair| + if (key.has(pair[0])) return resultAction(.{ .object_jump = .{ .object = pair[1], .direction = direction, .count = count } }); return .ignored; }, .register => { diff --git a/src/normal.zig b/src/normal.zig index 2aea2e54..b68f9ba6 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -168,13 +168,9 @@ pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, mode: m t.setRanges(text, out[0..m], &.{}, 0, true); // helix keeps primary 0 (its own TODO) } -/// helix's tree-sitter selection keys: every range walks the file's parse, -/// which is made on the first of these keys after an edit and kept, never -/// made again for a key that changed nothing. A range the tree has nothing -/// to say about stays as it was; a text without a grammar, all of them. -pub fn nodeSelect(p: *Pardes, pane: *Pane, t: *Text, walk: modal.Normal.NodeWalk) void { - if (t.what != .body) return; - const f = if (pane.file) |*file| file else return; +/// The file's parse, made on the first key that walks it after an edit and +/// kept until the next: never made again for a key that changed nothing. +pub fn fileTree(p: *Pardes, f: *panes.File.State) ?*anyopaque { if (f.node_tree == null or f.node_tree_revision != f.revision) { if (f.node_tree) |old| syntax.freeTree(old); f.node_tree = syntax.parseTree(f.path, f.content); @@ -182,7 +178,40 @@ pub fn nodeSelect(p: *Pardes, pane: *Pane, t: *Text, walk: modal.Normal.NodeWalk for (f.node_history.items) |step| p.gpa.free(step.ranges); f.node_history.clearRetainingCapacity(); } - const tree = f.node_tree orelse return; + return f.node_tree; +} + +/// `]f`/`[f` and kin (helix goto_ts_object): to the next object starting +/// after the cursor or the previous one ending before it, count times; select +/// mode stretches the range to it. +fn objectJump(p: *Pardes, pane: *Pane, t: *Text, text: []const u8, range: modal.Selection, object: modal.Normal.Object, forward: bool, count: u32) void { + if (t.what != .body) return; + const f = if (pane.file) |*file| file else return; + const tree = fileTree(p, f) orelse return; + var last = range; + for (0..count) |_| { + const s = syntax.objectNext(f.path, tree, text, object, forward, modal.selectionCursor(text, last)) orelse break; + const next: modal.Selection = .{ .anchor = s.from, .head = s.to }; + if (std.meta.eql(next, last)) break; + last = next; + } + if (std.meta.eql(last, range)) return; + if (t.select) { + const head = if (last.head < range.anchor) last.anchor else last.head; + return t.setRange(text, 0, .{ .anchor = range.anchor, .head = head }, false); + } + const out: modal.Selection = if (forward) last else .{ .anchor = last.head, .head = last.anchor }; + t.setRange(text, 0, out, false); +} + +/// helix's tree-sitter selection keys: every range walks the file's parse, +/// which is made on the first of these keys after an edit and kept, never +/// made again for a key that changed nothing. A range the tree has nothing +/// to say about stays as it was; a text without a grammar, all of them. +pub fn nodeSelect(p: *Pardes, pane: *Pane, t: *Text, walk: modal.Normal.NodeWalk) void { + if (t.what != .body) return; + const f = if (pane.file) |*file| file else return; + const tree = fileTree(p, f) orelse return; const text = f.content; const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active; const arena = p.scratch.allocator(); @@ -742,6 +771,7 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void ), .node => |walk| return nodeSelect(p, pane, t, walk), .goto_word => return startJump(p, pane, t), + .object_jump => |jump| return objectJump(p, pane, t, text, range, jump.object, jump.direction == .forward, jump.count), .macro_record => { const m = &p.macro; if (m.recording == 0) { @@ -1041,3 +1071,43 @@ test "one cursor holds no room for other ranges; a second range makes it and one try std.testing.expectEqual(@as(u16, 0), pane.body.nsel); try std.testing.expectEqual(@as(usize, 0), pane.body.sels.len); } + +test "tree-sitter textobject keys do in Python and JSON what hx 25.07.1 does" { + if (!syntax.supportsPath("a.py") or !syntax.supportsPath("a.json")) return; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + // each checked against the installed hx through a pty: the keys, then + // the text it saved + const Case = struct { path: []const u8, text: []const u8, keys: []const u8, want: []const u8 }; + for ([_]Case{ + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]fd", .want = "def alpha(a, b):\n return a + b\n\n\n\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]f]fd", .want = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\n\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "2]fd", .want = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\n\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "Gk[fd", .want = "ef alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "jmifd", .want = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "jmafd", .want = "\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "10lmiad", .want = "def alpha(, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "10lmaad", .want = "def alpha( b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]Td", .want = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\n\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]cd", .want = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n \n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "v]fd", .want = "\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]td", .want = "ef alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]ad", .want = "def alpha( b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.json", .text = "{\"a\": 1, \"b\": [1, 2]}\n", .keys = "]ed", .want = "{, \"b\": [1, 2]}\n" }, + .{ .path = "/test.json", .text = "{\"a\": 1, \"b\": [1, 2]}\n", .keys = "]e]ed", .want = "{\"a\": 1, }\n" }, + .{ .path = "/test.json", .text = "{\"a\": 1, \"b\": [1, 2]}\n", .keys = "8lmaed", .want = "{\"a\": 1,\"b\": [1, 2]}\n" }, + }) |c| { + const pane = try p.setTestFile(c.text); + p.gpa.free(pane.file.?.path); + pane.file.?.path = try p.gpa.dupe(u8, c.path); + for (c.keys, 0..) |ch, i| { + p.update(.{ .key = .{ .cp = ch, .text = c.keys[i..][0..1] } }); + while (p.nextEffect()) |_| {} + } + std.testing.expectEqualStrings(c.want, pane.file.?.content) catch |err| { + std.debug.print("keys {s} on {s}\n", .{ c.keys, c.path }); + return err; + }; + } +} diff --git a/src/syntax.zig b/src/syntax.zig index 89fc4198..3a83800b 100644 --- a/src/syntax.zig +++ b/src/syntax.zig @@ -3,6 +3,7 @@ const config = @import("pardes_config"); const tracy = @import("tracy.zig"); const grammar_manifest = @import("grammar_manifest.zig"); const look = @import("look.zig"); +const regexp = @import("regexp.zig"); const c_heap = @import("c_heap"); pub const enabled = config.syntax_highlighting; @@ -27,6 +28,10 @@ const Spec = struct { exts: []const []const u8, language: *const fn () callconv(.c) *const ts.Language, query_src: []const u8, + /// helix's textobjects.scm for the grammar (vendor/queries), "" for none + objects_src: []const u8 = "", + objects: ?*ts.Query = null, + objects_failed: bool = false, selected: ?Selected = null, capture_styles: [256]u8 = undefined, }; @@ -131,6 +136,7 @@ fn initSpecs() [specCount()]Spec { .exts = g.exts, .language = @extern(LanguageFn, .{ .name = "tree_sitter_" ++ g.name }), .query_src = querySrc(g), + .objects_src = @field(ts_queries, g.name ++ "_textobjects"), }; i += 1; } @@ -173,6 +179,9 @@ pub fn stop() void { selected.query.destroy(); } spec.selected = null; + if (spec.objects) |q| q.destroy(); + spec.objects = null; + spec.objects_failed = false; } ts_set_allocator(null, null, null, null); syntax_started = false; @@ -427,6 +436,141 @@ pub fn walkNodes(tree_ptr: *anyopaque, walk: NodeWalk, from: usize, to: usize, a } +/// helix's textobjects, which its `]f`, `mif` and kin select: the name a +/// query's captures begin with (`function.around`, `test.inside`). +pub const Object = @import("modal.zig").Normal.Object; + +fn objectName(o: Object) []const u8 { + return if (o == .xml_element) "xml-element" else @tagName(o); +} + +/// The grammar's textobject query for `path`, compiled the first time; null +/// when the grammar has none. +fn objectQuery(path: []const u8) ?*ts.Query { + const ext = std.fs.path.extension(path); + for (&specs) |*spec| for (spec.exts) |choice| { + if (!std.ascii.eqlIgnoreCase(ext, choice)) continue; + if (spec.objects) |q| return q; + if (spec.objects_src.len == 0 or spec.objects_failed) return null; + var error_offset: u32 = 0; + spec.objects = ts.Query.create(spec.language(), spec.objects_src, &error_offset) catch { + spec.objects_failed = true; + return null; + }; + return spec.objects; + }; + return null; +} + +fn captureId(q: *const ts.Query, name: []const u8) ?u32 { + for (0..q.captureCount()) |i| if (std.mem.eql(u8, q.captureNameForId(@intCast(i)) orelse "", name)) return @intCast(i); + return null; +} + +/// A match's `capture`, as one span over all its nodes (helix's grouped +/// capture), when the match's `#eq?` and `#match?` hold. +fn matchSpan(q: *const ts.Query, match: anytype, capture: u32, text: []const u8) ?Span { + var span: ?Span = null; + for (match.captures) |c| if (c.index == capture) { + const s: Span = .{ .from = c.node.startByte(), .to = c.node.endByte() }; + span = if (span) |old| .{ .from = @min(old.from, s.from), .to = @max(old.to, s.to) } else s; + }; + const found = span orelse return null; + const steps = q.predicatesForPattern(match.pattern_index); + var i: usize = 0; + while (i < steps.len) { + var end = i; + while (end < steps.len and steps[end].type != .done) end += 1; + const pred = steps[i..end]; + i = end + 1; + if (pred.len != 3 or pred[0].type != .string or pred[1].type != .capture) continue; + const op = q.stringValueForId(pred[0].value_id) orelse continue; + const subject = for (match.captures) |c| { + if (c.index == pred[1].value_id) break text[@min(c.node.startByte(), text.len)..@min(c.node.endByte(), text.len)]; + } else continue; + const want = if (pred[2].type == .string) q.stringValueForId(pred[2].value_id) orelse continue else for (match.captures) |c| { + if (c.index == pred[2].value_id) break text[@min(c.node.startByte(), text.len)..@min(c.node.endByte(), text.len)]; + } else continue; + if (std.mem.eql(u8, op, "eq?")) { + if (!std.mem.eql(u8, subject, want)) return null; + } else if (std.mem.eql(u8, op, "match?")) { + var re = regexp.Regex.compile(want) catch continue; + if ((re.find(subject, 0, subject.len, subject.len) catch null) == null) return null; + } + } + return found; +} + +/// `mi`/`ma`: the smallest `object.inside` (or `.around`) holding byte `at` +/// (helix textobject_treesitter). None without a query or such an object. +pub fn objectAt(path: []const u8, tree_ptr: *anyopaque, text: []const u8, object: Object, around: bool, at: usize) ?Span { + if (!enabled) return null; + const q = objectQuery(path) orelse return null; + var buf: [32]u8 = undefined; + const want = std.fmt.bufPrint(&buf, "{s}.{s}", .{ objectName(object), if (around) "around" else "inside" }) catch return null; + const capture = captureId(q, want) orelse return null; + const tree: *const ts.Tree = @ptrCast(@alignCast(tree_ptr)); + const cursor = ts.QueryCursor.create(); + defer cursor.destroy(); + cursor.exec(q, tree.rootNode()); + var best: ?Span = null; + while (cursor.nextMatch()) |match| { + const s = matchSpan(q, match, capture, text) orelse continue; + if (!(s.from <= at and at < s.to)) continue; + if (best == null or s.to - s.from < best.?.to - best.?.from) best = s; + } + const s = best orelse return null; + if (s.from >= text.len or s.to >= text.len) return null; + return s; +} + +/// `]f`/`[f` and kin: the next object starting after byte `at`, or the +/// previous one ending before it (helix goto_treesitter_object), from the +/// first of `.movement`, `.around`, `.inside` the query names. +pub fn objectNext(path: []const u8, tree_ptr: *anyopaque, text: []const u8, object: Object, forward: bool, at: usize) ?Span { + if (!enabled) return null; + const q = objectQuery(path) orelse return null; + var buf: [32]u8 = undefined; + const capture = for ([_][]const u8{ "movement", "around", "inside" }) |kind| { + const want = std.fmt.bufPrint(&buf, "{s}.{s}", .{ objectName(object), kind }) catch return null; + if (captureId(q, want)) |id| break id; + } else return null; + const tree: *const ts.Tree = @ptrCast(@alignCast(tree_ptr)); + const cursor = ts.QueryCursor.create(); + defer cursor.destroy(); + cursor.exec(q, tree.rootNode()); + var best: ?Span = null; + while (cursor.nextMatch()) |match| { + const s = matchSpan(q, match, capture, text) orelse continue; + if (forward) { + if (s.from <= at) continue; + if (best == null or s.from < best.?.from or (s.from == best.?.from and s.to > best.?.to)) best = s; + } else { + if (s.to >= at) continue; + if (best == null or s.to > best.?.to or (s.to == best.?.to and s.from < best.?.from)) best = s; + } + } + const s = best orelse return null; + if (s.from >= text.len or s.to >= text.len) return null; + return s; +} + +test "every vendored textobject query compiles against its grammar" { + if (!enabled) return; + var failed: usize = 0; + for (&specs) |*spec| { + if (spec.objects_src.len == 0) continue; + var error_offset: u32 = 0; + const q = ts.Query.create(spec.language(), spec.objects_src, &error_offset) catch { + std.debug.print("textobjects for {s} do not compile (offset {d})\n", .{ spec.name, error_offset }); + failed += 1; + continue; + }; + q.destroy(); + } + try std.testing.expectEqual(@as(usize, 0), failed); +} + /// Owned source analysis. Both slices use the allocator passed to analyzeSource. /// The parsed tree is released before returning; callers can cache this result. pub const SourceAnalysis = struct { diff --git a/vendor/queries/README.md b/vendor/queries/README.md new file mode 100644 index 00000000..abd37d00 --- /dev/null +++ b/vendor/queries/README.md @@ -0,0 +1,15 @@ +# Textobject queries + +`/textobjects.scm` is helix's own query for that grammar, vendored +from helix (https://github.com/helix-editor/helix) at 278b24389, the +reference build docs/helix-keys.md names. Each file says where it came from +and what it inherits, inlined. They are Mozilla Public License 2.0, whose text +is `helix.LICENSE` here. They give `]f`, `[f`, `mif` and the other +tree-sitter textobject keys what a function, class, argument, comment, test, +entry or element is. + +Not vendored: fortran, markdown and powershell (helix has no query for +them), and erlang and zig, whose helix queries do not compile against the +grammar versions pardes builds (a node name either side has renamed). Those +grammars have no textobjects, and the keys do nothing there, as in helix +without a query. diff --git a/vendor/queries/ada/textobjects.scm b/vendor/queries/ada/textobjects.scm new file mode 100644 index 00000000..a6f9c2ff --- /dev/null +++ b/vendor/queries/ada/textobjects.scm @@ -0,0 +1,26 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/ada/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +;; Support for high-level text objects selections. +;; For instance: +;; maf (v)isually select (a) (f)unction or subprogram +;; mif (v)isually select (i)nside a (f)unction or subprogram +;; mai (v)isually select (a) (i)f statement (or loop) +;; mii (v)isually select (i)nside an (i)f statement (or loop) +;; +;; For navigations using textobjects, check link below: +;; https://docs.helix-editor.com/master/usage.html#navigating-using-tree-sitter-textobjects +;; +;; For Textobject queries explanation, check out link below: +;; https://docs.helix-editor.com/master/guides/textobject.html + +(subprogram_body) @function.around +(subprogram_body (non_empty_declarative_part) @function.inside) +(subprogram_body (handled_sequence_of_statements) @function.inside) +(function_specification) @function.around +(procedure_specification) @function.around +(package_declaration) @function.around +(generic_package_declaration) @function.around +(package_body) @function.around diff --git a/vendor/queries/bash/textobjects.scm b/vendor/queries/bash/textobjects.scm new file mode 100644 index 00000000..8e7ad4f9 --- /dev/null +++ b/vendor/queries/bash/textobjects.scm @@ -0,0 +1,17 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/bash/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(function_definition + body: (_) @function.inside) @function.around + +(command + argument: (_) @parameter.inside) + +(comment) @comment.inside + +(comment)+ @comment.around + +(array + (_) @entry.around) diff --git a/vendor/queries/c/textobjects.scm b/vendor/queries/c/textobjects.scm new file mode 100644 index 00000000..34a5682f --- /dev/null +++ b/vendor/queries/c/textobjects.scm @@ -0,0 +1,32 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/c/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(function_definition + body: (_) @function.inside) @function.around + +(struct_specifier + body: (_) @class.inside) @class.around + +(enum_specifier + body: (_) @class.inside) @class.around + +(union_specifier + body: (_) @class.inside) @class.around + +(parameter_list + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(argument_list + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(comment) @comment.inside + +(comment)+ @comment.around + +(enumerator + (_) @entry.inside) @entry.around + +(initializer_list + (_) @entry.around) diff --git a/vendor/queries/c_sharp/textobjects.scm b/vendor/queries/c_sharp/textobjects.scm new file mode 100644 index 00000000..0b90f428 --- /dev/null +++ b/vendor/queries/c_sharp/textobjects.scm @@ -0,0 +1,25 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/c-sharp/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +[ + (class_declaration body: (_) @class.inside) + (struct_declaration body: (_) @class.inside) + (interface_declaration body: (_) @class.inside) + (enum_declaration body: (_) @class.inside) + (delegate_declaration) + (record_declaration body: (_) @class.inside) +] @class.around + +(constructor_declaration body: (_) @function.inside) @function.around + +(destructor_declaration body: (_) @function.inside) @function.around + +(method_declaration body: (_) @function.inside) @function.around + +(property_declaration (_) @function.inside) @function.around + +(parameter (_) @parameter.inside) @parameter.around + +(comment)+ @comment.around diff --git a/vendor/queries/clojure/textobjects.scm b/vendor/queries/clojure/textobjects.scm new file mode 100644 index 00000000..5121ff8c --- /dev/null +++ b/vendor/queries/clojure/textobjects.scm @@ -0,0 +1,76 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/clojure/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +; Function definitions (defn, defn-, defmacro, defmethod, etc.) +(list_lit + . + (sym_lit) @_keyword + . + (sym_lit) + (_)* @function.inside + (#match? @_keyword "^(defn|defn-|defmacro|defmethod|defmulti|definline)$")) @function.around + +; Anonymous functions (fn) +(list_lit + . + (sym_lit) @_fn + (_)* @function.inside + (#match? @_fn "^fn$")) @function.around + +; Anonymous function shorthand #() +(anon_fn_lit + (_)* @function.inside) @function.around + +; deftype, defrecord, defprotocol +(list_lit + . + (sym_lit) @_keyword + . + (sym_lit) + (_)* @class.inside + (#match? @_keyword "^(deftype|defrecord|defprotocol|definterface|defstruct)$")) @class.around + +; Test definitions (deftest) +(list_lit + . + (sym_lit) @_keyword + . + (sym_lit) + (_)* @test.inside + (#match? @_keyword "^deftest$")) @test.around + +; Function parameters in vectors +(vec_lit + (_)* @parameter.inside) @parameter.around + +; List entries +(list_lit + (_) @entry.inside @entry.around) + +; Vector entries +(vec_lit + (_) @entry.inside @entry.around) + +; Map entries +(map_lit + (_) @entry.inside @entry.around) + +; Set entries +(set_lit + (_) @entry.inside @entry.around) + +; Comments +(comment) @comment.inside +(comment)+ @comment.around + +; Discard expressions (also treated as comments) +(dis_expr) @comment.inside + +; Comment special form (comment ...) +(list_lit + . + (sym_lit) @_comment + (_)* @comment.inside + (#match? @_comment "^comment$")) @comment.around diff --git a/vendor/queries/cpp/textobjects.scm b/vendor/queries/cpp/textobjects.scm new file mode 100644 index 00000000..dba417a9 --- /dev/null +++ b/vendor/queries/cpp/textobjects.scm @@ -0,0 +1,39 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/cpp/textobjects.scm, with what it inherits from c inlined. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(function_definition + body: (_) @function.inside) @function.around + +(struct_specifier + body: (_) @class.inside) @class.around + +(enum_specifier + body: (_) @class.inside) @class.around + +(union_specifier + body: (_) @class.inside) @class.around + +(parameter_list + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(argument_list + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(comment) @comment.inside + +(comment)+ @comment.around + +(enumerator + (_) @entry.inside) @entry.around + +(initializer_list + (_) @entry.around) + + +(lambda_expression + body: (_) @function.inside) @function.around + +(class_specifier + body: (_) @class.inside) @class.around diff --git a/vendor/queries/css/textobjects.scm b/vendor/queries/css/textobjects.scm new file mode 100644 index 00000000..c6713c53 --- /dev/null +++ b/vendor/queries/css/textobjects.scm @@ -0,0 +1,27 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/css/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +; Rule sets and their declaration blocks +(rule_set) @function.around +(rule_set + (block) @function.inside) + +(keyframe_block_list) @function.around +(keyframe_block + (block) @function.inside) + +; At-rules with a body (e.g. @media, @supports) +(media_statement) @class.around +(media_statement + (block) @class.inside) + +; Declarations as key/value entries +(declaration) @entry.around +(declaration + ":" + (_) @entry.inside) + +(comment) @comment.inside +(comment)+ @comment.around diff --git a/vendor/queries/elixir/textobjects.scm b/vendor/queries/elixir/textobjects.scm new file mode 100644 index 00000000..ebe18d08 --- /dev/null +++ b/vendor/queries/elixir/textobjects.scm @@ -0,0 +1,40 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/elixir/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +; Function heads and guards have no body at all, so `keywords` and `do_block` nodes are both optional +((call + target: (identifier) @_keyword + (arguments + [ + (call + (arguments (_)? @parameter.inside)) + ; function has a guard + (binary_operator + left: + (call + (arguments (_)? @parameter.inside))) + ] + ; body is "do: body" instead of a do-block + (keywords + (pair + value: (_) @function.inside))?)? + (do_block (_)* @function.inside)?) + (#match? @_keyword "^(def|defdelegate|defguard|defguardp|defmacro|defmacrop|defn|defnp|defp)$")) @function.around + +(anonymous_function + (stab_clause right: (body) @function.inside)) @function.around + +((call + target: (identifier) @_keyword + (do_block (_)* @class.inside)) + (#match? @_keyword "^(defmodule|defprotocol|defimpl)$")) @class.around + +((call + target: (identifier) @_keyword + (arguments ((string) . (_)?)) + (do_block (_)* @test.inside)?) + (#match? @_keyword "^(test|describe)$")) @test.around + +(comment)+ @comment.around @comment.inside diff --git a/vendor/queries/go/textobjects.scm b/vendor/queries/go/textobjects.scm new file mode 100644 index 00000000..48529015 --- /dev/null +++ b/vendor/queries/go/textobjects.scm @@ -0,0 +1,39 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/go/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(function_declaration + body: (block)? @function.inside) @function.around + +(func_literal + body: (block)? @function.inside) @function.around + +(method_declaration + body: (block)? @function.inside) @function.around + +;; struct and interface declaration as class textobject? +(type_declaration + (type_spec (type_identifier) (struct_type (field_declaration_list (_)?) @class.inside))) @class.around + +(type_declaration + (type_spec (type_identifier) (interface_type (method_elem)+ @class.inside))) @class.around + +(type_parameter_list + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(parameter_list + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(argument_list + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(comment) @comment.inside + +(comment)+ @comment.around + +; Go test/benchmark/example/fuzz functions (all live in _test.go). +((function_declaration + name: (identifier) @_name + body: (block)? @test.inside) @test.around + (#match? @_name "^(Test|Benchmark|Example|Fuzz)")) diff --git a/vendor/queries/haskell/textobjects.scm b/vendor/queries/haskell/textobjects.scm new file mode 100644 index 00000000..2a085248 --- /dev/null +++ b/vendor/queries/haskell/textobjects.scm @@ -0,0 +1,29 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/haskell/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(comment) @comment.inside +(comment)+ @comment.around + +(newtype + (newtype_constructor + (_) @class.inside)) @class.around +(data_type + constructors: (_) @class.inside) @class.around +(decl/function + (match expression:(_) @function.inside)) @function.around +(lambda + expression:(_) @function.inside) @function.around + +(decl/function + patterns: (patterns + (_) @parameter.inside)) + +(expression/lambda + patterns: (patterns + (_) @parameter.inside)) + +(decl/function + (infix + (pattern) @parameter.inside)) diff --git a/vendor/queries/helix.LICENSE b/vendor/queries/helix.LICENSE new file mode 100644 index 00000000..a612ad98 --- /dev/null +++ b/vendor/queries/helix.LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/vendor/queries/html/textobjects.scm b/vendor/queries/html/textobjects.scm new file mode 100644 index 00000000..b7c4b83e --- /dev/null +++ b/vendor/queries/html/textobjects.scm @@ -0,0 +1,14 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/html/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(script_element (start_tag) (_) @xml-element.inside (end_tag)) @xml-element.around + +(style_element (start_tag) (_) @xml-element.inside (end_tag)) @xml-element.around + +(element (start_tag) (_)* @xml-element.inside (end_tag)) + +(element) @xml-element.around + +(comment) @comment.around diff --git a/vendor/queries/java/textobjects.scm b/vendor/queries/java/textobjects.scm new file mode 100644 index 00000000..306efb56 --- /dev/null +++ b/vendor/queries/java/textobjects.scm @@ -0,0 +1,49 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/java/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(method_declaration + body: (_)? @function.inside) @function.around + +(constructor_declaration + body: (_) @function.inside) @function.around + +(interface_declaration + body: (_) @class.inside) @class.around + +(class_declaration + body: (_) @class.inside) @class.around + +(record_declaration + body: (_) @class.inside) @class.around + +(enum_declaration + body: (_) @class.inside) @class.around + +(formal_parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(type_parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(type_arguments + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(argument_list + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +[ + (line_comment) + (block_comment) +] @comment.inside + +(line_comment)+ @comment.around + +(block_comment) @comment.around + +(array_initializer + (_) @entry.around) + +(enum_body + (enum_constant) @entry.around) diff --git a/vendor/queries/javascript/textobjects.scm b/vendor/queries/javascript/textobjects.scm new file mode 100644 index 00000000..50fe599c --- /dev/null +++ b/vendor/queries/javascript/textobjects.scm @@ -0,0 +1,8 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/javascript/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +; See runtime/queries/ecma/README.md for more info. + +; inherits: _javascript,ecma diff --git a/vendor/queries/json/textobjects.scm b/vendor/queries/json/textobjects.scm new file mode 100644 index 00000000..e0df6ecd --- /dev/null +++ b/vendor/queries/json/textobjects.scm @@ -0,0 +1,13 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/json/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(pair + (_) @entry.inside) @entry.around + +(array + (_) @entry.around) + +(comment) @comment.inside +(comment)+ @comment.around diff --git a/vendor/queries/kotlin/textobjects.scm b/vendor/queries/kotlin/textobjects.scm new file mode 100644 index 00000000..b02a8f7e --- /dev/null +++ b/vendor/queries/kotlin/textobjects.scm @@ -0,0 +1,50 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/kotlin/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(function_declaration + (function_body)? @function.inside) @function.around + +; Unlike function_body above, the constructor body is does not have its own +; symbol in the current grammar. +(secondary_constructor) @function.around + +(class_declaration + (class_body)? @class.inside) @class.around + +(class_declaration + (enum_class_body) @class.inside) @class.around + +[ + (line_comment) + (multiline_comment) +] @comment.inside + +(line_comment)+ @comment.around + +(multiline_comment) @comment.around + +(enum_entry) @entry.around +(lambda_literal) @entry.around +(property_declaration) @entry.around +(object_declaration) @entry.around +(assignment) @entry.around + +; TODO: This doesn't work with annotations yet, but fixing it without breaking +; the case of multiple parameters is non-trivial. +(function_value_parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +; secondary constructor uses function_value_parameters above +(primary_constructor + ((_)@parameter.inside . ","? @parameter.around) @parameter.around) + +(function_type_parameters + ((_)@parameter.inside . ","? @parameter.around) @parameter.around) + +(value_arguments + ((_)@parameter.inside . ","? @parameter.around) @parameter.around) + +(lambda_parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) diff --git a/vendor/queries/ocaml/textobjects.scm b/vendor/queries/ocaml/textobjects.scm new file mode 100644 index 00000000..9e6bb2cd --- /dev/null +++ b/vendor/queries/ocaml/textobjects.scm @@ -0,0 +1,37 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/ocaml/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +; Value/function definitions +(value_definition) @function.around +(value_definition + (let_binding + body: (_) @function.inside)) + +(fun_expression + body: (_) @function.inside) @function.around + +(function_expression) @function.around + +(method_definition + body: (_) @function.inside) @function.around + +; Types and modules +(type_definition) @class.around +(type_definition + (type_binding + body: (_) @class.inside)) + +(module_definition) @class.around +(module_definition + (module_binding + body: (_) @class.inside)) + +; Parameters +(parameter) @parameter.around +(parameter + pattern: (_) @parameter.inside) + +(comment) @comment.inside +(comment)+ @comment.around diff --git a/vendor/queries/pascal/textobjects.scm b/vendor/queries/pascal/textobjects.scm new file mode 100644 index 00000000..9abb7871 --- /dev/null +++ b/vendor/queries/pascal/textobjects.scm @@ -0,0 +1,14 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/pascal/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(declType (declClass (declSection) @class.inside)) @class.around + +(defProc body: (_) @function.inside) @function.around + +(declArgs (_) @parameter.inside) @parameter.around +(exprArgs (_) @parameter.inside) @parameter.around + +(comment) @comment.inside +(comment)+ @comment.around diff --git a/vendor/queries/php/textobjects.scm b/vendor/queries/php/textobjects.scm new file mode 100644 index 00000000..8bf7fab9 --- /dev/null +++ b/vendor/queries/php/textobjects.scm @@ -0,0 +1,63 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/php/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(class_declaration + body: (_) @class.inside) @class.around + +(interface_declaration + body: (_) @class.inside) @class.around + +(trait_declaration + body: (_) @class.inside) @class.around + +(enum_declaration + body: (_) @class.inside) @class.around + +(function_definition + body: (_) @function.inside) @function.around + +(method_declaration + body: (_) @function.inside) @function.around + +(arrow_function + body: (_) @function.inside) @function.around + +(anonymous_function + body: (_) @function.inside) @function.around + +(anonymous_class + body: (_) @class.inside) @class.around + +(property_hook + body: (_) @function.inside) @function.around + +(anonymous_function_use_clause + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(formal_parameters + ([ + (simple_parameter) + (variadic_parameter) + (property_promotion_parameter) + ] @parameter.inside . ","? @parameter.around) @parameter.around) + +(arguments + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(comment) @comment.inside + +(comment)+ @comment.around + +(array_creation_expression + (array_element_initializer + (_) @entry.inside + ) @entry.around @entry.movement) + +(list_literal + (_) @entry.inside @entry.around @entry.movement) + +[ + (enum_case) +] @entry.around @entry.movement diff --git a/vendor/queries/python/textobjects.scm b/vendor/queries/python/textobjects.scm new file mode 100644 index 00000000..c4dbcd0e --- /dev/null +++ b/vendor/queries/python/textobjects.scm @@ -0,0 +1,40 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/python/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(function_definition + body: (block)? @function.inside) @function.around + +(class_definition + body: (block)? @class.inside) @class.around + +(parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(lambda_parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(argument_list + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(comment) @comment.inside + +(comment)+ @comment.around + +((function_definition + name: (identifier) @_name + body: (block)? @test.inside) @test.around + (#match? @_name "^test_")) + +(list + (_) @entry.around) + +(tuple + (_) @entry.around) + +(set + (_) @entry.around) + +(pair + (_) @entry.inside) @entry.around diff --git a/vendor/queries/ruby/textobjects.scm b/vendor/queries/ruby/textobjects.scm new file mode 100644 index 00000000..368d80b9 --- /dev/null +++ b/vendor/queries/ruby/textobjects.scm @@ -0,0 +1,61 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/ruby/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +; Class and Modules +(class + body: (_)? @class.inside) @class.around + +(singleton_class + value: (_) + (_)+ @class.inside) @class.around + +(call + receiver: (constant) @class_const + method: (identifier) @class_method + (#match? @class_const "Class") + (#match? @class_method "new") + (do_block (_)+ @class.inside)) @class.around + +(module + body: (_)? @class.inside) @class.around + +; Functions and Blocks +(singleton_method + body: (_)? @function.inside) @function.around + +(method + body: (_)? @function.inside) @function.around + +(do_block + body: (_)? @function.inside) @function.around + +(block + body: (_)? @function.inside) @function.around + +; Parameters +(method_parameters + (_) @parameter.inside) @parameter.around + +(block_parameters + (_) @parameter.inside) @parameter.around + +(lambda_parameters + (_) @parameter.inside) @parameter.around + +; Comments +(comment) @comment.inside +(comment)+ @comment.around + +(pair + (_) @entry.inside) @entry.around + +(array + (_) @entry.around) + +(string_array + (_) @entry.around) + +(symbol_array + (_) @entry.around) diff --git a/vendor/queries/rust/textobjects.scm b/vendor/queries/rust/textobjects.scm new file mode 100644 index 00000000..d888c762 --- /dev/null +++ b/vendor/queries/rust/textobjects.scm @@ -0,0 +1,90 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/rust/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(function_item + body: (_) @function.inside) @function.around + +(closure_expression + body: (_) @function.inside) @function.around + +(struct_item + body: (_) @class.inside) @class.around + +(enum_item + body: (_) @class.inside) @class.around + +(union_item + body: (_) @class.inside) @class.around + +(trait_item + body: (_) @class.inside) @class.around + +(impl_item + body: (_) @class.inside) @class.around + +(parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(type_parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(type_arguments + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(closure_parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(arguments + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(field_initializer_list + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +[ + (line_comment) + (block_comment) +] @comment.inside + +(line_comment)+ @comment.around + +(block_comment) @comment.around + +(; #[test] + (attribute_item + (attribute + (identifier) @_test_attribute)) + ; allow other attributes like #[should_panic] and comments + [ + (attribute_item) + (line_comment) + ]* + ; the test function + (function_item + body: (_) @test.inside) @test.around + (#eq? @_test_attribute "test")) + +(array_expression + (_) @entry.around) + +(tuple_expression + (_) @entry.around) + +(tuple_pattern + (_) @entry.around) + +; Commonly used vec macro initializer is special cased +(macro_invocation + (identifier) @_id (token_tree (_) @entry.around) + (#eq? @_id "vec")) + +(enum_variant) @entry.around + +(field_declaration + (_) @entry.inside) @entry.around + +(field_initializer + (_) @entry.inside) @entry.around + +(shorthand_field_initializer) @entry.around diff --git a/vendor/queries/scala/textobjects.scm b/vendor/queries/scala/textobjects.scm new file mode 100644 index 00000000..7ff60759 --- /dev/null +++ b/vendor/queries/scala/textobjects.scm @@ -0,0 +1,70 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/scala/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +; Function queries + +(function_definition + body: (_) @function.inside) @function.around ; Does not include end marker + +(lambda_expression + (_) @function.inside) @function.around + +; Scala 3 braceless lambda +(colon_argument + (_) @function.inside) @function.around + + +; Class queries + +(object_definition + body: (_)? @class.inside) @class.around + +(class_definition + body: (_)? @class.inside) @class.around + +(trait_definition + body: (_)? @class.inside) @class.around + +(type_definition) @class.around + +(enum_case_definitions) @class.around + +(enum_definition + body: (_)? @class.inside) @class.around + + +; Parameter queries + +(parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(class_parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(parameter_types + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(bindings + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +; Does not match context bounds or higher-kinded types +(type_parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(arguments + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(type_arguments + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + + +; Comment queries + +[(comment) (block_comment)] @comment.inside +[(comment) (block_comment)] @comment.around ; Does not match consecutive block comments + + +; Test queries +; Not supported diff --git a/vendor/queries/typst/textobjects.scm b/vendor/queries/typst/textobjects.scm new file mode 100644 index 00000000..64c99275 --- /dev/null +++ b/vendor/queries/typst/textobjects.scm @@ -0,0 +1,28 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/typst/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(let + pattern: (call) + value: (_) @function.inside) @function.around + +(call + (group + ((_) @parameter.inside . ","? @parameter.around) @parameter.around)) + +(lambda + pattern: + (group + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + value: (_) @function.inside) @function.around + +(group + [ + (tagged (_) @entry.inside) + (_) + ] @entry.around) + +(comment) @comment.inside + +(comment)+ @comment.around -- cgit v1.3