From fde030a699b3d6667d1fa21ae192b5011e3f373f Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 23:50:56 -0300 Subject: Rules and chrome scale with the display's density; dark pages get visible separators and focus tints Density: dp() in gui.zig draws every size given in pixels in logical pixels (SDL's window display scale, rounded, at least one), as acme scales Border, ButtonBorder and Scrollgap: the rules (rule_px), the tag rule, the grip's ring, its dirty gap and mode bars, the rail's gap to the text and its minimum, the thumb's inset, the rim, a bar cursor's minimum width, underline and strikethrough thickness (the atlas is dropped on a scale change), the rule-grab margin, and the Crt's scanline and mask periods (pardesScale in the post prefix). Cell- and tagline-measured sizes stay: the rail's width, Lift's blur and offsets, every motion; iResolution, fragCoord and iMouse stay in the display's pixels, as ghostty's. PARDES_TEST_SCALE forces a density in a harness (a test window is 1x otherwise, whatever display it lands on) and sizes the test font with it. Colours: a separator that stands off its page by less than 1.5:1 is lifted toward the text to 1.6:1 (helix, atelier, paper; a theme with no page, dark, is judged against #121212), and empty_col follows it. forge's own rule already stands at 3.83:1; what hid it was density: 2 device pixels on a 3x screen, now 6 (dp). A focused tag's tint closer than 1.5:1 to the plain tag on a dark page (1.25:1 on a light one) is pushed further the way it leans, as far as its text keeps its own contrast or 4.5: forge 1.23 -> 1.51 (#2d3440 -> #374253), and likewise dusk, orchard, ink, lagoon, clay, harvest, spectrum, solarium, forge_soft; daybreak, atelier, paper to 1.25. dark and helix gain a tint of their own (dark: #484566 under #c8c8c8, 1.54:1; helix: ramp 237 under 250, 1.65:1). Tag caret: past a full row's end, the G3 glide box sits on the tag's right edge, where the layer's caret and the pixel shell's are. Tests: every native theme's separator >= 1.5 and every dark one has a tint >= 1.25 with readable text; forge keeps its own border; the tag caret glide box at a full row; at 2x the spine, tag rule, ring, rail start and gap, and bar cursor double. Legacy-ThemeFile test strips helix's new pair. Goldens: snapshot goldens change in colour rows only, the tints (orchard #283a2f -> #314738 at boot, forge, helix and dark in theme/themesel). GUI goldens: the harness's single Del of the renamed wide pane is refused since ysqnkyoy (a rename counted as unsaved; fixed on the 9P line in wkzlsqvy), so scenes 09-17 were recorded with that pane open and the file was stale since the merge (08 and 18 also moved with ykvnltvs's full-row tag wrap). The goldens here were recorded with that pane closed (as wkzlsqvy will close it); against the parent's binary under the same conditions they differ only in the tints: 01-13 forge's (#2d3440 -> #374253, and the glyph edges on it), 14 ink's; 15-18 are byte-identical. Until wkzlsqvy is merged, a run here fails 09-17. Each commit reproduces its own recorded goldens on this machine (checked at kvtmqkyn). A test window is 1x unless PARDES_TEST_SCALE says otherwise, whatever display it opens on. Prebuilt shaders refreshed from their sources. Captures: .scratch/render/dark-borders/{before,after,compare,scale2,scale3}. --- docs/effects.md | 13 +++++++++++++ docs/themes.md | 8 ++++---- 2 files changed, 17 insertions(+), 4 deletions(-) (limited to 'docs') diff --git a/docs/effects.md b/docs/effects.md index 08fd3d88..c8e5c7a7 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -13,6 +13,19 @@ its text. A dim touches only the unfocused panes, with the same floor, and the focused pane's text is never at less contrast than theirs (tests in src/draw.zig). +**Density.** A pixel shell draws every size given in pixels in logical +pixels, `dp()` in gui.zig (SDL's window display scale, rounded, at least +one; `PARDES_TEST_SCALE` forces one in a harness, and a test window is 1x +otherwise): the rules (`rule_px`), the tag rule, the grip's ring and its +dirty gap and mode bars, the rail's gap to the text and its minimum, the +thumb's inset, the rim, a bar cursor's minimum width, underline and +strikethrough thickness, the rule-grab margin, and the Crt's scanline and +mask periods (`pardesScale`). Left as they are: what is measured in cells +or the tagline (the rail's width, `rail_px` scaled with the tagline; Lift's +blur and offsets; every motion, in cells), Shadertoy's iResolution, +fragCoord and iMouse (the display's pixels, as ghostty's), and the pet and +touch debug overlays. + Each effect of docs/render-pipeline.md §9 lands behind its own switch, off, and is kept only after a feel review (§8.4): a frame series on the virtual clock, a recording, and a verdict — keep, polish or drop — with one line of diff --git a/docs/themes.md b/docs/themes.md index e028e806..2b15051e 100644 --- a/docs/themes.md +++ b/docs/themes.md @@ -102,13 +102,13 @@ so existing exported themes remain valid. | Optional role | Purpose | When absent | | --- | --- | --- | -| `tag_active_bg`, `tag_active_fg` | Focused pane tag surface and text | `tag_bg`, `tag_fg` | +| `tag_active_bg`, `tag_active_fg` | Focused pane tag surface and text; a surface closer than 1.25:1 to `tag_bg` is pushed further the way it already leans, as far as its text keeps its own contrast (or 4.5). Every dark native theme sets one | `tag_bg`, `tag_fg` | | `tag_name_fg` | Filename or terminal `Tty` command tint | The corresponding normal or active tag foreground | | `tag_active_name_fg` | Filename or terminal `Tty` tint in active tags | `tag_name_fg`, then the active tag foreground | | `column_box` | Column grip while held, and its drag rail | `num` | | `column_box_dim` | Column drag grip at rest, in every focus state | Equal mix of `column_box` and `tag_bg` | -| `border` | Quiet separators | `scroll_track` | -| `empty_col` | A column with no pane, under its tag (acme: white) | `border` | +| `border` | Quiet separators; one that stands off the page by less than 1.5:1 (a dark rule on a near-black page) is lifted toward the text to 1.6:1. A theme with no page is judged against #121212 | `scroll_track` | +| `empty_col` | A column with no pane, under its tag (acme: white) | `border`, as drawn | | `lineno_active` | Restrained current line number foreground | `lineno` | | `search_bg`, `search_fg` | Search matches, independent of selection | `sel_bg`, `sel_fg` | | `diagnostic_error` | Error text | `fg`, or `tag_fg` for an inherited foreground | @@ -118,7 +118,7 @@ so existing exported themes remain valid. | `tag_sel_bg` | A tag's selection ground | `sel_bg` | | `sweep_bg`, `sweep_fg` | Three triples each: the select, exec and look sweeps' ground and ink | `sel_bg` tinted toward `kw`, `str` and `num`, in `sel_fg` | | `tag_rule` | The one-pixel rule between a pane's tag and its body, quieter than the `rule_px` rules between panes and columns | Halfway between `tag_bg` and the page the shell draws | -| `rule_px` | Pixel shells: the width of the rules between columns, between panes stacked in a column, and under the workspace and column tags, in `border` (the rule between a tag and its body is always one pixel) | 2 | +| `rule_px` | Pixel shells: the width of the rules between columns, between panes stacked in a column, and under the workspace and column tags, in `border` (the rule between a tag and its body is always one pixel), in logical pixels: doubled on a 2x display, as acme scales its Border | 2 | | `box_border`, `box_dirty` | The grip is acme's button: `box` filled when focused, else a two-pixel ring of `box_border` round the tag's ground; `box_dirty` fills it inside either ring while its file is unsaved (a grid, which has no ring, shows a bold `*` on `box_dirty` in the grip's second cell) | `box_dim`; `diagnostic_warning`, then `num` | | `rail_px` | Pixel shells: the scroll column's width, the grip's button over the scrollbar (thumb a pixel narrower), in pixels at a 17px tagline, scaled with it | 12, acme's Scrollwid | -- cgit v1.3 From 4452a8f467765cbfa0dd0444a9bf416ac8bd0bf5 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 05:44:11 -0300 Subject: G4: bloom, vignette and grain in the post chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bundled Shadertoy passes beside Crt, each `Bloom|Vignette|Grain 0..3` (off by default, nothing drawn while off), in shaders/post/: - Bloom: what is brighter than the page (a channel's linear peak over 0.75/0.7/0.6 and over the page's own + 0.15, so a light page does not bloom) glows, 2/3.5/5% (§8.2's budget). The glow is a dual Kawase blur in RGBA16F textures at half size and down, 3/4/5 steps by level (shaders/bloom-down, bloom-up; Post.bloomChain), composited in linear light and dithered. - Vignette: round, from halfway to a corner, 10/18/28% at the far corners, in linear light, dithered. - Grain: a still, triangular-spread grain of 1.5/3/5 of 255 on page-coloured pixels only, a logical pixel big; still, so it asks for no frames. Focus rule: the shell passes every bundled pass the rectangles of the tags with their grips, the column and workspace tags, the notices and the cursor (pardesSpare, at most 32, neighbours on one row merged), and a bundled pass leaves them as they are; and a per-theme ceiling (pardesCap, Post.capsOf) keeps the page's text and the selection's text at min(their contrast, 4.5) under each pass at its strongest. User files get no ceiling and no spared rectangles. Fix found on the way: SDL GPU binds at most 4 KiB of a uniform block, so every Shadertoy colour after iPalette (iBackgroundColor, iForegroundColor, the cursor and selection colours, iPalette[>=4]) read zero. The prefix's block is cut in three (Globals, Palette, Colors) with ghostty's names and order, each pushed as a slice of the one struct; pardes's own block moves to binding 3. Tests: the caps hold over every native theme; each uniform slice fits 4 KiB; the prefix's members in ghostty's order across its blocks. Gates: tty/gui unit-test and core-test pass; snap fails only nested-optout; GUI goldens 01-08 byte-identical (09-18 wait on wkzlsqvy, as in somtrmsz); web builds. Feel review material: .scratch/render/{bloom,vignette,grain}/ (levels on forge, dusk, acme, crops, a levels mp4). --- build.zig | 10 +- docs/config.md | 16 +- docs/effects.md | 18 ++ docs/render-pipeline.md | 4 +- shaders/bloom-down.frag.glsl | 39 +++++ shaders/bloom-up.frag.glsl | 26 +++ shaders/post/bloom.glsl | 40 +++++ shaders/post/grain.glsl | 32 ++++ shaders/post/prefix.glsl | 27 ++- shaders/post/vignette.glsl | 38 ++++ shaders/prebuilt/bloom-down.frag.glsl | 39 +++++ shaders/prebuilt/bloom-down.frag.spv | Bin 0 -> 3876 bytes shaders/prebuilt/bloom-up.frag.glsl | 26 +++ shaders/prebuilt/bloom-up.frag.spv | Bin 0 -> 3504 bytes shaders/prebuilt/post-bloom.frag.glsl | 141 +++++++++++++++ shaders/prebuilt/post-bloom.frag.spv | Bin 0 -> 8500 bytes shaders/prebuilt/post-crt.frag.glsl | 27 ++- shaders/prebuilt/post-crt.frag.spv | Bin 12668 -> 13092 bytes shaders/prebuilt/post-grain.frag.glsl | 133 ++++++++++++++ shaders/prebuilt/post-grain.frag.spv | Bin 0 -> 8236 bytes shaders/prebuilt/post-vignette.frag.glsl | 139 +++++++++++++++ shaders/prebuilt/post-vignette.frag.spv | Bin 0 -> 8652 bytes src/config.zig | 15 +- src/effect_sources.zig | 13 ++ src/gui/Post.zig | 291 +++++++++++++++++++++++++++++-- src/gui/gui.zig | 42 +++++ 26 files changed, 1089 insertions(+), 27 deletions(-) create mode 100644 shaders/bloom-down.frag.glsl create mode 100644 shaders/bloom-up.frag.glsl create mode 100644 shaders/post/bloom.glsl create mode 100644 shaders/post/grain.glsl create mode 100644 shaders/post/vignette.glsl create mode 100644 shaders/prebuilt/bloom-down.frag.glsl create mode 100644 shaders/prebuilt/bloom-down.frag.spv create mode 100644 shaders/prebuilt/bloom-up.frag.glsl create mode 100644 shaders/prebuilt/bloom-up.frag.spv create mode 100644 shaders/prebuilt/post-bloom.frag.glsl create mode 100644 shaders/prebuilt/post-bloom.frag.spv create mode 100644 shaders/prebuilt/post-grain.frag.glsl create mode 100644 shaders/prebuilt/post-grain.frag.spv create mode 100644 shaders/prebuilt/post-vignette.frag.glsl create mode 100644 shaders/prebuilt/post-vignette.frag.spv (limited to 'docs') diff --git a/build.zig b/build.zig index e63b7f4b..d4f3e120 100644 --- a/build.zig +++ b/build.zig @@ -51,9 +51,15 @@ const gui_shaders = [_][]const u8{ "overlay.vert", "overlay.frag", "image.vert", "image.frag", "post.vert", - // The bundled post pass: shaders/post/crt.glsl behind the Shadertoy - // prefix (shaderSource). + // The bundled post passes: shaders/post/.glsl behind the + // Shadertoy prefix (shaderSource). "post-crt.frag", + "post-bloom.frag", + "post-vignette.frag", + "post-grain.frag", + // Bloom's steps below the frame (src/gui/Post.zig bloomChain). + "bloom-down.frag", + "bloom-up.frag", }; pub fn build(b: *std.Build) void { diff --git a/docs/config.md b/docs/config.md index 40d0fb24..b3cff3a4 100644 --- a/docs/config.md +++ b/docs/config.md @@ -122,7 +122,7 @@ such a shell there when it is the column's only one and nobody has typed into it (no scrollback, cursor still on the first prompt line), the boot's placeholder giving its rows to the document. Bare `BootShell` flips it, as bare `Placement` does: a setting that chooses among words (`on`/`off` -switches, `Placement`, `BootShell`, `Crt`, `Lift`, `Motion`, +switches, `Placement`, `BootShell`, `Crt`, `Bloom`, `Vignette`, `Grain`, `Lift`, `Motion`, `ShaderAnimation`) steps to its next value when written bare, as its word clicked in a tag does, and a value it does not take is refused with the values it takes, which `/commands` also @@ -542,6 +542,20 @@ 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. +Three more bundled passes take the same levels, each off by default and +each costing nothing while off: + +```text +Bloom 2 the brightest ink glows a little (only what is brighter than + the page; a dual Kawase blur at half size and down) +Vignette 2 the window's corners fall a little into shade +Grain 2 the page's own ground takes a fine, still grain, like paper +``` + +None of them touches a tag, a grip, a notice or the cursor, and each is +capped per theme so text and the selection keep their own contrast or 4.5 +(docs/effects.md). + The focused pane can stand off the page (SDL GUI, off by default): ```text diff --git a/docs/effects.md b/docs/effects.md index c8e5c7a7..8073c50a 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -37,6 +37,7 @@ why. The verdict is the user's. | Ripple, Glitch | — | removed | live window | dropped by the user after a live look | | G1 lift | `Lift shadow\|rim\|auto` (off), `InactiveDim ` | opt-in until the focus-lift default is decided | lift-shots-2 (off, shadow, rim, auto on acme, dusk, forge); frame series + mp4 per Motion flavour | shadow on acme: keep as is. Round 1 dropped glow and surface (surface lowered the focused text's contrast) and made rim a hairline just above the focused tag (light on a dark page, shade on a light one). auto on a dark page recedes the others (InactiveDim 30) | | G3 cursor | follows `Motion`; `CursorBlink on\|off` (on) | glide on by default through the default flavour (smooth); blink on (decision 6) | .scratch/render/cursor/cursor-flavours-grid.mp4 (and quarter speed), raw/// | pending | +| G4 bloom, vignette, grain | `Bloom`, `Vignette`, `Grain` `0..3` (off) | bundled post passes; none touches a tag, grip, notice or the cursor (pardesSpare); each capped per theme (pardesCap) | .scratch/render/{bloom,vignette,grain}/ (levels 0-3 on forge, dusk, acme; crops; mp4 stepping through the levels) | pending | ## Motion flavours @@ -96,3 +97,20 @@ forge's text goes from 15.5:1 to 11.2:1 and dusk's from 6.4:1 to 4.8:1, plainly quieter and still easy reading; at 10 forge barely moves (14.1:1). On a light page the same percent bites far harder (acme: 7.0:1 at 10, the 4.5 floor at 30), which is why `auto` shades there instead of dimming. + +Notes on G4: the three are post passes, so each is a frame of cost only +when it is in the chain. None moves (grain is still, by choice: moving +grain would redraw every frame for a texture), so none asks for frames. +The focus rule is kept by construction: the shell hands every pass the +rectangles of the tags (with their grips), the column and workspace tags, +the notices and the cursor, and a bundled pass leaves them as they are; its +strength is capped per theme so the text on the page and the selection's +text keep min(their contrast, 4.5) (Post.capsOf, tested over the native +themes). Bloom takes only what is brighter than the page, so a light page +does not bloom at all; its glow is a dual Kawase blur in float textures at +half size and down (3 to 5 steps by level), within §8.2's 5%. The vignette +is round, starts halfway to a corner and reaches 10, 18 and 28%. Grain is +1.5, 3 and 5 of 255 on page-coloured pixels only, a logical pixel big. +Found on the way: SDL GPU binds at most 4 KiB of a uniform block, so every +Shadertoy colour after the palette (iBackgroundColor and on) read zero; the +block is now cut in three with ghostty's names and order kept. diff --git a/docs/render-pipeline.md b/docs/render-pipeline.md index 7c94fbea..b8f735d9 100644 --- a/docs/render-pipeline.md +++ b/docs/render-pipeline.md @@ -460,7 +460,9 @@ Mirror ghostty 1.3.2 (`zig-pkg/ghostty-*/src/renderer/shadertoy.zig`, iBackgroundColor, iForegroundColor, iCursorColor, iCursorText, iSelectionForegroundColor, iSelectionBackgroundColor; CURSORSTYLE_* defines; `#define texture2D texture`), but SDL GPU bindings: `iChannel0` at set 2 - binding 0, the block at set 3 binding 0. `main()` calls + binding 0, the block at set 3 bindings 0 to 2 (cut before and after + iPalette: SDL GPU binds at most 4 KiB of a block, so one block left + the colours after the palette at zero until G4), pardes's own at 3. `main()` calls `mainImage(_fragColor, gl_FragCoord.xy)`. Trap found: ghostty's Zig `Uniforms` (shadertoy.zig:13) orders selection_background before selection_foreground while its GLSL block orders Foreground before Background, diff --git a/shaders/bloom-down.frag.glsl b/shaders/bloom-down.frag.glsl new file mode 100644 index 00000000..3465926d --- /dev/null +++ b/shaders/bloom-down.frag.glsl @@ -0,0 +1,39 @@ +#version 450 + +// Bloom, down (docs/effects.md G4): one step of a dual Kawase filter, a +// source half as big again sampled at five points, the first step over the +// frame through a bright pass in linear light. Written to a float target. + +layout(set = 2, binding = 0) uniform sampler2D source; +layout(std140, set = 3, binding = 0) uniform BloomStep { + // xy: a source texel; zw: a target texel. + vec4 texels; + // x: the bright pass's threshold on a channel's linear peak (above the + // page's own), 0 for none (a later step); y: its soft knee. + vec4 pass; +} u; + +layout(location = 0) out vec4 o_col; + +vec3 toLinear(vec3 c) { + return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); +} + +vec3 tap(vec2 uv) { + vec3 c = texture(source, uv).rgb; + if (u.pass.x <= 0.0) return c; + c = toLinear(c); + float peak = max(c.r, max(c.g, c.b)); + return c * smoothstep(u.pass.x, u.pass.x + u.pass.y, peak); +} + +void main() { + vec2 uv = gl_FragCoord.xy * u.texels.zw; + vec2 h = 0.5 * u.texels.xy; + vec3 sum = tap(uv) * 4.0; + sum += tap(uv - h); + sum += tap(uv + h); + sum += tap(uv + vec2(h.x, -h.y)); + sum += tap(uv - vec2(h.x, -h.y)); + o_col = vec4(sum / 8.0, 1.0); +} diff --git a/shaders/bloom-up.frag.glsl b/shaders/bloom-up.frag.glsl new file mode 100644 index 00000000..368076ed --- /dev/null +++ b/shaders/bloom-up.frag.glsl @@ -0,0 +1,26 @@ +#version 450 + +// Bloom, up (docs/effects.md G4): one step of a dual Kawase filter back up, +// a tent of eight points over the smaller level. Float target. + +layout(set = 2, binding = 0) uniform sampler2D source; +layout(std140, set = 3, binding = 0) uniform BloomStep { + vec4 texels; + vec4 pass; +} u; + +layout(location = 0) out vec4 o_col; + +void main() { + vec2 uv = gl_FragCoord.xy * u.texels.zw; + vec2 h = 0.5 * u.texels.xy; + vec3 sum = texture(source, uv + vec2(-2.0 * h.x, 0.0)).rgb; + sum += texture(source, uv + vec2(-h.x, h.y)).rgb * 2.0; + sum += texture(source, uv + vec2(0.0, 2.0 * h.y)).rgb; + sum += texture(source, uv + vec2(h.x, h.y)).rgb * 2.0; + sum += texture(source, uv + vec2(2.0 * h.x, 0.0)).rgb; + sum += texture(source, uv + vec2(h.x, -h.y)).rgb * 2.0; + sum += texture(source, uv + vec2(0.0, -2.0 * h.y)).rgb; + sum += texture(source, uv + vec2(-h.x, -h.y)).rgb * 2.0; + o_col = vec4(sum / 12.0, 1.0); +} diff --git a/shaders/post/bloom.glsl b/shaders/post/bloom.glsl new file mode 100644 index 00000000..d54a0aac --- /dev/null +++ b/shaders/post/bloom.glsl @@ -0,0 +1,40 @@ +// Bloom: the brightest ink glows, a little. The glow itself is made by the +// shell at half resolution and below (bloom-down, bloom-up: a dual Kawase +// filter over a bright pass, in linear light) and arrives here in +// pardesBloom; this pass adds it to the frame. Only true highlights pass +// the threshold, and the strength stays in §8.2's budget (at most 5%) and +// under pardesCap, so no text or selection loses contrast below its own or +// 4.5. Tags, grips, notices and the cursor are left alone (pardesSpare). +// It is a Shadertoy file: pardes puts its prefix before it. + +layout(set = 2, binding = 1) uniform sampler2D pardesBloom; + +float strength() { return min(pardesByLevel(0.02, 0.035, 0.05), pardesCap); } + +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 uv = fragCoord / iResolution.xy; + vec4 frame = texture(iChannel0, uv); + if (pardesSpared(fragCoord)) { + fragColor = frame; + return; + } + vec3 glow = texture(pardesBloom, uv).rgb; + vec3 col = toLinear(frame.rgb) + glow * strength(); + vec3 srgb = toSrgb(col); + // Dithered: a glow's long falloff never bands. + srgb += (ign(fragCoord) - 0.5) / 255.0 * step(1e-4, dot(glow, vec3(1.0))); + fragColor = vec4(clamp(srgb, 0.0, 1.0), frame.a); +} diff --git a/shaders/post/grain.glsl b/shaders/post/grain.glsl new file mode 100644 index 00000000..61c51492 --- /dev/null +++ b/shaders/post/grain.glsl @@ -0,0 +1,32 @@ +// Grain: the page's own ground takes a fine, still grain, like paper. Only +// pixels of the page's colour (iBackgroundColor) are grained: no glyph, no +// selection, no tag or rule, and nothing in pardesSpare. The grain averages +// to the page, is a logical pixel big (pardesScale), and never moves, so it +// costs no frames. At most 1.5, 3 and 5 of 255 at levels 1 to 3, and never +// more than pardesCap, which keeps the text on it at its own contrast or 4.5. +// It is a Shadertoy file: pardes puts its prefix before it. + +float amplitude() { return min(pardesByLevel(1.5, 3.0, 5.0) / 255.0, pardesCap); } + +// An integer hash of the grain cell: white, with no pattern to see. +float hash(uvec2 p) { + uint h = p.x * 1664525u + p.y * 1013904223u; + h ^= h >> 16; + h *= 2246822519u; + h ^= h >> 13; + h *= 3266489917u; + h ^= h >> 16; + return float(h) / 4294967295.0; +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; + vec4 frame = texture(iChannel0, uv); + fragColor = frame; + vec3 page = iBackgroundColor.rgb; + if (any(greaterThan(abs(frame.rgb - page), vec3(0.75 / 255.0))) || pardesSpared(fragCoord)) return; + uvec2 cell = uvec2(fragCoord / max(1.0, pardesScale)); + // Two hashes summed: a triangular spread, softer than a flat one. + float n = hash(cell) + hash(cell + uvec2(7919u, 104729u)) - 1.0; + fragColor = vec4(clamp(page + n * amplitude(), 0.0, 1.0), frame.a); +} diff --git a/shaders/post/prefix.glsl b/shaders/post/prefix.glsl index 7a50936e..44c5e0f8 100644 --- a/shaders/post/prefix.glsl +++ b/shaders/post/prefix.glsl @@ -30,7 +30,14 @@ layout(set = 3, binding = 0, std140) uniform Globals { uniform float iTimeCursorChange; uniform float iTimeFocus; uniform int iFocus; +}; +// ghostty's one block, cut in three: SDL GPU binds at most 4 KiB of a +// uniform block (Vulkan), and the palette alone is 4 KiB. The names and +// their order are ghostty's; each block is a slice of the same memory. +layout(set = 3, binding = 1, std140) uniform Palette { uniform vec3 iPalette[256]; +}; +layout(set = 3, binding = 2, std140) uniform Colors { uniform vec3 iBackgroundColor; uniform vec3 iForegroundColor; uniform vec3 iCursorColor; @@ -44,13 +51,29 @@ layout(set = 3, binding = 0, std140) uniform Globals { // pass's strength, 1 to 3 (`Crt 3`), and 0 for a user's file; pardesScale // is the display's density (2 on a 2x screen), for a pass whose pattern is // in logical pixels. iResolution, fragCoord and iMouse stay in the display's -// pixels, as ghostty's do. -layout(set = 3, binding = 1, std140) uniform PardesPost { +// pixels, as ghostty's do. pardesCap is the most a bundled pass may do on +// this theme and still leave its text, and its selection's, at their own +// contrast or 4.5 (docs/effects.md); pardesSpare holds pardesSpareCount +// rectangles (x0, y0, x1, y1 in fragCoord's pixels) a bundled pass leaves +// alone: every tag, grip and notice, and the cursor, so no pass ever touches +// a focus indicator. +layout(set = 3, binding = 3, std140) uniform PardesPost { float pardesOpaque; float pardesLevel; float pardesScale; + float pardesCap; + float pardesSpareCount; + vec4 pardesSpare[32]; }; +bool pardesSpared(vec2 p) { + for (int i = 0; i < int(pardesSpareCount); i++) { + vec4 r = pardesSpare[i]; + if (p.x >= r.x && p.y >= r.y && p.x < r.z && p.y < r.w) return true; + } + return false; +} + // 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); diff --git a/shaders/post/vignette.glsl b/shaders/post/vignette.glsl new file mode 100644 index 00000000..30dc2e0f --- /dev/null +++ b/shaders/post/vignette.glsl @@ -0,0 +1,38 @@ +// Vignette: the window's corners fall a little into shade, in linear light, +// so the eye settles on the middle. Round, whatever the window's shape; +// nothing within half the way to a corner is touched. At most 28% at the +// far corners (level 3), and never more than pardesCap, so text and its +// selection keep their own contrast or 4.5. Tags, grips, notices and the +// cursor are left alone (pardesSpare): the focus indicators never darken. +// It is a Shadertoy file: pardes puts its prefix before it. + +float strength() { return min(pardesByLevel(0.10, 0.18, 0.28), pardesCap); } + +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 uv = fragCoord / iResolution.xy; + vec4 frame = texture(iChannel0, uv); + if (pardesSpared(fragCoord)) { + fragColor = frame; + return; + } + // 0 at the middle, 1 at a corner, on a circle's measure. + vec2 half_size = 0.5 * iResolution.xy; + float r = length(fragCoord - half_size) / length(half_size); + float shade = strength() * pow(smoothstep(0.5, 1.0, r), 1.5); + vec3 srgb = toSrgb(toLinear(frame.rgb) * (1.0 - shade)); + srgb += (ign(fragCoord) - 0.5) / 255.0 * step(1e-4, shade); + fragColor = vec4(clamp(srgb, 0.0, 1.0), frame.a); +} diff --git a/shaders/prebuilt/bloom-down.frag.glsl b/shaders/prebuilt/bloom-down.frag.glsl new file mode 100644 index 00000000..3465926d --- /dev/null +++ b/shaders/prebuilt/bloom-down.frag.glsl @@ -0,0 +1,39 @@ +#version 450 + +// Bloom, down (docs/effects.md G4): one step of a dual Kawase filter, a +// source half as big again sampled at five points, the first step over the +// frame through a bright pass in linear light. Written to a float target. + +layout(set = 2, binding = 0) uniform sampler2D source; +layout(std140, set = 3, binding = 0) uniform BloomStep { + // xy: a source texel; zw: a target texel. + vec4 texels; + // x: the bright pass's threshold on a channel's linear peak (above the + // page's own), 0 for none (a later step); y: its soft knee. + vec4 pass; +} u; + +layout(location = 0) out vec4 o_col; + +vec3 toLinear(vec3 c) { + return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); +} + +vec3 tap(vec2 uv) { + vec3 c = texture(source, uv).rgb; + if (u.pass.x <= 0.0) return c; + c = toLinear(c); + float peak = max(c.r, max(c.g, c.b)); + return c * smoothstep(u.pass.x, u.pass.x + u.pass.y, peak); +} + +void main() { + vec2 uv = gl_FragCoord.xy * u.texels.zw; + vec2 h = 0.5 * u.texels.xy; + vec3 sum = tap(uv) * 4.0; + sum += tap(uv - h); + sum += tap(uv + h); + sum += tap(uv + vec2(h.x, -h.y)); + sum += tap(uv - vec2(h.x, -h.y)); + o_col = vec4(sum / 8.0, 1.0); +} diff --git a/shaders/prebuilt/bloom-down.frag.spv b/shaders/prebuilt/bloom-down.frag.spv new file mode 100644 index 00000000..5ca327fe Binary files /dev/null and b/shaders/prebuilt/bloom-down.frag.spv differ diff --git a/shaders/prebuilt/bloom-up.frag.glsl b/shaders/prebuilt/bloom-up.frag.glsl new file mode 100644 index 00000000..368076ed --- /dev/null +++ b/shaders/prebuilt/bloom-up.frag.glsl @@ -0,0 +1,26 @@ +#version 450 + +// Bloom, up (docs/effects.md G4): one step of a dual Kawase filter back up, +// a tent of eight points over the smaller level. Float target. + +layout(set = 2, binding = 0) uniform sampler2D source; +layout(std140, set = 3, binding = 0) uniform BloomStep { + vec4 texels; + vec4 pass; +} u; + +layout(location = 0) out vec4 o_col; + +void main() { + vec2 uv = gl_FragCoord.xy * u.texels.zw; + vec2 h = 0.5 * u.texels.xy; + vec3 sum = texture(source, uv + vec2(-2.0 * h.x, 0.0)).rgb; + sum += texture(source, uv + vec2(-h.x, h.y)).rgb * 2.0; + sum += texture(source, uv + vec2(0.0, 2.0 * h.y)).rgb; + sum += texture(source, uv + vec2(h.x, h.y)).rgb * 2.0; + sum += texture(source, uv + vec2(2.0 * h.x, 0.0)).rgb; + sum += texture(source, uv + vec2(h.x, -h.y)).rgb * 2.0; + sum += texture(source, uv + vec2(0.0, -2.0 * h.y)).rgb; + sum += texture(source, uv + vec2(-h.x, -h.y)).rgb * 2.0; + o_col = vec4(sum / 12.0, 1.0); +} diff --git a/shaders/prebuilt/bloom-up.frag.spv b/shaders/prebuilt/bloom-up.frag.spv new file mode 100644 index 00000000..b2c64b80 Binary files /dev/null and b/shaders/prebuilt/bloom-up.frag.spv differ diff --git a/shaders/prebuilt/post-bloom.frag.glsl b/shaders/prebuilt/post-bloom.frag.glsl new file mode 100644 index 00000000..a84cf88a --- /dev/null +++ b/shaders/prebuilt/post-bloom.frag.glsl @@ -0,0 +1,141 @@ +#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; +}; +// ghostty's one block, cut in three: SDL GPU binds at most 4 KiB of a +// uniform block (Vulkan), and the palette alone is 4 KiB. The names and +// their order are ghostty's; each block is a slice of the same memory. +layout(set = 3, binding = 1, std140) uniform Palette { + uniform vec3 iPalette[256]; +}; +layout(set = 3, binding = 2, std140) uniform Colors { + 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; pardesScale +// is the display's density (2 on a 2x screen), for a pass whose pattern is +// in logical pixels. iResolution, fragCoord and iMouse stay in the display's +// pixels, as ghostty's do. pardesCap is the most a bundled pass may do on +// this theme and still leave its text, and its selection's, at their own +// contrast or 4.5 (docs/effects.md); pardesSpare holds pardesSpareCount +// rectangles (x0, y0, x1, y1 in fragCoord's pixels) a bundled pass leaves +// alone: every tag, grip and notice, and the cursor, so no pass ever touches +// a focus indicator. +layout(set = 3, binding = 3, std140) uniform PardesPost { + float pardesOpaque; + float pardesLevel; + float pardesScale; + float pardesCap; + float pardesSpareCount; + vec4 pardesSpare[32]; +}; + +bool pardesSpared(vec2 p) { + for (int i = 0; i < int(pardesSpareCount); i++) { + vec4 r = pardesSpare[i]; + if (p.x >= r.x && p.y >= r.y && p.x < r.z && p.y < r.w) return true; + } + return false; +} + +// 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 +// Bloom: the brightest ink glows, a little. The glow itself is made by the +// shell at half resolution and below (bloom-down, bloom-up: a dual Kawase +// filter over a bright pass, in linear light) and arrives here in +// pardesBloom; this pass adds it to the frame. Only true highlights pass +// the threshold, and the strength stays in §8.2's budget (at most 5%) and +// under pardesCap, so no text or selection loses contrast below its own or +// 4.5. Tags, grips, notices and the cursor are left alone (pardesSpare). +// It is a Shadertoy file: pardes puts its prefix before it. + +layout(set = 2, binding = 1) uniform sampler2D pardesBloom; + +float strength() { return min(pardesByLevel(0.02, 0.035, 0.05), pardesCap); } + +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 uv = fragCoord / iResolution.xy; + vec4 frame = texture(iChannel0, uv); + if (pardesSpared(fragCoord)) { + fragColor = frame; + return; + } + vec3 glow = texture(pardesBloom, uv).rgb; + vec3 col = toLinear(frame.rgb) + glow * strength(); + vec3 srgb = toSrgb(col); + // Dithered: a glow's long falloff never bands. + srgb += (ign(fragCoord) - 0.5) / 255.0 * step(1e-4, dot(glow, vec3(1.0))); + fragColor = vec4(clamp(srgb, 0.0, 1.0), frame.a); +} diff --git a/shaders/prebuilt/post-bloom.frag.spv b/shaders/prebuilt/post-bloom.frag.spv new file mode 100644 index 00000000..7b71dbdd Binary files /dev/null and b/shaders/prebuilt/post-bloom.frag.spv differ diff --git a/shaders/prebuilt/post-crt.frag.glsl b/shaders/prebuilt/post-crt.frag.glsl index 7d2266c0..ca44d014 100644 --- a/shaders/prebuilt/post-crt.frag.glsl +++ b/shaders/prebuilt/post-crt.frag.glsl @@ -30,7 +30,14 @@ layout(set = 3, binding = 0, std140) uniform Globals { uniform float iTimeCursorChange; uniform float iTimeFocus; uniform int iFocus; +}; +// ghostty's one block, cut in three: SDL GPU binds at most 4 KiB of a +// uniform block (Vulkan), and the palette alone is 4 KiB. The names and +// their order are ghostty's; each block is a slice of the same memory. +layout(set = 3, binding = 1, std140) uniform Palette { uniform vec3 iPalette[256]; +}; +layout(set = 3, binding = 2, std140) uniform Colors { uniform vec3 iBackgroundColor; uniform vec3 iForegroundColor; uniform vec3 iCursorColor; @@ -44,13 +51,29 @@ layout(set = 3, binding = 0, std140) uniform Globals { // pass's strength, 1 to 3 (`Crt 3`), and 0 for a user's file; pardesScale // is the display's density (2 on a 2x screen), for a pass whose pattern is // in logical pixels. iResolution, fragCoord and iMouse stay in the display's -// pixels, as ghostty's do. -layout(set = 3, binding = 1, std140) uniform PardesPost { +// pixels, as ghostty's do. pardesCap is the most a bundled pass may do on +// this theme and still leave its text, and its selection's, at their own +// contrast or 4.5 (docs/effects.md); pardesSpare holds pardesSpareCount +// rectangles (x0, y0, x1, y1 in fragCoord's pixels) a bundled pass leaves +// alone: every tag, grip and notice, and the cursor, so no pass ever touches +// a focus indicator. +layout(set = 3, binding = 3, std140) uniform PardesPost { float pardesOpaque; float pardesLevel; float pardesScale; + float pardesCap; + float pardesSpareCount; + vec4 pardesSpare[32]; }; +bool pardesSpared(vec2 p) { + for (int i = 0; i < int(pardesSpareCount); i++) { + vec4 r = pardesSpare[i]; + if (p.x >= r.x && p.y >= r.y && p.x < r.z && p.y < r.w) return true; + } + return false; +} + // 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); diff --git a/shaders/prebuilt/post-crt.frag.spv b/shaders/prebuilt/post-crt.frag.spv index d6cc6c37..fe2c3edb 100644 Binary files a/shaders/prebuilt/post-crt.frag.spv and b/shaders/prebuilt/post-crt.frag.spv differ diff --git a/shaders/prebuilt/post-grain.frag.glsl b/shaders/prebuilt/post-grain.frag.glsl new file mode 100644 index 00000000..a4a02e99 --- /dev/null +++ b/shaders/prebuilt/post-grain.frag.glsl @@ -0,0 +1,133 @@ +#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; +}; +// ghostty's one block, cut in three: SDL GPU binds at most 4 KiB of a +// uniform block (Vulkan), and the palette alone is 4 KiB. The names and +// their order are ghostty's; each block is a slice of the same memory. +layout(set = 3, binding = 1, std140) uniform Palette { + uniform vec3 iPalette[256]; +}; +layout(set = 3, binding = 2, std140) uniform Colors { + 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; pardesScale +// is the display's density (2 on a 2x screen), for a pass whose pattern is +// in logical pixels. iResolution, fragCoord and iMouse stay in the display's +// pixels, as ghostty's do. pardesCap is the most a bundled pass may do on +// this theme and still leave its text, and its selection's, at their own +// contrast or 4.5 (docs/effects.md); pardesSpare holds pardesSpareCount +// rectangles (x0, y0, x1, y1 in fragCoord's pixels) a bundled pass leaves +// alone: every tag, grip and notice, and the cursor, so no pass ever touches +// a focus indicator. +layout(set = 3, binding = 3, std140) uniform PardesPost { + float pardesOpaque; + float pardesLevel; + float pardesScale; + float pardesCap; + float pardesSpareCount; + vec4 pardesSpare[32]; +}; + +bool pardesSpared(vec2 p) { + for (int i = 0; i < int(pardesSpareCount); i++) { + vec4 r = pardesSpare[i]; + if (p.x >= r.x && p.y >= r.y && p.x < r.z && p.y < r.w) return true; + } + return false; +} + +// 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 +// Grain: the page's own ground takes a fine, still grain, like paper. Only +// pixels of the page's colour (iBackgroundColor) are grained: no glyph, no +// selection, no tag or rule, and nothing in pardesSpare. The grain averages +// to the page, is a logical pixel big (pardesScale), and never moves, so it +// costs no frames. At most 1.5, 3 and 5 of 255 at levels 1 to 3, and never +// more than pardesCap, which keeps the text on it at its own contrast or 4.5. +// It is a Shadertoy file: pardes puts its prefix before it. + +float amplitude() { return min(pardesByLevel(1.5, 3.0, 5.0) / 255.0, pardesCap); } + +// An integer hash of the grain cell: white, with no pattern to see. +float hash(uvec2 p) { + uint h = p.x * 1664525u + p.y * 1013904223u; + h ^= h >> 16; + h *= 2246822519u; + h ^= h >> 13; + h *= 3266489917u; + h ^= h >> 16; + return float(h) / 4294967295.0; +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; + vec4 frame = texture(iChannel0, uv); + fragColor = frame; + vec3 page = iBackgroundColor.rgb; + if (any(greaterThan(abs(frame.rgb - page), vec3(0.75 / 255.0))) || pardesSpared(fragCoord)) return; + uvec2 cell = uvec2(fragCoord / max(1.0, pardesScale)); + // Two hashes summed: a triangular spread, softer than a flat one. + float n = hash(cell) + hash(cell + uvec2(7919u, 104729u)) - 1.0; + fragColor = vec4(clamp(page + n * amplitude(), 0.0, 1.0), frame.a); +} diff --git a/shaders/prebuilt/post-grain.frag.spv b/shaders/prebuilt/post-grain.frag.spv new file mode 100644 index 00000000..e4c893af Binary files /dev/null and b/shaders/prebuilt/post-grain.frag.spv differ diff --git a/shaders/prebuilt/post-vignette.frag.glsl b/shaders/prebuilt/post-vignette.frag.glsl new file mode 100644 index 00000000..d5737901 --- /dev/null +++ b/shaders/prebuilt/post-vignette.frag.glsl @@ -0,0 +1,139 @@ +#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; +}; +// ghostty's one block, cut in three: SDL GPU binds at most 4 KiB of a +// uniform block (Vulkan), and the palette alone is 4 KiB. The names and +// their order are ghostty's; each block is a slice of the same memory. +layout(set = 3, binding = 1, std140) uniform Palette { + uniform vec3 iPalette[256]; +}; +layout(set = 3, binding = 2, std140) uniform Colors { + 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; pardesScale +// is the display's density (2 on a 2x screen), for a pass whose pattern is +// in logical pixels. iResolution, fragCoord and iMouse stay in the display's +// pixels, as ghostty's do. pardesCap is the most a bundled pass may do on +// this theme and still leave its text, and its selection's, at their own +// contrast or 4.5 (docs/effects.md); pardesSpare holds pardesSpareCount +// rectangles (x0, y0, x1, y1 in fragCoord's pixels) a bundled pass leaves +// alone: every tag, grip and notice, and the cursor, so no pass ever touches +// a focus indicator. +layout(set = 3, binding = 3, std140) uniform PardesPost { + float pardesOpaque; + float pardesLevel; + float pardesScale; + float pardesCap; + float pardesSpareCount; + vec4 pardesSpare[32]; +}; + +bool pardesSpared(vec2 p) { + for (int i = 0; i < int(pardesSpareCount); i++) { + vec4 r = pardesSpare[i]; + if (p.x >= r.x && p.y >= r.y && p.x < r.z && p.y < r.w) return true; + } + return false; +} + +// 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 +// Vignette: the window's corners fall a little into shade, in linear light, +// so the eye settles on the middle. Round, whatever the window's shape; +// nothing within half the way to a corner is touched. At most 28% at the +// far corners (level 3), and never more than pardesCap, so text and its +// selection keep their own contrast or 4.5. Tags, grips, notices and the +// cursor are left alone (pardesSpare): the focus indicators never darken. +// It is a Shadertoy file: pardes puts its prefix before it. + +float strength() { return min(pardesByLevel(0.10, 0.18, 0.28), pardesCap); } + +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 uv = fragCoord / iResolution.xy; + vec4 frame = texture(iChannel0, uv); + if (pardesSpared(fragCoord)) { + fragColor = frame; + return; + } + // 0 at the middle, 1 at a corner, on a circle's measure. + vec2 half_size = 0.5 * iResolution.xy; + float r = length(fragCoord - half_size) / length(half_size); + float shade = strength() * pow(smoothstep(0.5, 1.0, r), 1.5); + vec3 srgb = toSrgb(toLinear(frame.rgb) * (1.0 - shade)); + srgb += (ign(fragCoord) - 0.5) / 255.0 * step(1e-4, shade); + fragColor = vec4(clamp(srgb, 0.0, 1.0), frame.a); +} diff --git a/shaders/prebuilt/post-vignette.frag.spv b/shaders/prebuilt/post-vignette.frag.spv new file mode 100644 index 00000000..4dfb6125 Binary files /dev/null and b/shaders/prebuilt/post-vignette.frag.spv differ diff --git a/src/config.zig b/src/config.zig index fb38e4dd..43d2cfda 100644 --- a/src/config.zig +++ b/src/config.zig @@ -26,6 +26,7 @@ const undecided: []const u8 = ""; // null means word/chord-only. Every enabled builtin must explicitly choose a path. pub const leader_path = paths: { + @setEvalBranchQuota(2000); var table = std.EnumArray(Builtin, ?[]const u8).initDefault(@as(?[]const u8, undecided), .{ .Help = &[_]u8{leader_help}, .Hover = "lk", @@ -139,6 +140,9 @@ pub const leader_path = paths: { } if (builtins.capabilities.scene_shaders) { table.set(.Crt, "tr"); + table.set(.Bloom, null); + table.set(.Vignette, null); + table.set(.Grain, null); table.set(.Shader, null); table.set(.ShaderAnimation, null); } @@ -869,8 +873,9 @@ 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, syntax_bold, verbose, message_animation, ligatures, cursor_blink }; - /// The bundled post pass, a Shadertoy file in shaders/post/. - pub const Scene = enum { crt }; + /// A bundled post pass, a Shadertoy file in shaders/post/: the CRT, and + /// G4's bloom, vignette and grain (docs/effects.md). + pub const Scene = enum { crt, bloom, vignette, grain }; pub const Action = union(enum) { toggle: Toggle, @@ -950,6 +955,9 @@ 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 = "Bloom", .action = .{ .scene = .bloom }, .availability = .scene_shaders }, + .{ .word = "Vignette", .action = .{ .scene = .vignette }, .availability = .scene_shaders }, + .{ .word = "Grain", .action = .{ .scene = .grain }, .availability = .scene_shaders }, .{ .word = "Shader", .action = .shader, .availability = .scene_shaders }, .{ .word = "ShaderAnimation", .action = .shader_animation, .availability = .scene_shaders }, .{ .word = "Placement", .action = .{ .choice = .placement } }, @@ -1678,6 +1686,9 @@ pub const Runtime = struct { }, .scene => |effect| switch (effect) { .crt => "Crt 2\n", + .bloom => "Bloom 2\n", + .vignette => "Vignette 2\n", + .grain => "Grain 2\n", }, .shader => "Shader ~/crt.glsl\n", .shader_animation => "ShaderAnimation always\n", diff --git a/src/effect_sources.zig b/src/effect_sources.zig index 83bd1313..328ce730 100644 --- a/src/effect_sources.zig +++ b/src/effect_sources.zig @@ -14,6 +14,11 @@ const image_fragment_path = shader_dir ++ "image.frag.glsl"; const post_vertex_path = shader_dir ++ "post.vert.glsl"; // The bundled post pass as compiled: the prefix and the pass together. const post_crt_path = shader_dir ++ "post-crt.frag.glsl"; +const post_bloom_path = shader_dir ++ "post-bloom.frag.glsl"; +const post_vignette_path = shader_dir ++ "post-vignette.frag.glsl"; +const post_grain_path = shader_dir ++ "post-grain.frag.glsl"; +const bloom_down_path = shader_dir ++ "bloom-down.frag.glsl"; +const bloom_up_path = shader_dir ++ "bloom-up.frag.glsl"; pub const Backend = @TypeOf(build_config.platform); pub const backend: Backend = build_config.platform; @@ -53,6 +58,11 @@ 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_bloom_path, .contents = @embedFile("effect-source-post-bloom.frag.glsl") }, + .{ .path = post_vignette_path, .contents = @embedFile("effect-source-post-vignette.frag.glsl") }, + .{ .path = post_grain_path, .contents = @embedFile("effect-source-post-grain.frag.glsl") }, + .{ .path = bloom_down_path, .contents = @embedFile("effect-source-bloom-down.frag.glsl") }, + .{ .path = bloom_up_path, .contents = @embedFile("effect-source-bloom-up.frag.glsl") }, }, .macos => [_]filesystem.Source{ .{ .path = "src/macos/Sources/PardesView.swift", .contents = @embedFile("macos/Sources/PardesView.swift") }, @@ -73,6 +83,9 @@ 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 }, + .bloom => &.{ "src/gui/Post.zig", "src/gui/gui.zig", post_vertex_path, bloom_down_path, bloom_up_path, post_bloom_path }, + .vignette => &.{ "src/gui/Post.zig", "src/gui/gui.zig", post_vertex_path, post_vignette_path }, + .grain => &.{ "src/gui/Post.zig", "src/gui/gui.zig", post_vertex_path, post_grain_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 45798f8d..61ebc2cd 100644 --- a/src/gui/Post.zig +++ b/src/gui/Post.zig @@ -28,7 +28,27 @@ 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"), + .bloom = @embedFile("post-bloom.frag.spv"), + .vignette = @embedFile("post-vignette.frag.spv"), + .grain = @embedFile("post-grain.frag.spv"), }); +const bloom_down_spv = @embedFile("bloom-down.frag.spv"); +const bloom_up_spv = @embedFile("bloom-up.frag.spv"); +/// Bloom's steps below the frame, at levels 1 to 3: a half, a quarter, ... +const bloom_steps = [_]u8{ 3, 4, 5 }; +const bloom_format = c.SDL_GPU_TEXTUREFORMAT_R16G16B16A16_FLOAT; + +/// pardes's own block (shaders/post/prefix.glsl PardesPost), std140. +pub const Own = extern struct { + opaque_window: f32 = 1, + level: f32 = 0, + scale: f32 = 1, + cap: f32 = 0, + spare_count: f32 = 0, + _pad: [3]f32 = @splat(0), + spare: [max_spare][4]f32 = @splat(@splat(0)), +}; +pub const max_spare = 32; /// 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 @@ -101,6 +121,18 @@ uniforms: Uniforms = .{}, /// The display's density, for a pass whose pattern is in logical pixels /// (pardesScale); the shell sets it. scale: f32 = 1, +/// What a bundled pass may do on this theme (pardesCap), each scene's, and +/// the rectangles every bundled pass leaves alone (pardesSpare): both from +/// the last frame described. +caps: std.EnumArray(Scene, f32) = .initFill(0), +spare: [max_spare][4]f32 = @splat(@splat(0)), +spare_len: usize = 0, +/// Bloom's float chain, a half the frame and down, and its two steps. +bloom_tex: [bloom_steps[bloom_steps.len - 1]]?*c.SDL_GPUTexture = @splat(null), +bloom_w: u32 = 0, +bloom_h: u32 = 0, +bloom_down: ?*Pipeline = null, +bloom_up: ?*Pipeline = null, started_ns: ?u64 = null, last_ns: u64 = 0, @@ -119,7 +151,11 @@ pub fn sync(post: *Post, gpa: std.mem.Allocator, io: std.Io, device: *c.SDL_GPUD 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); + if (post.bundled.get(scene) == null) post.bundled.set(scene, makePipeline(device, format, bundled_spv.get(scene), if (scene == .bloom) 2 else 1, 4) catch null); + if (scene == .bloom and post.bloom_down == null) { + post.bloom_down = makePipeline(device, bloom_format, bloom_down_spv, 1, 1) catch null; + post.bloom_up = makePipeline(device, bloom_format, bloom_up_spv, 1, 1) catch null; + } next[i] = .{ .scene = scene, .level = entry.level, .pipeline = post.bundled.get(scene) }; continue; } @@ -182,7 +218,7 @@ fn collect(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice, format .spirv => |spirv| { defer gpa.free(spirv); const target = pass orelse continue; - const made = makePipeline(device, format, spirv) catch { + const made = makePipeline(device, format, spirv, 1, 4) catch { core.setMessage(core.active, std.fmt.bufPrint(&buf, "Shader {s}: the GPU refused it", .{path}) catch "Shader: the GPU refused it"); continue; }; @@ -266,10 +302,10 @@ fn compileOne(gpa: std.mem.Allocator, io: std.Io, path: []const u8) Job.Result { return .{ .failed = named }; } -fn makePipeline(device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, frag: []const u8) !*Pipeline { +fn makePipeline(device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, frag: []const u8, samplers: u32, uniform_buffers: u32) !*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); + const fs = try gui.makeShader(device, frag, c.SDL_GPU_SHADERSTAGE_FRAGMENT, samplers, uniform_buffers); 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); @@ -303,6 +339,9 @@ pub fn animating(post: *const Post, mode: pardes.config.Runtime.ShaderAnimation) 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, + /// What a bundled pass leaves alone, in framebuffer pixels: the tags, + /// grips and notices (and the cursor, added here). + spare: []const [4]f32 = &.{}, background: [3]u8, foreground: [3]u8, selection_foreground: [3]u8, @@ -318,6 +357,10 @@ fn rgba(rgb: [3]u8) [4]f32 { /// colour makes the old one the previous and stamps the change. pub fn describe(post: *Post, frame: Frame) void { const u = &post.uniforms; + post.caps = capsOf(frame); + post.spare_len = 0; + for (frame.spare) |rect| post.addSpare(rect); + if (frame.cursor) |cursor| post.addSpare(.{ cursor.x0, cursor.y0, cursor.x1, cursor.y1 }); u.background_color = rgba(frame.background); u.foreground_color = rgba(frame.foreground); u.selection_foreground_color = rgba(frame.selection_foreground); @@ -343,6 +386,102 @@ pub fn describe(post: *Post, frame: Frame) void { u.cursor_change_time = u.time; } +/// A rectangle a bundled pass leaves alone; past `max_spare` the last one +/// grows to cover the rest (sparing more, never less). +fn addSpare(post: *Post, rect: [4]f32) void { + if (rect[2] <= rect[0] or rect[3] <= rect[1]) return; + if (post.spare_len < max_spare) { + post.spare[post.spare_len] = rect; + post.spare_len += 1; + return; + } + const last = &post.spare[max_spare - 1]; + last.* = .{ @min(last[0], rect[0]), @min(last[1], rect[1]), @max(last[2], rect[2]), @max(last[3], rect[3]) }; +} + +/// sRGB's relative luminance (WCAG) and a pair's contrast. +fn luminance(rgb: [3]f32) f32 { + var sum: f32 = 0; + for (rgb, [3]f32{ 0.2126, 0.7152, 0.0722 }) |v, weight| + sum += weight * (if (v <= 0.04045) v / 12.92 else std.math.pow(f32, (v + 0.055) / 1.055, 2.4)); + return sum; +} +fn contrastOf(a: f32, b: f32) f32 { + return (@max(a, b) + 0.05) / (@min(a, b) + 0.05); +} +fn unit(rgb: [3]u8) [3]f32 { + return .{ @as(f32, @floatFromInt(rgb[0])) / 255, @as(f32, @floatFromInt(rgb[1])) / 255, @as(f32, @floatFromInt(rgb[2])) / 255 }; +} + +/// Each bundled pass's ceiling on this theme (pardesCap, docs/effects.md): +/// the most it may do while the text on the page, and the selection's text +/// on its own ground, keep min(their contrast, 4.5). Bloom adds light to +/// both of a pair (linear, as a share of full white); a vignette darkens +/// both (a share); grain moves the page alone, either way (sRGB units). +pub fn capsOf(frame: Frame) std.EnumArray(Scene, f32) { + const pairs = [_][2][3]f32{ .{ unit(frame.foreground), unit(frame.background) }, .{ unit(frame.selection_foreground), unit(frame.selection_background) } }; + var bloom: f32 = 1; + var shade: f32 = 1; + for (pairs) |pair| { + const a = luminance(pair[0]); + const b = luminance(pair[1]); + const light = @max(a, b); + const dark = @min(a, b); + const target = @min(contrastOf(a, b), 4.5); + // (light + e + .05) / (dark + e + .05) >= target + bloom = @min(bloom, @max(0, (light + 0.05 - target * (dark + 0.05)) / @max(target - 1, 1e-3))); + // (k light + .05) / (k dark + .05) >= target, darkened by 1 - k + const room = light - target * dark; + shade = @min(shade, if (room <= 0) 0 else std.math.clamp(1 - 0.05 * (target - 1) / room, 0, 1)); + } + // Grain: the largest step off the page, up and down, that keeps the text + // on it at its contrast or 4.5 (searched in 1/1020 steps up to 8/255). + const ink = luminance(unit(frame.foreground)); + const page = unit(frame.background); + const target = @min(contrastOf(ink, luminance(page)), 4.5); + var grain: f32 = 0; + var step: f32 = 0.25 / 255.0; + while (step <= 8.0 / 255.0) : (step += 0.25 / 255.0) { + var up = page; + var down = page; + for (&up, &down) |*u, *d| { + u.* = @min(1, u.* + step); + d.* = @max(0, d.* - step); + } + if (contrastOf(ink, luminance(up)) < target - 1e-4 or contrastOf(ink, luminance(down)) < target - 1e-4) break; + grain = step; + } + var caps: std.EnumArray(Scene, f32) = .initFill(1); + caps.set(.bloom, bloom); + caps.set(.vignette, shade); + caps.set(.grain, grain); + return caps; +} + +test "no bundled pass at its ceiling takes a native theme's text or selection below its contrast or 4.5" { + for (pardes.themes[0..pardes.colors.native_count]) |*theme| { + const frame: Frame = .{ .cursor = null, .background = theme.bg orelse .{ 18, 18, 18 }, .foreground = theme.fg orelse .{ 204, 204, 204 }, .selection_foreground = theme.sel_fg, .selection_background = theme.sel_bg }; + const caps = capsOf(frame); + for ([_][2][3]u8{ .{ frame.foreground, frame.background }, .{ frame.selection_foreground, frame.selection_background } }, 0..) |pair, i| { + const a = luminance(unit(pair[0])); + const b = luminance(unit(pair[1])); + const target = @min(contrastOf(a, b), 4.5) - 1e-3; + // Bloom at its ceiling, over both. + const e = @min(caps.get(.bloom), 0.05); + try std.testing.expect(contrastOf(a + e, b + e) >= target); + // The vignette at its ceiling, over both. + const k = 1 - @min(caps.get(.vignette), 0.28); + try std.testing.expect(contrastOf(a * k, b * k) >= target); + // Grain moves the page alone. + if (i == 0) { + var up = unit(pair[1]); + for (&up) |*v| v.* = @min(1, v.* + caps.get(.grain)); + try std.testing.expect(contrastOf(a, luminance(up)) >= target); + } + } + } +} + /// 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; @@ -420,13 +559,25 @@ pub fn draw(post: *Post, device: *c.SDL_GPUDevice, cmd: *c.SDL_GPUCommandBuffer, 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; + // Bloom's glow first, from this pass's input, at half size and down. + // A chain that could not be made adds no glow, and the pass is the + // frame as it was. + const glow = if (pass.scene == .bloom) post.bloomChain(device, cmd, sampler, input, w, h, pass.level) catch null else null; 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)); - const own: [4]f32 = .{ @floatFromInt(@intFromBool(opaque_window)), @floatFromInt(pass.level), post.scale, 0 }; - c.SDL_PushGPUFragmentUniformData(cmd, 1, &own, @sizeOf(@TypeOf(own))); + const bindings = [2]c.SDL_GPUTextureSamplerBinding{ .{ .texture = input, .sampler = sampler }, .{ .texture = glow orelse input, .sampler = sampler } }; + c.SDL_BindGPUFragmentSamplers(pass_rp, 0, &bindings, if (pass.scene == .bloom) 2 else 1); + pushUniforms(cmd, u); + var own: Own = .{ + .opaque_window = @floatFromInt(@intFromBool(opaque_window)), + .level = @floatFromInt(pass.level), + .scale = post.scale, + // A user's file has no ceiling of ours, and no spared rectangles. + .cap = if (pass.scene) |scene| (if (scene == .bloom and glow == null) 0 else post.caps.get(scene)) else 1, + .spare_count = if (pass.scene != null) @floatFromInt(post.spare_len) else 0, + }; + if (pass.scene != null) @memcpy(own.spare[0..post.spare_len], post.spare[0..post.spare_len]); + c.SDL_PushGPUFragmentUniformData(cmd, 3, &own, @sizeOf(Own)); c.SDL_DrawGPUPrimitives(pass_rp, 3, 1, 0, 0); c.SDL_EndGPURenderPass(pass_rp); input = output; @@ -434,7 +585,107 @@ pub fn draw(post: *Post, device: *c.SDL_GPUDevice, cmd: *c.SDL_GPUCommandBuffer, u.frame +%= 1; } +/// ghostty's block as the prefix cuts it (SDL GPU binds at most 4 KiB of a +/// block): the globals up to the palette, the palette, the colours after it, +/// each a slice of the one struct, at set 3 bindings 0 to 2. +fn pushUniforms(cmd: *c.SDL_GPUCommandBuffer, u: *const Uniforms) void { + const bytes = std.mem.asBytes(u); + const palette = @offsetOf(Uniforms, "palette"); + const colors = @offsetOf(Uniforms, "background_color"); + c.SDL_PushGPUFragmentUniformData(cmd, 0, bytes.ptr, @intCast(palette)); + c.SDL_PushGPUFragmentUniformData(cmd, 1, bytes.ptr + palette, @intCast(colors - palette)); + c.SDL_PushGPUFragmentUniformData(cmd, 2, bytes.ptr + colors, @intCast(@sizeOf(Uniforms) - colors)); +} + +test "each slice of the uniforms fits the 4 KiB SDL GPU binds of a block" { + try std.testing.expect(@offsetOf(Uniforms, "palette") <= 4096); + try std.testing.expect(@offsetOf(Uniforms, "background_color") - @offsetOf(Uniforms, "palette") <= 4096); + try std.testing.expect(@sizeOf(Uniforms) - @offsetOf(Uniforms, "background_color") <= 4096); + try std.testing.expect(@sizeOf(Own) <= 4096); +} + +/// Bloom's glow of `input` (w x h): a bright pass and dual Kawase steps +/// down to 1/2^n of the frame and back up to a half, in float textures; +/// the half-size result, for the composite (shaders/post/bloom.glsl). +fn bloomChain(post: *Post, device: *c.SDL_GPUDevice, cmd: *c.SDL_GPUCommandBuffer, sampler: *c.SDL_GPUSampler, input: *c.SDL_GPUTexture, w: u32, h: u32, level: u8) !*c.SDL_GPUTexture { + const down = post.bloom_down orelse return error.GpuCreate; + const up = post.bloom_up orelse return error.GpuCreate; + const steps = bloom_steps[std.math.clamp(level, 1, 3) - 1]; + if (post.bloom_tex[0] == null or post.bloom_w != w or post.bloom_h != h) { + for (&post.bloom_tex, 0..) |*texture, i| { + 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 = bloom_format; + info.usage = c.SDL_GPU_TEXTUREUSAGE_COLOR_TARGET | c.SDL_GPU_TEXTUREUSAGE_SAMPLER; + info.width = @max(1, w >> @intCast(i + 1)); + info.height = @max(1, h >> @intCast(i + 1)); + 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.bloom_w = w; + post.bloom_h = h; + } + const Step = extern struct { texels: [4]f32, pass: [4]f32 }; + const size = struct { + fn of(full: u32, i: usize) f32 { + return @floatFromInt(if (i == 0) full else @max(1, full >> @intCast(i))); + } + }; + // Down: the frame (0) to 1, 1 to 2, ...; the first through the bright + // pass (a channel's linear peak over 0.6 to 0.75, by level). + const threshold: f32 = switch (std.math.clamp(level, 1, 3)) { + 1 => 0.75, + 2 => 0.7, + else => 0.6, + }; + // Only what is brighter than the page glows: on a light page, where the + // ground itself would pass, nothing does. + const page_peak = peak: { + var peak: f32 = 0; + for (post.uniforms.background_color[0..3]) |v| peak = @max(peak, if (v <= 0.04045) v / 12.92 else std.math.pow(f32, (v + 0.055) / 1.055, 2.4)); + break :peak peak; + }; + for (0..steps) |i| { + const from = if (i == 0) input else post.bloom_tex[i - 1].?; + const to = post.bloom_tex[i].?; + const params: Step = .{ + .texels = .{ 1 / size.of(w, i), 1 / size.of(h, i), 1 / size.of(w, i + 1), 1 / size.of(h, i + 1) }, + .pass = .{ if (i == 0) @max(threshold, page_peak + 0.15) else 0, 0.25, 0, 0 }, + }; + bloomStep(cmd, down, sampler, from, to, ¶ms, @sizeOf(Step)); + } + // Up: each level into the one above it, back to the half. + var i: usize = steps - 1; + while (i > 0) : (i -= 1) { + const params: Step = .{ + .texels = .{ 1 / size.of(w, i + 1), 1 / size.of(h, i + 1), 1 / size.of(w, i), 1 / size.of(h, i) }, + .pass = @splat(0), + }; + bloomStep(cmd, up, sampler, post.bloom_tex[i].?, post.bloom_tex[i - 1].?, ¶ms, @sizeOf(Step)); + } + return post.bloom_tex[0].?; +} + +fn bloomStep(cmd: *c.SDL_GPUCommandBuffer, pipeline: *Pipeline, sampler: *c.SDL_GPUSampler, from: *c.SDL_GPUTexture, to: *c.SDL_GPUTexture, params: *const anyopaque, len: u32) void { + var info = std.mem.zeroes(c.SDL_GPUColorTargetInfo); + info.texture = to; + info.load_op = c.SDL_GPU_LOADOP_DONT_CARE; + info.store_op = c.SDL_GPU_STOREOP_STORE; + const rp = c.SDL_BeginGPURenderPass(cmd, &info, 1, null); + c.SDL_BindGPUGraphicsPipeline(rp, pipeline); + const binding = c.SDL_GPUTextureSamplerBinding{ .texture = from, .sampler = sampler }; + c.SDL_BindGPUFragmentSamplers(rp, 0, &binding, 1); + c.SDL_PushGPUFragmentUniformData(cmd, 0, params, len); + c.SDL_DrawGPUPrimitives(rp, 3, 1, 0, 0); + c.SDL_EndGPURenderPass(rp); +} + pub fn deinit(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice) void { + for (post.bloom_tex) |texture| if (texture) |t| c.SDL_ReleaseGPUTexture(device, t); + for ([_]?*Pipeline{ post.bloom_down, post.bloom_up }) |pipeline| if (pipeline) |p| c.SDL_ReleaseGPUGraphicsPipeline(device, p); if (post.job) |job| { job.thread.join(); for (job.paths[0..job.len], job.results[0..job.len]) |path, result| { @@ -511,16 +762,22 @@ test "the uniform block is ghostty's, offset for offset, and the prefix names it .{ "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().?, " ;"); + // Its three blocks (Globals, Palette, Colors) read as one, in order. + const start = std.mem.indexOf(u8, prefix, "uniform Globals {").?; + const end = std.mem.indexOf(u8, prefix, "uniform PardesPost {").?; + var lines = std.mem.tokenizeScalar(u8, prefix[start..end], '\n'); + var n: usize = 0; + while (lines.next()) |line| { + if (!std.mem.startsWith(u8, line, " uniform ")) continue; + var words = std.mem.tokenizeAny(u8, line, " ;"); 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.expectEqualStrings(glsl[n][0], words.next().?); + try std.testing.expectEqualStrings(glsl[n][1], words.next().?); + n += 1; } - try std.testing.expect(lines.next() == null); + try std.testing.expectEqual(glsl.len, n); + // Each block starts where its first member is in the one struct. + try std.testing.expect(std.mem.indexOf(u8, prefix, "uniform Palette {\n uniform vec3 iPalette[256];\n};") != null); } test "a moved cursor becomes the previous one, stamped with the time of the move" { diff --git a/src/gui/gui.zig b/src/gui/gui.zig index fdcd7b2a..bde9aecd 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -5732,8 +5732,10 @@ fn renderFrame( if (core) |p| { var boxes: [max_cursors]CursorBox = undefined; const cursors = if (cursors_shown) cursorBoxes(g, surface, layout, win_h, &boxes) else 0; + var spare: [Post.max_spare][4]f32 = undefined; g.post.describe(.{ .cursor = if (cursors > 0) boxes[0] else null, + .spare = spare[0..postSpare(surface, layout, &spare)], .background = chrome.page orelse bg_default, .foreground = chrome.fg orelse fg_default, .selection_foreground = p.theme().sel_fg, @@ -8594,6 +8596,46 @@ 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); } +/// What a bundled post pass leaves alone (Post.Frame.spare), in window +/// pixels: every tag with its grip, the column and workspace tags, and the +/// notices, the chrome the focus indicators live in. A grip, the gap after +/// it and the tag beside it are one rectangle; past the list's end the last +/// grows to cover the rest. +fn postSpare(surface: *const pardes.Surface, layout: CellLayout, out: *[Post.max_spare][4]f32) usize { + var n: usize = 0; + for (surface.regionList()) |region| { + switch (region.kind) { + .grip, .tag, .notice, .column_grip, .column_tag, .workspace_tag => {}, + else => continue, + } + if (region.rect.w == 0 or region.rect.h == 0) continue; + const rect: [4]f32 = .{ + @as(f32, @floatFromInt(region.rect.x)) * layout.w, + @as(f32, @floatFromInt(region.rect.y)) * layout.h, + @as(f32, @floatFromInt(region.rect.x + region.rect.w)) * layout.w, + @as(f32, @floatFromInt(region.rect.y + region.rect.h)) * layout.h, + }; + const joined = for (out[0..n]) |*other| { + // The same rows, a gap cell or two apart (a grip, the gap after + // it, its tag): one band. + const gap = 2 * layout.w + 0.5; + if (other[1] == rect[1] and other[3] == rect[3] and rect[0] <= other[2] + gap and other[0] <= rect[2] + gap) { + other.* = .{ @min(other[0], rect[0]), rect[1], @max(other[2], rect[2]), rect[3] }; + break true; + } + } else false; + if (joined) continue; + if (n == out.len) { + const last = &out[n - 1]; + last.* = .{ @min(last[0], rect[0]), @min(last[1], rect[1]), @max(last[2], rect[2]), @max(last[3], rect[3]) }; + continue; + } + out[n] = rect; + n += 1; + } + return n; +} + const CursorBox = Post.Frame.Cursor; const max_cursors = 1 + pardes.MAX_PANES + pardes.MAX_TAG_LAYERS; -- cgit v1.3 From 09ec515c5e94de741104038cd61f18b2b997e78c Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 06:04:28 -0300 Subject: G5: lapis, 0x4200.cafe's manuscript, as a theme with its ornament A native theme `lapis` (src/themes/lapis.zig) from web/theme/lapis.css: a lapis-deep page, lapis tags with gold names, vellum text, vermilion rubrics, gold rules and a vermilion tag rule, gold selection in lapis ink. Its ornament is the theme's new `decor` (colors.Decor), drawn by a pixel shell inside the chrome it already has (decision 1), all of it off every focus indicator: - page_dots: the site's gold dot grid (#e8c46a22, 22 px), drawn by the cell shader on page-coloured cells alone (flag 0x10000000), so it lies under the text and never under a selection, a tag or the cursor; fixed to the window, a page the panes lie on. - rail_checker: the dither frame's gold and lapis checker in the scroll track (a decor kind, flag 0x08000000), anchored to its rail; the thumb stays solid gold. - tag plaques (tag_border, tag_shadow, tag_stripe): every tag a raised plaque inside its own band, the site's trail: a 2px gold frame and a hard 5px vermilion shadow down and right, both within the band's rows and columns (down, what the slack under the text leaves), so no shadow reaches a body, a grip or the next pane. The focused pane's plaque is the code window's title bar, gold and lapis stripes of 2px, its words on plates of the focused ground; unfocused ones are plain; column and workspace tags take the object-label language (1px frame, 2px shadow toward the page). Drawn as decor under the cells (a group's under range), with blank tag cells see-through and the cells blended premultiplied (pipeline_over) on such frames; opaque windows only. - title_shadow: the titles' vermilion offset shadow under a file name's letters, sampled in the cell shader inside the letters' own cells (flag 0x08000000), none on a cursor. Chrome carries the decor (decor_*, name_ink, active_name_ink); a theme file folds a nested decor literal field by field. Left out, with why, in docs/effects.md: the fonts, the ornaments, a decor crossfade, and in the tty anything but the colours (a tag row has no spare cell for a shadow). An empty column is the page. The effect id is masked to 27 bits (ui.vert/ui.frag) for the fifth flag. Tests: the cell flags land only on page ground and file-name cells, never a cursor, nothing without decor; lapis's plaques stay inside their tag's band, clear of every grip, only the focused one striped; its checker is in the track; no other theme draws any; the separator and focus tests cover lapis. Goldens: theme and themesel (the ring gains lapis after acme). Gates as G4. Feel review: .scratch/render/lapis/ (lapis.png, lapis-2x.png, tags-focused-vs-unfocused-2x.png, a PanelSlide at 60 fps and a quarter). --- docs/effects.md | 29 ++++ docs/themes.md | 1 + shaders/decor.frag.glsl | 13 ++ shaders/prebuilt/decor.frag.glsl | 13 ++ shaders/prebuilt/decor.frag.spv | Bin 6560 -> 7852 bytes shaders/prebuilt/ui.frag.glsl | 32 +++- shaders/prebuilt/ui.frag.spv | Bin 5700 -> 8448 bytes shaders/prebuilt/ui.vert.glsl | 10 +- shaders/prebuilt/ui.vert.spv | Bin 5612 -> 5612 bytes shaders/ui.frag.glsl | 32 +++- shaders/ui.vert.glsl | 10 +- src/colors.zig | 49 +++++- src/detached/wire.zig | 2 +- src/draw.zig | 17 ++ src/gui/gui.zig | 335 ++++++++++++++++++++++++++++++++++++++- src/surface.zig | 20 +++ src/themes/lapis.zig | 92 +++++++++++ test/snapshots/theme.golden | 62 ++++---- test/snapshots/themesel.golden | 126 +++++++-------- 19 files changed, 729 insertions(+), 114 deletions(-) create mode 100644 src/themes/lapis.zig (limited to 'docs') diff --git a/docs/effects.md b/docs/effects.md index 8073c50a..8a60d116 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -38,6 +38,7 @@ why. The verdict is the user's. | G1 lift | `Lift shadow\|rim\|auto` (off), `InactiveDim ` | opt-in until the focus-lift default is decided | lift-shots-2 (off, shadow, rim, auto on acme, dusk, forge); frame series + mp4 per Motion flavour | shadow on acme: keep as is. Round 1 dropped glow and surface (surface lowered the focused text's contrast) and made rim a hairline just above the focused tag (light on a dark page, shade on a light one). auto on a dark page recedes the others (InactiveDim 30) | | G3 cursor | follows `Motion`; `CursorBlink on\|off` (on) | glide on by default through the default flavour (smooth); blink on (decision 6) | .scratch/render/cursor/cursor-flavours-grid.mp4 (and quarter speed), raw/// | pending | | G4 bloom, vignette, grain | `Bloom`, `Vignette`, `Grain` `0..3` (off) | bundled post passes; none touches a tag, grip, notice or the cursor (pardesSpare); each capped per theme (pardesCap) | .scratch/render/{bloom,vignette,grain}/ (levels 0-3 on forge, dusk, acme; crops; mp4 stepping through the levels) | pending | +| G5 lapis | `Theme lapis` (its `decor`) | a native theme with ornament: tag plaques with hard shadows inside their bands (the focused one striped), dots, rail checker, title shadow | .scratch/render/lapis/ (whole window at 1x and 2x, crops, a slide mp4) | pending | ## Motion flavours @@ -114,3 +115,31 @@ is round, starts halfway to a corner and reaches 10, 18 and 28%. Grain is Found on the way: SDL GPU binds at most 4 KiB of a uniform block, so every Shadertoy colour after the palette (iBackgroundColor and on) read zero; the block is now cut in three with ghostty's names and order kept. + +Notes on G5: lapis (src/themes/lapis.zig) is 0x4200.cafe's lapis.css in +pardes's chrome, drawn inside it (decision 1). The tags are the site's +trail and title bars: every tag a plaque in its own band, framed in gold +(2 px, the trail's border) with the trail's hard vermilion shadow (5 px) +down and right, the frame and the shadow both inside the band's rows and +columns, so no shadow reaches a body, a grip or the next pane; down, the +shadow gets what the band's slack under the text leaves (a pixel or three +at a tagline under the body's size, more at a larger one), to the right +its full offset. The focused pane's plaque is the code window's title bar, +gold and lapis stripes of 2 px, its words on plates of the focused ground +(the words' cells stay opaque, the blanks between let the stripes show), +so the focus indicator is plainer to see than any theme's tint, never +less; the unfocused ones are plain. Column and workspace tags take the +quieter object-label language: a 1 px frame and a 2 px shadow of vermilion +toward the page. The plaques are decor drawn under their cells (a group's +under range) with the cells blended premultiplied over them; blank tag +cells on the tag's ground are see-through there. They need an opaque +window: under WindowOpacity 100 the tags are plain. Also from the site: +the colours (lapis-deep page, vellum text, gold names and rules, +vermilion rubrics and tag rule, gold selection in lapis ink), the page's +gold dot grid (22 px, #e8c46a22) on page-coloured cells only, fixed to the +window; the dither frame's checker in the scroll track, anchored to its +rail (the thumb solid gold); the titles' vermilion offset shadow under a +file name's letters. Left out: Crimson Pro and Departure Mono, the +ornaments, a decor crossfade (it snaps while the colours fade). The tty +shows the colours alone: a tag row abuts its body and its neighbour, so +there is no cell for a shadow row or column. diff --git a/docs/themes.md b/docs/themes.md index 2b15051e..38596819 100644 --- a/docs/themes.md +++ b/docs/themes.md @@ -121,6 +121,7 @@ so existing exported themes remain valid. | `rule_px` | Pixel shells: the width of the rules between columns, between panes stacked in a column, and under the workspace and column tags, in `border` (the rule between a tag and its body is always one pixel), in logical pixels: doubled on a 2x display, as acme scales its Border | 2 | | `box_border`, `box_dirty` | The grip is acme's button: `box` filled when focused, else a two-pixel ring of `box_border` round the tag's ground; `box_dirty` fills it inside either ring while its file is unsaved (a grid, which has no ring, shows a bold `*` on `box_dirty` in the grip's second cell) | `box_dim`; `diagnostic_warning`, then `num` | | `rail_px` | Pixel shells: the scroll column's width, the grip's button over the scrollbar (thumb a pixel narrower), in pixels at a 17px tagline, scaled with it | 12, acme's Scrollwid | +| `decor` | Pixel shells: a theme's ornament, drawn inside the chrome it has (lapis's): `page_dots` (a dot grid on the page's own ground, `page_dot_alpha` strong every `page_dot_px`), `rail_checker` (a checker in the scroll track, squares of `rail_checker_px`), `tag_border`, `tag_shadow`, `tag_stripe` (every tag a raised plaque inside its own band: a `tag_border_px` frame round its text and a hard `tag_shadow_px` shadow down and right, both inside the band's rows and columns; the focused pane's plaque striped `tag_stripe_px` with its words on plates of the tag's ground; column and workspace tags a quieter one; opaque windows only) and `title_shadow` (the file name's offset shadow, `title_shadow_px`), sizes in logical pixels. None of it touches a focus indicator | none | Native palettes give filenames a distinct hue at approximately the same brightness as the surrounding tag text. This also applies to output names diff --git a/shaders/decor.frag.glsl b/shaders/decor.frag.glsl index 3e2c286f..bb1ef0d6 100644 --- a/shaders/decor.frag.glsl +++ b/shaders/decor.frag.glsl @@ -8,6 +8,8 @@ // colour rides in the cell's ground (a_bg), its coverage in a_fg.r, so the // result is the overlay pipeline's exactly. // +// Checker (flag 0x08000000): two colours in squares, or in stripes, see main. +// // Soft (flag 0x10000000): a rectangle's shadow, rim or glow blurred by a Gaussian, // in closed form (the product of two erf edges), on one quad grown by 3 // sigma: no blur pass. Everything is in the quad's own 0..1 space, so a @@ -51,6 +53,17 @@ 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 opacity = clamp(u_ui.background_opacity, 0.0, 1.0); + // Checker (flag 0x08000000): squares of v_uv.x pixels from the quad's + // own corner, so it moves with its rail, in the ground's colour and the + // ink's (lapis's scroll track). + vec2 in_quad = v_cell_uv / max(vec2(dFdx(v_cell_uv.x), dFdy(v_cell_uv.y)), vec2(1e-6)); + if ((v_effect.x & 0x08000000u) != 0u) { + ivec2 q = ivec2(floor(in_quad / max(v_uv.x, 1.0))); + // v_uv.y set: stripes across the quad (lapis's title bar), not squares. + int k = v_uv.y > 0.5 ? q.y : q.x + q.y; + o_col = vec4(((k & 1) == 0 ? v_bg : v_fg) * opacity, 1.0); + return; + } if ((v_effect.x & 0x10000000u) == 0u) { o_col = vec4(v_bg * opacity, v_fg.r); return; diff --git a/shaders/prebuilt/decor.frag.glsl b/shaders/prebuilt/decor.frag.glsl index 3e2c286f..bb1ef0d6 100644 --- a/shaders/prebuilt/decor.frag.glsl +++ b/shaders/prebuilt/decor.frag.glsl @@ -8,6 +8,8 @@ // colour rides in the cell's ground (a_bg), its coverage in a_fg.r, so the // result is the overlay pipeline's exactly. // +// Checker (flag 0x08000000): two colours in squares, or in stripes, see main. +// // Soft (flag 0x10000000): a rectangle's shadow, rim or glow blurred by a Gaussian, // in closed form (the product of two erf edges), on one quad grown by 3 // sigma: no blur pass. Everything is in the quad's own 0..1 space, so a @@ -51,6 +53,17 @@ 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 opacity = clamp(u_ui.background_opacity, 0.0, 1.0); + // Checker (flag 0x08000000): squares of v_uv.x pixels from the quad's + // own corner, so it moves with its rail, in the ground's colour and the + // ink's (lapis's scroll track). + vec2 in_quad = v_cell_uv / max(vec2(dFdx(v_cell_uv.x), dFdy(v_cell_uv.y)), vec2(1e-6)); + if ((v_effect.x & 0x08000000u) != 0u) { + ivec2 q = ivec2(floor(in_quad / max(v_uv.x, 1.0))); + // v_uv.y set: stripes across the quad (lapis's title bar), not squares. + int k = v_uv.y > 0.5 ? q.y : q.x + q.y; + o_col = vec4(((k & 1) == 0 ? v_bg : v_fg) * opacity, 1.0); + return; + } if ((v_effect.x & 0x10000000u) == 0u) { o_col = vec4(v_bg * opacity, v_fg.r); return; diff --git a/shaders/prebuilt/decor.frag.spv b/shaders/prebuilt/decor.frag.spv index 3256f2b1..bd565d03 100644 Binary files a/shaders/prebuilt/decor.frag.spv and b/shaders/prebuilt/decor.frag.spv differ diff --git a/shaders/prebuilt/ui.frag.glsl b/shaders/prebuilt/ui.frag.glsl index dca224ba..62dbbfa2 100644 --- a/shaders/prebuilt/ui.frag.glsl +++ b/shaders/prebuilt/ui.frag.glsl @@ -10,6 +10,12 @@ layout(set = 2, binding = 0) uniform sampler2D u_atlas; layout(std140, set = 3, binding = 0) uniform UiUniforms { float background_opacity; + // A theme's ornament (colors.Decor): the page's dots (rgb, strength), + // their geometry (period, solid radius, faded radius, in pixels), and a + // file name's shadow (rgb, offset in pixels). + vec4 decor_dots; + vec4 decor_dot_geom; + vec4 decor_shadow; } u_ui; layout(location = 0) in vec2 v_uv; @@ -43,7 +49,7 @@ void main() { float bgA = (v_effect.x & 0x20000000u) != 0u ? 1.0 : (v_effect.x & 0x40000000u) != 0u ? 0.0 : clamp(u_ui.background_opacity, 0.0, 1.0); - uint effect = v_effect.x & 0x0fffffffu; + uint effect = v_effect.x & 0x07ffffffu; float progress = clamp(uintBitsToFloat(v_effect.y), 0.0, 1.0); uint cellCol = v_effect.w & 0xffffu; uint cellRow = v_effect.w >> 16; @@ -52,7 +58,29 @@ void main() { // Ink over ground, both premultiplied: v_fg*a + (v_bg*bgA)*(1-a), which is // mix() with the ground already premultiplied. At bgA = 1 that is exactly // the mix(v_bg, v_fg, a) at alpha 1 this shader always wrote. - vec4 cell = vec4(mix(v_bg * bgA, v_fg, a), mix(bgA, 1.0, a)); + vec3 ground = v_bg; + // 0x10000000: the page's ground under a pane body, dotted (lapis): a + // dot every period pixels, fixed to the window, under the text. + if ((v_effect.x & 0x10000000u) != 0u) { + float period = u_ui.decor_dot_geom.x; + vec2 d = mod(gl_FragCoord.xy, period) - 0.5 * period; + float dot_a = 1.0 - smoothstep(u_ui.decor_dot_geom.y, u_ui.decor_dot_geom.z, length(d)); + ground = mix(ground, u_ui.decor_dots.rgb, u_ui.decor_dots.a * dot_a); + } + vec4 under = vec4(ground * bgA, bgA); + // Derivatives outside any branch: a pixel's step in the atlas and in + // the cell, for the shadow's offset. + vec2 step_uv = vec2(dFdx(v_uv.x), dFdy(v_uv.y)) * u_ui.decor_shadow.a; + vec2 step_cell = vec2(dFdx(v_cell_uv.x), dFdy(v_cell_uv.y)) * u_ui.decor_shadow.a; + // 0x08000000: a file name's letters cast a shadow down and right onto + // their own cell's ground (lapis), under the ink; what would fall past + // the cell's edge is not drawn. + if ((v_effect.x & 0x08000000u) != 0u) { + vec2 from = v_cell_uv - step_cell; + float s = all(greaterThanEqual(from, vec2(0.0))) ? texture(u_atlas, v_uv - step_uv).r : 0.0; + under = mix(under, vec4(u_ui.decor_shadow.rgb, 1.0), s); + } + vec4 cell = vec4(mix(under.rgb, v_fg, a), mix(under.a, 1.0, a)); // 3 = data dissolve. Changed cells retain the frozen old cell until their // stable threshold, then atomically become the new cell. Exact endpoints diff --git a/shaders/prebuilt/ui.frag.spv b/shaders/prebuilt/ui.frag.spv index c8ee1604..d83d9c55 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 5551f3f1..de22adee 100644 --- a/shaders/prebuilt/ui.vert.glsl +++ b/shaders/prebuilt/ui.vert.glsl @@ -14,9 +14,11 @@ layout(location = 3) in vec3 a_bg; layout(location = 4) in vec4 a_panel_rect; layout(location = 5) in vec4 a_present_rect; // effect id, eased-progress float bits, pane serial, local row/column packed -// into one uint. The FOUR high effect bits are flags — the frozen-old cell -// layer, a see-through cell background, an opaque block-cursor fill and a -// soft shadow (decor.frag.glsl) — so the id is what is left below them; integer transport preserves the exact +// into one uint. The FIVE high effect bits are flags — the frozen-old cell +// layer, a see-through cell background, an opaque block-cursor fill, a soft +// shadow (decor.frag.glsl) or dotted page ground (ui.frag.glsl), and a +// checker (decor) or a file name's shadow (ui) — so 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 @@ -61,7 +63,7 @@ void main() { v_cell_uv = vec2(0.0, 1.0); } - uint effect = a_effect.x & 0x0fffffffu; + uint effect = a_effect.x & 0x07ffffffu; if (effect == 1u || effect == 2u || effect == 5u) { // NDC y points up, so a top-left/bottom-right box has a NEGATIVE y // delta. Preserve that sign or every local y is mirrored/outside. diff --git a/shaders/prebuilt/ui.vert.spv b/shaders/prebuilt/ui.vert.spv index b1e87ec1..af5b0738 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 dca224ba..62dbbfa2 100644 --- a/shaders/ui.frag.glsl +++ b/shaders/ui.frag.glsl @@ -10,6 +10,12 @@ layout(set = 2, binding = 0) uniform sampler2D u_atlas; layout(std140, set = 3, binding = 0) uniform UiUniforms { float background_opacity; + // A theme's ornament (colors.Decor): the page's dots (rgb, strength), + // their geometry (period, solid radius, faded radius, in pixels), and a + // file name's shadow (rgb, offset in pixels). + vec4 decor_dots; + vec4 decor_dot_geom; + vec4 decor_shadow; } u_ui; layout(location = 0) in vec2 v_uv; @@ -43,7 +49,7 @@ void main() { float bgA = (v_effect.x & 0x20000000u) != 0u ? 1.0 : (v_effect.x & 0x40000000u) != 0u ? 0.0 : clamp(u_ui.background_opacity, 0.0, 1.0); - uint effect = v_effect.x & 0x0fffffffu; + uint effect = v_effect.x & 0x07ffffffu; float progress = clamp(uintBitsToFloat(v_effect.y), 0.0, 1.0); uint cellCol = v_effect.w & 0xffffu; uint cellRow = v_effect.w >> 16; @@ -52,7 +58,29 @@ void main() { // Ink over ground, both premultiplied: v_fg*a + (v_bg*bgA)*(1-a), which is // mix() with the ground already premultiplied. At bgA = 1 that is exactly // the mix(v_bg, v_fg, a) at alpha 1 this shader always wrote. - vec4 cell = vec4(mix(v_bg * bgA, v_fg, a), mix(bgA, 1.0, a)); + vec3 ground = v_bg; + // 0x10000000: the page's ground under a pane body, dotted (lapis): a + // dot every period pixels, fixed to the window, under the text. + if ((v_effect.x & 0x10000000u) != 0u) { + float period = u_ui.decor_dot_geom.x; + vec2 d = mod(gl_FragCoord.xy, period) - 0.5 * period; + float dot_a = 1.0 - smoothstep(u_ui.decor_dot_geom.y, u_ui.decor_dot_geom.z, length(d)); + ground = mix(ground, u_ui.decor_dots.rgb, u_ui.decor_dots.a * dot_a); + } + vec4 under = vec4(ground * bgA, bgA); + // Derivatives outside any branch: a pixel's step in the atlas and in + // the cell, for the shadow's offset. + vec2 step_uv = vec2(dFdx(v_uv.x), dFdy(v_uv.y)) * u_ui.decor_shadow.a; + vec2 step_cell = vec2(dFdx(v_cell_uv.x), dFdy(v_cell_uv.y)) * u_ui.decor_shadow.a; + // 0x08000000: a file name's letters cast a shadow down and right onto + // their own cell's ground (lapis), under the ink; what would fall past + // the cell's edge is not drawn. + if ((v_effect.x & 0x08000000u) != 0u) { + vec2 from = v_cell_uv - step_cell; + float s = all(greaterThanEqual(from, vec2(0.0))) ? texture(u_atlas, v_uv - step_uv).r : 0.0; + under = mix(under, vec4(u_ui.decor_shadow.rgb, 1.0), s); + } + vec4 cell = vec4(mix(under.rgb, v_fg, a), mix(under.a, 1.0, a)); // 3 = data dissolve. Changed cells retain the frozen old cell until their // stable threshold, then atomically become the new cell. Exact endpoints diff --git a/shaders/ui.vert.glsl b/shaders/ui.vert.glsl index 5551f3f1..de22adee 100644 --- a/shaders/ui.vert.glsl +++ b/shaders/ui.vert.glsl @@ -14,9 +14,11 @@ layout(location = 3) in vec3 a_bg; layout(location = 4) in vec4 a_panel_rect; layout(location = 5) in vec4 a_present_rect; // effect id, eased-progress float bits, pane serial, local row/column packed -// into one uint. The FOUR high effect bits are flags — the frozen-old cell -// layer, a see-through cell background, an opaque block-cursor fill and a -// soft shadow (decor.frag.glsl) — so the id is what is left below them; integer transport preserves the exact +// into one uint. The FIVE high effect bits are flags — the frozen-old cell +// layer, a see-through cell background, an opaque block-cursor fill, a soft +// shadow (decor.frag.glsl) or dotted page ground (ui.frag.glsl), and a +// checker (decor) or a file name's shadow (ui) — so 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 @@ -61,7 +63,7 @@ void main() { v_cell_uv = vec2(0.0, 1.0); } - uint effect = a_effect.x & 0x0fffffffu; + uint effect = a_effect.x & 0x07ffffffu; if (effect == 1u || effect == 2u || effect == 5u) { // NDC y points up, so a top-left/bottom-right box has a NEGATIVE y // delta. Preserve that sign or every local y is mirrored/outside. diff --git a/src/colors.zig b/src/colors.zig index 4a18bba9..b44e84f9 100644 --- a/src/colors.zig +++ b/src/colors.zig @@ -123,6 +123,42 @@ pub fn mix(a: [3]u8, b: [3]u8) [3]u8 { return out; } +/// A theme's ornament, drawn by a pixel shell inside the chrome it already +/// has (docs/render-pipeline.md §10: no gaps between panes): the lapis +/// theme's. Sizes are logical pixels. None of it touches a focus indicator: +/// the dots lie on the page's own ground, the checker in the scroll track +/// (never its thumb), a tag's plaque inside its band beside its grip (and +/// the focused one only the more striking), and the shadow under a file +/// name's own letters. +pub const Decor = struct { + /// A dot grid on the page's ground, `page_dot_alpha` of 255 strong, a + /// dot one logical pixel across (fading out by a pixel and a half) every + /// `page_dot_px`, fixed to the window. + page_dots: ?[3]u8 = null, + page_dot_alpha: u8 = 0x22, + page_dot_px: u8 = 22, + /// A checker in the scroll track, squares of `rail_checker_px`, this + /// colour and the track's. + rail_checker: ?[3]u8 = null, + rail_checker_px: u8 = 3, + /// Every tag drawn as a raised plaque inside its own band (the site's + /// trail and title bars): a `tag_border` frame round the text, a hard + /// `tag_shadow` offset down and right by `tag_shadow_px`, both within + /// the band's rows and columns, never past them; the focused pane's + /// plaque striped `tag_stripe` and the tag's ground, `tag_stripe_px` + /// each, its words on plates of the ground. Column and workspace tags + /// take a quieter one (a one-pixel frame, a shadow of two, no stripes). + tag_border: ?[3]u8 = null, + tag_border_px: u8 = 2, + tag_shadow: ?[3]u8 = null, + tag_shadow_px: u8 = 5, + tag_stripe: ?[3]u8 = null, + tag_stripe_px: u8 = 2, + /// The file name's offset shadow, down and right, in a pane's tag. + title_shadow: ?[3]u8 = null, + title_shadow_px: u8 = 2, +}; + pub const Theme = struct { name: []const u8, bg: ?[3]u8, @@ -188,6 +224,8 @@ pub const Theme = struct { /// in pixels at a 17px tagline, the thumb one narrower (acme's 12px /// Scrollwid, the default). rail_px: ?u8 = null, + /// A pixel shell's ornament (Decor): lapis's. + decor: ?Decor = null, }; const curated = struct { @@ -209,6 +247,7 @@ const curated = struct { pub const helix = @import("themes/helix.zig"); pub const dark = @import("themes/dark.zig"); pub const acme = @import("themes/acme.zig"); + pub const lapis = @import("themes/lapis.zig"); }; fn fold(comptime C: type) [@typeInfo(C).@"struct".decls.len]Theme { @@ -219,7 +258,15 @@ fn fold(comptime C: type) [@typeInfo(C).@"struct".decls.len]Theme { var t: Theme = undefined; const source = @field(C, d.name).theme; for (@typeInfo(Theme).@"struct".fields) |f| { - @field(t, f.name) = if (@hasField(@TypeOf(source), f.name)) + @field(t, f.name) = if (f.type == ?Decor and @hasField(@TypeOf(source), f.name)) decor: { + // A nested literal is folded field by field too. + var decor: Decor = .{}; + const given = @field(source, f.name); + for (@typeInfo(Decor).@"struct".fields) |g| if (@hasField(@TypeOf(given), g.name)) { + @field(decor, g.name) = @field(given, g.name); + }; + break :decor decor; + } else if (@hasField(@TypeOf(source), f.name)) @field(source, f.name) else if (f.default_value_ptr) |ptr| @as(*const f.type, @ptrCast(@alignCast(ptr))).* diff --git a/src/detached/wire.zig b/src/detached/wire.zig index 53a1a70d..9f9c716a 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -13,7 +13,7 @@ pub const version: u16 = 8; /// The Chrome palette is carried field by field as its declaration lists /// them, so any change to it changes the wire: bump `version`, then this. -const chrome_layout: u64 = 0xc91196fcdef144e2; +const chrome_layout: u64 = 0xf852cb229ae38f51; comptime { @setEvalBranchQuota(10_000); var hash = std.hash.Fnv1a_64.init(); diff --git a/src/draw.zig b/src/draw.zig index b8cf4e7d..5f14ae21 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -470,7 +470,24 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { .grip_dirty = boxDirty(p), .tag_rule = p.theme().tag_rule, .rail_px = @intCast(@min(255, @as(u32, p.theme().rail_px orelse 12) * p.settings.grip_width / 100)), + .name_ink = chrome.tag_name_fg, + .active_name_ink = if (p.settings.focus_tint) chrome.tag_active_name_fg else chrome.tag_name_fg, }; + if (p.theme().decor) |decor| { + s.chrome.decor_dots = decor.page_dots; + s.chrome.decor_dot_alpha = decor.page_dot_alpha; + s.chrome.decor_dot_px = decor.page_dot_px; + s.chrome.decor_checker = decor.rail_checker; + s.chrome.decor_checker_px = decor.rail_checker_px; + s.chrome.decor_box_border = decor.tag_border; + s.chrome.decor_box_border_px = decor.tag_border_px; + s.chrome.decor_box_shadow = decor.tag_shadow; + s.chrome.decor_box_shadow_px = decor.tag_shadow_px; + s.chrome.decor_box_stripe = decor.tag_stripe; + s.chrome.decor_box_stripe_px = decor.tag_stripe_px; + s.chrome.decor_title = decor.title_shadow; + s.chrome.decor_title_px = decor.title_shadow_px; + } place(p, s); // Separators have their own role; a scrollbar need not frame the page. const whole = s.regions[0].rect; diff --git a/src/gui/gui.zig b/src/gui/gui.zig index bde9aecd..092bb3bf 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -507,6 +507,12 @@ const clear_bg_bit: u32 = 0x4000_0000; const opaque_bg_bit: u32 = 0x2000_0000; /// Decor that is a soft shadow, not a flat fill (decor.frag.glsl). const soft_shadow_bit: u32 = 0x1000_0000; +/// Decor that is a checker (decor.frag.glsl). +const checker_bit: u32 = 0x0800_0000; +/// A cell on the page's ground under the theme's dots, and a file name's +/// cell that casts the theme's shadow (ui.frag.glsl, Chrome.decor_*). +const dotted_bit: u32 = 0x1000_0000; +const title_bit: u32 = 0x0800_0000; const SavedImagePlace = struct { key: pardes.ImageCacheKey, @@ -1787,6 +1793,10 @@ const Gui = struct { device: *c.SDL_GPUDevice, swapchain_format: c.SDL_GPUTextureFormat, pipeline: *c.SDL_GPUGraphicsPipeline, + /// The cell pipeline, blending premultiplied over what is drawn under: + /// for a frame with a theme's tag plaques, whose blank tag cells let + /// them show (tagPlaques). + pipeline_over: *c.SDL_GPUGraphicsPipeline, decor_pipeline: *c.SDL_GPUGraphicsPipeline, overlay_pipeline: *c.SDL_GPUGraphicsPipeline, image_pipeline: *c.SDL_GPUGraphicsPipeline, @@ -2068,6 +2078,9 @@ const Group = struct { image_count: u32 = 0, decor_start: u32 = 0, decor_count: u32 = 0, + /// The first `under_count` of the group's decor are drawn before its + /// cells, under them (a theme's tag plaques, whose cells let them show). + under_count: u32 = 0, }; /// The frame's draw groups, bottom to top: tier 0 (the page and the panes @@ -2455,6 +2468,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u const atlas_xfer = c.SDL_CreateGPUTransferBuffer(device, &ax_info) orelse return error.GpuCreate; const pipeline = try makePipeline(device, swapchain_format, frag_spv, 1, std.mem.zeroes(c.SDL_GPUColorTargetBlendState)); + const pipeline_over = try makePipeline(device, swapchain_format, frag_spv, 1, premultipliedBlend()); 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); @@ -2485,6 +2499,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u .device = device, .swapchain_format = swapchain_format, .pipeline = pipeline, + .pipeline_over = pipeline_over, .decor_pipeline = decor_pipeline, .overlay_pipeline = overlay_pipeline, .image_pipeline = image_pipeline, @@ -2523,6 +2538,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.pipeline_over); c.SDL_ReleaseGPUGraphicsPipeline(device, g.decor_pipeline); c.SDL_ReleaseGPUGraphicsPipeline(device, g.overlay_pipeline); c.SDL_ReleaseGPUTexture(device, g.atlas_tex); @@ -5418,6 +5434,20 @@ fn renderFrame( defer if (building) build_zone.end(); const layout = fixedCellLayout(g); const chrome = &surface.chrome; + cell_decor = CellDecor.of(chrome); + // Plaques need an opaque window: with WindowOpacity under 100 the cells + // are drawn as they are, and the plaques not at all. + cell_decor.plaques_off = backgroundOpacity(g.applied_window_opacity) < 1; + if (chrome.decor_box_border != null and !cell_decor.plaques_off) { + cell_decor.grounds = .{ chrome.tag_bg, chrome.tag_focus_bg }; + for (surface.regionList()) |region| { + if (region.kind != .tag and region.kind != .column_tag and region.kind != .workspace_tag) continue; + if (cell_decor.nplaques == cell_decor.plaques.len) break; + const plaque = plaqueBox(g, chrome, surface.regionList(), region, layout, win_w, win_h) orelse continue; + cell_decor.plaques[cell_decor.nplaques] = plaque.band; + cell_decor.nplaques += 1; + } + } var groups = makeGroups(surface.panelTracks(), surface.hasPanelDiff()); // Cursors stand still while panes move, as they always have; while the // focused one glides or blinks, it is the overlay's quad instead. One @@ -5696,9 +5726,16 @@ fn renderFrame( const whole = c.SDL_Rect{ .x = 0, .y = 0, .w = @intCast(sw), .h = @intCast(sh) }; const decor_base = cell_total + shifted; for (groups.items[0..groups.len], 0..) |group, index| { + if (group.under_count != 0 and g.vbuf != null) { + c.SDL_BindGPUGraphicsPipeline(rp, g.decor_pipeline); + pushLayerOpacity(cmd, rp, 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.under_count, 0, 0); + } 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); + c.SDL_BindGPUGraphicsPipeline(rp, if (cell_decor.nplaques != 0) g.pipeline_over else 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 (group.cell_count != 0) { @@ -5716,13 +5753,13 @@ fn renderFrame( } } if (group.image_count != 0) drawNativeImagesGpu(g, rp, surface, group, whole); - if (group.decor_count != 0 and g.vbuf != null) { + if (group.decor_count > group.under_count 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) }; + const binding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = (decor_base + group.decor_start + group.under_count) * @sizeOf(CellInstance) }; c.SDL_BindGPUVertexBuffers(rp, 0, &binding, 1); - c.SDL_DrawGPUPrimitives(rp, 6, group.decor_count, 0, 0); + c.SDL_DrawGPUPrimitives(rp, 6, group.decor_count - group.under_count, 0, 0); } } drawOverlayGpu(g, cmd, rp, overlay_count); @@ -6024,6 +6061,9 @@ fn emitTagLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const p const y = layer.viewport.y + @as(u16, @intCast(index)); const top = @as(f32, @floatFromInt(y)) * bh; emitInstance(g, instances, next.*, 0, y, .{ .w = band_right - left, .h = bh, .x_off = left, .y_off = slide }, win_w, win_h, track, .body, (&background)[0..1], 0, null, old_layer, false, page); + // A theme's plaque is drawn under the band: the band's own ground + // lets it show (tagPlaques). + if (cell_decor.nplaques != 0 and cell_decor.inPlaque(left + 0.5, top + 0.5)) instances[next.*].effect |= clear_bg_bit; if (slide != 0) clipInstanceTop(&instances[next.*], top, win_h); next.* += 1; const cells = layer.cells[index * layer.cols ..][0..layer.cols]; @@ -6229,6 +6269,10 @@ fn emitSurfaceCell( // this underlay at tagline height leaves page-colored gutters between // adjacent workspace, column and pane tags at TaglineSize < 100. emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .body, (&band_base)[0..1], 0, null, old_layer, false, page); + // Over a theme's plaque (not a grip's button) the underlay lets it + // show (tagPlaques). + if (cell_decor.nplaques != 0 and !here.grip and cell_decor.inPlaque((@as(f32, @floatFromInt(col)) + 0.5) * body_layout.w + body_layout.x_off, @as(f32, @floatFromInt(row)) * body_layout.h + body_layout.y_off + 0.5)) + instances[next.*].effect |= clear_bg_bit; next.* += 1; // Tag glyphs keep their font size inside a full physical-width grip. // A pane's grip cells are the cell shells' form (a dirty `*`): the @@ -6553,6 +6597,21 @@ fn emitInstance( // A block cursor is foreground UI ink, even though its color is carried // in the reversed cell background. Keep it visible at WindowOpacity 0. if (is_cursor) instances[idx].effect |= opaque_bg_bit; + // The theme's ornament (cell_decor): dots on the page's own ground, a + // shadow under a file name's letters. Never on a cursor. + if (!is_cursor) { + // A blank cell on a tag's ground inside a plaque: the plaque shows. + if (cell_decor.nplaques != 0 and resolved.role == .tagline and cellCodepoint(&line[at]) == ' ' and + (std.mem.eql(u8, &resolved.bg, &cell_decor.grounds[0]) or std.mem.eql(u8, &resolved.bg, &cell_decor.grounds[1])) and + (cell_decor.inPlaque(px0 + 0.5, py0 + 0.5) or cell_decor.inPlaque(px0 + layout.w - 0.5, py0 + 0.5))) + instances[idx].effect |= clear_bg_bit; + if (cell_decor.dots) |dotted| { + if (!resolved.clear_bg and std.mem.eql(u8, &resolved.bg, &dotted)) instances[idx].effect |= dotted_bit; + } + if (cell_decor.title) |inks| { + if (resolved.role == .tagline and (std.mem.eql(u8, &resolved.fg, &inks[0]) or std.mem.eql(u8, &resolved.fg, &inks[1]))) instances[idx].effect |= title_bit; + } + } } test "block cursor alone overrides transparent cell background" { @@ -6569,6 +6628,107 @@ test "block cursor alone overrides transparent cell background" { } } +test "lapis dots only the page's own ground and shadows only a file name, never a cursor" { + var g: Gui = undefined; + g.words = .{}; + g.cell_w = 10; + g.cell_h = 20; + g.tagline_width = 8; + g.tagline_height = 16; + g.space_slot = .{ .u = 0, .v = 0 }; + const page: [3]u8 = .{ 0x0a, 0x10, 0x30 }; + const name: [3]u8 = .{ 0xe8, 0xc4, 0x6a }; + var chrome: pardes.Chrome = .{ .page = page, .decor_dots = .{ 1, 2, 3 }, .decor_dot_alpha = 0x22, .decor_title = .{ 4, 5, 6 }, .name_ink = name, .active_name_ink = name }; + cell_decor = CellDecor.of(&chrome); + defer cell_decor = .{}; + const on_page: pardes.Cell = .{ .default = false, .style = .{ .fg = .{ .rgb = .{ 200, 200, 200 } }, .bg = .{ .rgb = page } } }; + const selected: pardes.Cell = .{ .default = false, .style = .{ .fg = .{ .rgb = .{ 0, 0, 0 } }, .bg = .{ .rgb = name } } }; + const title: pardes.Cell = .{ .default = false, .style = .{ .fg = .{ .rgb = name }, .bg = .{ .rgb = .{ 0x0f, 0x1a, 0x4a } }, .font_role = .tagline } }; + const plain_tag: pardes.Cell = .{ .default = false, .style = .{ .fg = .{ .rgb = .{ 0x9a, 0xa6, 0xd9 } }, .bg = .{ .rgb = .{ 0x0f, 0x1a, 0x4a } }, .font_role = .tagline } }; + var instances: [1]CellInstance = undefined; + const Case = struct { cell: pardes.Cell, role: pardes.FontRole, cursor: bool, dotted: bool, shadowed: bool }; + for ([_]Case{ + .{ .cell = on_page, .role = .body, .cursor = false, .dotted = true, .shadowed = false }, + .{ .cell = on_page, .role = .body, .cursor = true, .dotted = false, .shadowed = false }, + .{ .cell = selected, .role = .body, .cursor = false, .dotted = false, .shadowed = false }, + .{ .cell = title, .role = .tagline, .cursor = false, .dotted = false, .shadowed = true }, + .{ .cell = title, .role = .tagline, .cursor = true, .dotted = false, .shadowed = false }, + .{ .cell = plain_tag, .role = .tagline, .cursor = false, .dotted = false, .shadowed = false }, + }) |case| { + emitInstance(&g, &instances, 0, 0, 0, fixedCellLayout(&g), 800, 480, null, case.role, (&case.cell)[0..1], 0, null, false, case.cursor, Ground.opaqueRgb(page)); + try std.testing.expectEqual(case.dotted, instances[0].effect & dotted_bit != 0); + try std.testing.expectEqual(case.shadowed, instances[0].effect & title_bit != 0); + } + // A theme without decor marks nothing. + chrome = .{ .page = page }; + cell_decor = CellDecor.of(&chrome); + emitInstance(&g, &instances, 0, 0, 0, fixedCellLayout(&g), 800, 480, null, .body, (&on_page)[0..1], 0, null, false, false, Ground.opaqueRgb(page)); + try std.testing.expectEqual(@as(u32, 0), instances[0].effect & (dotted_bit | title_bit)); +} + +test "lapis plaques stay in their tag's band, clear of every grip; only the focused one is striped" { + 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)); + try std.testing.expect(core.executeBuiltinLine(0, "Theme lapis")); + for (0..40) |_| core.update(.tick); + pardes.test_api.sync(core); + const s = try core.render(core.scratch.allocator()); + try std.testing.expect(s.chrome.decor_box_border != null and s.chrome.decor_checker != null and s.chrome.decor_dots != null and s.chrome.decor_title != null); + var g: Gui = undefined; + g.decor = .empty; + defer g.decor.deinit(gpa); + g.cell_w = 16; + g.cell_h = 32; + g.tagline_width = 12; + g.tagline_height = 22; + display_scale = 1; + var groups = makeGroups(&.{}, false); + try buildDecor(&g, gpa, s, &groups, fixedCellLayout(&g), 1280, 768, true); + const layout = fixedCellLayout(&g); + const under = g.decor.items[groups.items[0].decor_start..][0..groups.items[0].under_count]; + try std.testing.expect(under.len != 0); + var stripes: usize = 0; + var shadows: usize = 0; + for (under) |item| { + const r: [4]f32 = .{ (item.x0 + 1) / 2 * 1280, (1 - item.y0) / 2 * 768, (item.x1 + 1) / 2 * 1280, (1 - item.y1) / 2 * 768 }; + // Inside one tag's band, whole: never on a body, a grip or the + // next pane. + const home = for (s.regionList()) |region| { + if (region.kind != .tag and region.kind != .column_tag and region.kind != .workspace_tag) continue; + const band = plaqueBox(&g, &s.chrome, s.regionList(), region, layout, 1280, 768) orelse continue; + if (r[0] >= band.band[0] - 0.01 and r[1] >= band.band[1] - 0.01 and r[2] <= band.band[2] + 0.01 and r[3] <= band.band[3] + 0.01) break region; + } else return error.PlaqueOutsideItsBand; + for (s.regionList()) |grip| { + if (grip.kind != .grip and grip.kind != .column_grip) continue; + const gx0 = @as(f32, @floatFromInt(grip.rect.x)) * 16; + const gx1 = @as(f32, @floatFromInt(grip.rect.x + grip.rect.w)) * 16; + const gy0 = @as(f32, @floatFromInt(grip.rect.y)) * 32; + const gy1 = @as(f32, @floatFromInt(grip.rect.y + grip.rect.h)) * 32; + try std.testing.expect(r[2] <= gx0 + 0.01 or r[0] >= gx1 - 0.01 or r[3] <= gy0 + 0.01 or r[1] >= gy1 - 0.01); + } + if (item.effect & checker_bit != 0) { + try std.testing.expect(home.kind == .tag and home.active); + stripes += 1; + } + const rgb: [3]u8 = .{ @intFromFloat(@round(item.br * 255)), @intFromFloat(@round(item.bg * 255)), @intFromFloat(@round(item.bb * 255)) }; + if (std.mem.eql(u8, &rgb, &s.chrome.decor_box_shadow.?)) shadows += 1; + } + try std.testing.expectEqual(@as(usize, 1), stripes); + try std.testing.expect(shadows >= 2); + // The scroll track is chequered. + var checkers: usize = 0; + for (g.decor.items[groups.items[0].decor_start + groups.items[0].under_count ..]) |item| checkers += @intFromBool(item.effect & checker_bit != 0); + try std.testing.expect(checkers >= 1); + // No other theme draws any of it. + try std.testing.expect(core.executeBuiltinLine(0, "Theme forge")); + for (0..40) |_| core.update(.tick); + const plain = try core.render(core.scratch.allocator()); + try std.testing.expect(plain.chrome.decor_box_border == null and plain.chrome.decor_checker == null and plain.chrome.decor_dots == null and plain.chrome.decor_title == null); +} + fn cellCodepoint(cell: *const pardes.Cell) u32 { const grapheme = cell.grapheme(); return if (cell.default or grapheme.len == 0) ' ' else firstCp(grapheme); @@ -7352,6 +7512,20 @@ fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat, return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate; } +/// Premultiplied over: an opaque cell writes itself exactly, a see-through +/// one only its ink. +fn premultipliedBlend() c.SDL_GPUColorTargetBlendState { + var blend = std.mem.zeroes(c.SDL_GPUColorTargetBlendState); + blend.src_color_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE; + blend.dst_color_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA; + blend.color_blend_op = c.SDL_GPU_BLENDOP_ADD; + blend.src_alpha_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE; + blend.dst_alpha_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA; + blend.alpha_blend_op = c.SDL_GPU_BLENDOP_ADD; + blend.enable_blend = true; + return blend; +} + fn backgroundLayerBlend() c.SDL_GPUColorTargetBlendState { var blend = std.mem.zeroes(c.SDL_GPUColorTargetBlendState); blend.src_color_blendfactor = c.SDL_GPU_BLENDFACTOR_SRC_ALPHA; @@ -7742,6 +7916,29 @@ fn addDecor(g: *Gui, gpa: std.mem.Allocator, x0_in: f32, y0_in: f32, x1_in: f32, try g.decor.append(gpa, instance); } +/// A checker (decor.frag.glsl): squares of `px` window pixels in `a` and +/// `b` from the quad's corner (lapis's scroll track); `stripes`, bands of +/// `px` across it instead (lapis's focused title bar), `a` first. +fn addChecker(g: *Gui, gpa: std.mem.Allocator, x0: f32, y0: f32, x1: f32, y1: f32, a: [3]u8, b: [3]u8, px: f32, track: ?pardes.animation.Track, win_w: f32, win_h: f32) !void { + return addPattern(g, gpa, x0, y0, x1, y1, a, b, px, false, track, win_w, win_h); +} + +fn addPattern(g: *Gui, gpa: std.mem.Allocator, x0: f32, y0: f32, x1: f32, y1: f32, a: [3]u8, b: [3]u8, px: f32, stripes: bool, track: ?pardes.animation.Track, win_w: f32, win_h: f32) !void { + const first = g.decor.items.len; + try addDecor(g, gpa, x0, y0, x1, y1, a, 1, track, win_w, win_h); + if (g.decor.items.len == first) return; + const instance = &g.decor.items[first]; + instance.u0 = px; + instance.u1 = px; + instance.v0 = if (stripes) 1 else 0; + instance.v1 = if (stripes) 1 else 0; + instance.fr = @as(f32, @floatFromInt(b[0])) / 255.0; + instance.fg = @as(f32, @floatFromInt(b[1])) / 255.0; + instance.fb = @as(f32, @floatFromInt(b[2])) / 255.0; + instance.effect |= checker_bit; +} + + /// How a lift of 0 to 1 stands off the page: the shadow's blur and offset /// in cell heights, and its darkest, where it leaves the caster's edge. /// Within §8.2's budget: sigma 0.4-0.8 cell, at most 30% darker at the edge. @@ -8062,6 +8259,13 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g 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; + // Under the cells: a theme's tag plaques, this group's panes' (and + // the column and workspace tags with the still ones). + if (index <= groups.tracks) { + const closing = if (group.track) |active| active.phase == .closing else false; + try tagPlaques(g, gpa, if (closing) surface.previous_regions else regions, groups, index, chrome, layout, win_w, win_h, group.track); + } + group.under_count = @as(u32, @intCast(g.decor.items.len)) - group.decor_start; // The grips' buttons before any rule: the rules over their rows cut // them, and the band round them never covers a rule. if (index <= groups.tracks) { @@ -8226,7 +8430,10 @@ fn paneDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, gr // the track edges it. const column = scrollColumn(g, chrome, rail.rect.x, layout); try addDecor(g, gpa, x0, y0, x0 + layout.w, y1, page, 1, track, win_w, win_h); - try addDecor(g, gpa, column[0], y0, column[1], y1, chrome.scroll_track, 1, track, win_w, win_h); + if (chrome.decor_checker) |gold| + try addChecker(g, gpa, column[0], y0, column[1], y1, chrome.scroll_track, gold, dp(@floatFromInt(@max(1, chrome.decor_checker_px))), track, win_w, win_h) + else + try addDecor(g, gpa, column[0], y0, column[1], 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; @@ -8269,11 +8476,126 @@ fn updateDisplayScale(window: *c.SDL_Window) void { display_scale = std.math.clamp(display_scale, 1, 4); } +/// A theme's ornament as the cell shader draws it (Chrome.decor_*), for the +/// frame being built: renderFrame sets it, writing a cell marks the cells +/// it falls on, and pushLayerOpacity hands the shader its colours and +/// sizes. ponytail: one window per process, as display_scale. +var cell_decor: CellDecor = .{}; +const CellDecor = struct { + /// The page's ground, when the theme dots it. + dots: ?[3]u8 = null, + /// A file name's inks in a tag, when the theme shadows it. + title: ?[2][3]u8 = null, + /// UiUniforms after background_opacity (ui.frag.glsl): the dots' colour + /// and strength, their period and radii, the shadow's colour and offset. + uniforms: [12]f32 = @splat(0), + /// The tag plaques' bands (window pixels) and the tag grounds: a blank + /// tag cell there lets its plaque show through (tagPlaques). + plaques: [pardes.MAX_TAG_LAYERS][4]f32 = undefined, + nplaques: usize = 0, + grounds: [2][3]u8 = .{ .{ 0, 0, 0 }, .{ 0, 0, 0 } }, + plaques_off: bool = false, + + fn inPlaque(decor: *const CellDecor, x: f32, y: f32) bool { + for (decor.plaques[0..decor.nplaques]) |band| + if (x >= band[0] and x < band[2] and y >= band[1] and y < band[3]) return true; + return false; + } + + fn of(chrome: *const pardes.Chrome) CellDecor { + var out: CellDecor = .{}; + if (chrome.decor_dots) |rgb| if (chrome.page) |page| { + out.dots = page; + out.uniforms[0..4].* = .{ unit(rgb[0]), unit(rgb[1]), unit(rgb[2]), unit(chrome.decor_dot_alpha) }; + out.uniforms[4..8].* = .{ dp(@floatFromInt(@max(2, chrome.decor_dot_px))), 0.75 * display_scale, 1.25 * display_scale, 0 }; + }; + if (chrome.decor_title) |rgb| { + out.title = .{ chrome.name_ink, chrome.active_name_ink }; + out.uniforms[8..12].* = .{ unit(rgb[0]), unit(rgb[1]), unit(rgb[2]), dp(@floatFromInt(@max(1, chrome.decor_title_px))) }; + } + return out; + } + + fn unit(v: u8) f32 { + return @as(f32, @floatFromInt(v)) / 255; + } +}; + /// A logical pixel size in the display's pixels: rounded, at least one. fn dp(logical: f32) f32 { return @max(1, @round(logical * display_scale)); } +/// A theme's tag plaques (Chrome.decor_box_*), drawn under the cells of +/// group `index`: each pane tag, and with the still panes the column and +/// workspace tags, becomes a framed box inside its own band with a hard +/// shadow down and right that stays inside the band too, so it never falls +/// on a body, a grip or the next pane. The focused pane's box is striped, +/// its words on plates of the tag's ground (their cells stay opaque; the +/// spaces between let the stripes through, emitInstance); a column or the +/// workspace takes a quieter box, a one-pixel frame and a two-pixel shadow. +fn tagPlaques(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, groups: *const Groups, index: usize, chrome: *const pardes.Chrome, layout: CellLayout, win_w: f32, win_h: f32, track: ?pardes.animation.Track) !void { + const border_rgb = chrome.decor_box_border orelse return; + if (cell_decor.plaques_off) return; + const page = chrome.page orelse bg_default; + for (regions) |tag| { + const pane = tag.kind == .tag; + if (!pane and tag.kind != .column_tag and tag.kind != .workspace_tag) continue; + if (tag.rect.w == 0 or tag.rect.h == 0) continue; + if (pane and groupOf(groups, tag.serial) != index) continue; + if (!pane and index != 0) continue; + const plaque = plaqueBox(g, chrome, regions, tag, layout, win_w, win_h) orelse continue; + const b = plaque.border; + const sh = plaque.shadow; + const fill = if (tag.active) chrome.tag_focus_bg else chrome.tag_bg; + // The band's own ground, then the shadow, the frame and the fill. + try addDecor(g, gpa, plaque.band[0], plaque.band[1], plaque.band[2], plaque.band[3], page, 1, track, win_w, win_h); + const shadow_rgb = chrome.decor_box_shadow orelse border_rgb; + try addDecor(g, gpa, plaque.box[0] + sh[0], plaque.box[1] + sh[1], plaque.box[2] + sh[0], plaque.box[3] + sh[1], if (pane) shadow_rgb else mixRgb(shadow_rgb, page), 1, track, win_w, win_h); + try addDecor(g, gpa, plaque.box[0], plaque.box[1], plaque.box[2], plaque.box[3], border_rgb, 1, track, win_w, win_h); + const inner: [4]f32 = .{ plaque.box[0] + b, plaque.box[1] + b, plaque.box[2] - b, plaque.box[3] - b }; + if (pane and tag.active and chrome.decor_box_stripe != null) + try addPattern(g, gpa, inner[0], inner[1], inner[2], inner[3], chrome.decor_box_stripe.?, fill, dp(@floatFromInt(@max(1, chrome.decor_box_stripe_px))), true, track, win_w, win_h) + else + try addDecor(g, gpa, inner[0], inner[1], inner[2], inner[3], fill, 1, track, win_w, win_h); + } +} + +const Plaque = struct { band: [4]f32, box: [4]f32, border: f32, shadow: [2]f32 }; + +/// A tag's plaque in window pixels: its band (the rows, from after the +/// grip to the end), the framed box round the tagline's text, and the +/// frame's and the shadow's widths, the shadow cut to what the band's slack +/// below the text and its right end leave. Null where there is no room. +fn plaqueBox(g: *const Gui, chrome: *const pardes.Chrome, regions: []const pardes.Region, tag: pardes.Region, layout: CellLayout, win_w: f32, win_h: f32) ?Plaque { + const pane = tag.kind == .tag; + const first = tag.rect.y; + const last = tag.rect.y + tag.rect.h - 1; + // From its start, right of its grip: the gap cell before its text is + // the plaque's margin, so its frame is never under the first letter. + _ = regions; + const x0 = @as(f32, @floatFromInt(tag.rect.x)) * layout.w; + const x1_cells = @as(f32, @floatFromInt(tag.rect.x + tag.rect.w)) * layout.w; + const x1 = if (win_w - x1_cells < layout.w) @max(x1_cells, win_w) else x1_cells; + const row_top = @as(f32, @floatFromInt(first)) * layout.h; + const row_bottom = @as(f32, @floatFromInt(last + 1)) * layout.h; + const text_top = row_top + @as(f32, @floatFromInt(taglineBandOffset(first, win_h, g.cell_h, g.tagline_height))); + const text_bottom = @as(f32, @floatFromInt(last)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(last, win_h, g.cell_h, g.tagline_height) + g.tagline_height)); + const border = @min(if (pane) dp(@floatFromInt(@max(1, chrome.decor_box_border_px))) else dp(1), @floor(text_top - row_top)); + if (border < 1) return null; + // The shadow's full offset to the right; down, what the slack under the + // text leaves (a pixel or three at a tagline under the body's size). + const want: f32 = if (pane) dp(@floatFromInt(chrome.decor_box_shadow_px)) else dp(2); + const drop = std.math.clamp(@floor(row_bottom - text_bottom - border), 0, want); + if (x1 - x0 < 4 * border + want) return null; + return .{ + .band = .{ x0, row_top, x1, row_bottom }, + .box = .{ x0, text_top - border, x1 - want, text_bottom + border }, + .border = border, + .shadow = .{ want, drop }, + }; +} + /// 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, chrome: *const pardes.Chrome, layout: CellLayout, cols: u16, rows: u16, win_w: f32, win_h: f32, track: ?pardes.animation.Track) !void { @@ -8903,7 +9225,8 @@ fn uploadOverlayGpu(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, vertex_count: u32) bo } fn pushLayerOpacity(cmd: *c.SDL_GPUCommandBuffer, pass: ?*c.SDL_GPURenderPass, opacity: f32) void { - const uniforms = [4]f32{ opacity, 0, 0, 0 }; + var uniforms = [4]f32{ opacity, 0, 0, 0 } ++ @as([12]f32, @splat(0)); + uniforms[4..].* = cell_decor.uniforms; c.SDL_PushGPUFragmentUniformData(cmd, 0, &uniforms, @sizeOf(@TypeOf(uniforms))); // The source alpha is geometric/image coverage, not window opacity. Its // alpha contribution is multiplied by this constant separately, so a diff --git a/src/surface.zig b/src/surface.zig index 523cba43..5b850a8b 100644 --- a/src/surface.zig +++ b/src/surface.zig @@ -428,6 +428,26 @@ pub const Chrome = struct { /// The scroll column's width, button and scrollbar alike, in pixels at /// a 17px tagline (acme's 12 at its font); a pixel shell scales it. rail_px: u8 = 12, + /// The theme's ornament (colors.Decor), a pixel shell's alone: the dot + /// grid on the page, the scroll track's checker, the tags' plaques + /// (frame, shadow, the focused one's stripes) and the file name's shadow, each null when the theme has none, sizes + /// in logical pixels. `name_ink` and `active_name_ink` are the file + /// name's colours in a tag, which the shadow goes under. + decor_dots: ?[3]u8 = null, + decor_dot_alpha: u8 = 0, + decor_dot_px: u8 = 22, + decor_checker: ?[3]u8 = null, + decor_checker_px: u8 = 3, + decor_box_border: ?[3]u8 = null, + decor_box_border_px: u8 = 2, + decor_box_shadow: ?[3]u8 = null, + decor_box_shadow_px: u8 = 5, + decor_box_stripe: ?[3]u8 = null, + decor_box_stripe_px: u8 = 2, + decor_title: ?[3]u8 = null, + decor_title_px: u8 = 2, + name_ink: [3]u8 = .{ 0, 0, 0 }, + active_name_ink: [3]u8 = .{ 0, 0, 0 }, }; /// The canonical interface: what a frame of pardes IS. The tty shell writes diff --git a/src/themes/lapis.zig b/src/themes/lapis.zig new file mode 100644 index 00000000..46623fc2 --- /dev/null +++ b/src/themes/lapis.zig @@ -0,0 +1,92 @@ +//! Lapis: 0x4200.cafe's illuminated manuscript (web/theme/lapis.css) as a +//! pardes theme. Ultramarine vellum, gold leaf and vermilion rubrics: the +//! page is lapis-deep, the tags lapis with their names in gold, the text +//! vellum. The site's ornament comes with it as the theme's `decor`, drawn +//! inside the existing chrome (docs/render-pipeline.md §10, decision 1): +//! a gold dot grid on the page under the pane bodies, a gold and lapis +//! checker in the scroll column, every tag a gold-framed plaque with the +//! trail's hard vermilion shadow (inside its own band: in a tiled layout a +//! shadow past it would fall on the next pane or its grip, and no ornament +//! may cover a focus indicator), the focused one striped like the code +//! window's title bar, and the file name's vermilion offset shadow. +pub const theme = .{ + .name = "lapis", + // --lapis-deep, --vellum + .bg = .{ 0x0a, 0x10, 0x30 }, + .fg = .{ 0xee, 0xe6, 0xd2 }, + // A gold highlight in lapis ink, as the directory's current entry. + .sel_bg = .{ 0xe8, 0xc4, 0x6a }, + .sel_fg = .{ 0x0f, 0x1a, 0x4a }, + // --lapis, --vellum-dim + .tag_bg = .{ 0x0f, 0x1a, 0x4a }, + .tag_fg = .{ 0x9a, 0xa6, 0xd9 }, + .tag_name_fg = .{ 0xe8, 0xc4, 0x6a }, + .tag_active_name_fg = .{ 0xf2, 0xd2, 0x80 }, + // The plaques' hover lapis, taken a step further: 1.5:1 off the tag. + .tag_active_bg = .{ 0x25, 0x39, 0x7d }, + .tag_active_fg = .{ 0xee, 0xe6, 0xd2 }, + .box = .{ 0xe8, 0xc4, 0x6a }, + .box_dim = .{ 0x3a, 0x4a, 0x8a }, + .box_border = .{ 0x9a, 0xa6, 0xd9 }, + .box_dirty = .{ 0xff, 0x6a, 0x4a }, + .column_box = .{ 0xff, 0x6a, 0x4a }, + .column_box_dim = .{ 0x8a, 0x3a, 0x3a }, + // Rubrics: vermilion keywords, gilded strings, the code window's green + // for numbers, vellum-dim comments. + .kw = .{ 0xff, 0x6a, 0x4a }, + .str = .{ 0xe8, 0xc4, 0x6a }, + .num = .{ 0xe7, 0xff, 0xe0 }, + .comment = .{ 0x9a, 0xa6, 0xd9 }, + .lineno = .{ 0x5a, 0x6a, 0xa8 }, + .lineno_active = .{ 0x6a, 0x7a, 0xb8 }, + // --lapis-ink under a gold thumb (the page's scrollbar-color). + .scroll_track = .{ 0x05, 0x0a, 0x24 }, + .scroll_thumb = .{ 0xe8, 0xc4, 0x6a }, + // Gold rules, a vermilion hairline between a tag and its text. + .border = .{ 0xe8, 0xc4, 0x6a }, + // An empty column is the page, not a slab of gold leaf. + .empty_col = .{ 0x0a, 0x10, 0x30 }, + .tag_rule = .{ 0xd4, 0x41, 0x2f }, + .search_bg = .{ 0x6a, 0x2a, 0x1c }, + .search_fg = .{ 0xff, 0xd0, 0xb0 }, + .diagnostic_error = .{ 0xff, 0x6a, 0x4a }, + .diagnostic_warning = .{ 0xe8, 0xc4, 0x6a }, + .diagnostic_info = .{ 0x9a, 0xc6, 0xff }, + .diagnostic_hint = .{ 0xa8, 0xe0, 0xc0 }, + .decor = .{ + .page_dots = .{ 0xe8, 0xc4, 0x6a }, + // #e8c46a22 on the site: a dot a pixel and a half across every 22. + .page_dot_alpha = 0x22, + .page_dot_px = 22, + .rail_checker = .{ 0xe8, 0xc4, 0x6a }, + .rail_checker_px = 3, + // The trail: a 2px gold frame and a 5px vermilion shadow; the code + // window's title bar: gold and lapis stripes of 2px. + .tag_border = .{ 0xe8, 0xc4, 0x6a }, + .tag_border_px = 2, + .tag_shadow = .{ 0xd4, 0x41, 0x2f }, + .tag_shadow_px = 5, + .tag_stripe = .{ 0xe8, 0xc4, 0x6a }, + .tag_stripe_px = 2, + .title_shadow = .{ 0xd4, 0x41, 0x2f }, + .title_shadow_px = 2, + }, + .palette = .{ + .{ 0x0a, 0x10, 0x30 }, + .{ 0xff, 0x6a, 0x4a }, + .{ 0xa8, 0xe0, 0xc0 }, + .{ 0xe8, 0xc4, 0x6a }, + .{ 0x9a, 0xc6, 0xff }, + .{ 0xd8, 0xa0, 0xe8 }, + .{ 0x8a, 0xd8, 0xd8 }, + .{ 0xee, 0xe6, 0xd2 }, + .{ 0x9a, 0xa6, 0xd9 }, + .{ 0xff, 0x9a, 0x6a }, + .{ 0xc8, 0xf0, 0xd8 }, + .{ 0xff, 0xde, 0x90 }, + .{ 0xc0, 0xdc, 0xff }, + .{ 0xe8, 0xc0, 0xf4 }, + .{ 0xb0, 0xec, 0xec }, + .{ 0xff, 0xf8, 0xe8 }, + }, +}; diff --git a/test/snapshots/theme.golden b/test/snapshots/theme.golden index 4acf715d..a6bd6e60 100644 --- a/test/snapshots/theme.golden +++ b/test/snapshots/theme.golden @@ -645,34 +645,34 @@ |30: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, == snap generated grid=100x31 cursor=4,6 == style generated grid=100x31 -|0: 0-50 #e0e0e0,#1e262e, 51-59 #e0e0e0,#303840, 60-70 #e0e0e0,#1e262e, 71-99 d,#1e262e, -|1: 0-1 #ffffff,#4f7b96, 2-2 d,#1e262e, 3-34 #e0e0e0,#1e262e, 35-49 d,#1e262e, 50-51 #ffffff,#4f7b96, 52-52 d,#1e262e, 53-84 #e0e0e0,#1e262e, 85-99 d,#1e262e, -|2: 0-1 #000000,#ffa060, 2-2 d,#1e262e, 3-49 #e0e0e0,#1e262e, 50-51 #ffffff,#303840, 52-52 d,#1e262e, 53-99 #e0e0e0,#1e262e, -|3: 0-2 d,#1e262e, 3-20 #e0e0e0,#1e262e, 21-52 d,#1e262e, 53-70 #e0e0e0,#1e262e, 71-99 d,#1e262e, -|4: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|5: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|6: 0-0 d,#303840, 1-49 d,#161e26, 50-50 d,#303840, 51-99 d,#161e26, -|7: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|8: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|9: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|10: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|11: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|12: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|13: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|14: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|15: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|16: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|17: 0-1 #ffffff,#303840, 2-2 d,#1e262e, 3-49 #e0e0e0,#1e262e, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|18: 0-2 d,#1e262e, 3-20 #e0e0e0,#1e262e, 21-49 d,#1e262e, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|19: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|20: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|21: 0-0 d,#303840, 1-49 d,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|22: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|23: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|24: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|25: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|26: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|27: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|28: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|29: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|30: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, +|0: 0-50 #9aa6d9,#0f1a4a, 51-59 #0f1a4a,#e8c46a, 60-70 #9aa6d9,#0f1a4a, 71-99 d,#0f1a4a, +|1: 0-1 #ffffff,#8a3a3a, 2-2 d,#25397d, 3-34 #eee6d2,#25397d, 35-49 d,#25397d, 50-51 #ffffff,#8a3a3a, 52-52 d,#0f1a4a, 53-84 #9aa6d9,#0f1a4a, 85-99 d,#0f1a4a, +|2: 0-1 #000000,#e8c46a, 2-2 d,#25397d, 3-29 #eee6d2,#25397d, 30-37 #f2d280,#25397d, 38-49 #eee6d2,#25397d, 50-51 #ffffff,#6a78b1, 52-52 d,#0f1a4a, 53-79 #9aa6d9,#0f1a4a, 80-87 #e8c46a,#0f1a4a, 88-99 #9aa6d9,#0f1a4a, +|3: 0-2 d,#25397d, 3-20 #eee6d2,#25397d, 21-49 d,#25397d, 50-52 d,#0f1a4a, 53-70 #9aa6d9,#0f1a4a, 71-99 d,#0f1a4a, +|4: 0-0 d,#e8c46a, 1-1 d,#0a1030, 2-49 #eee6d2,#0a1030, 50-50 d,#e8c46a, 51-51 d,#0a1030, 52-99 #eee6d2,#0a1030, +|5: 0-0 d,#e8c46a, 1-1 d,#0a1030, 2-49 #eee6d2,#0a1030, 50-50 d,#e8c46a, 51-51 d,#0a1030, 52-99 #eee6d2,#0a1030, +|6: 0-0 d,#e8c46a, 1-49 d,#0a1030, 50-50 d,#e8c46a, 51-99 d,#0a1030, +|7: 0-0 d,#e8c46a, 1-1 d,#0a1030, 2-49 #eee6d2,#0a1030, 50-50 d,#e8c46a, 51-51 d,#0a1030, 52-99 #eee6d2,#0a1030, +|8: 0-0 d,#e8c46a, 1-1 d,#0a1030, 2-49 #eee6d2,#0a1030, 50-50 d,#e8c46a, 51-51 d,#0a1030, 52-99 #eee6d2,#0a1030, +|9: 0-0 d,#e8c46a, 1-1 d,#0a1030, 2-49 #eee6d2,#0a1030, 50-50 d,#e8c46a, 51-51 d,#0a1030, 52-99 #eee6d2,#0a1030, +|10: 0-0 d,#e8c46a, 1-1 d,#0a1030, 2-49 #eee6d2,#0a1030, 50-50 d,#e8c46a, 51-51 d,#0a1030, 52-99 #eee6d2,#0a1030, +|11: 0-0 d,#e8c46a, 1-1 d,#0a1030, 2-49 #eee6d2,#0a1030, 50-50 d,#e8c46a, 51-51 d,#0a1030, 52-99 #eee6d2,#0a1030, +|12: 0-0 d,#e8c46a, 1-1 d,#0a1030, 2-49 #eee6d2,#0a1030, 50-50 d,#e8c46a, 51-51 d,#0a1030, 52-99 #eee6d2,#0a1030, +|13: 0-0 d,#e8c46a, 1-1 d,#0a1030, 2-49 #eee6d2,#0a1030, 50-50 d,#e8c46a, 51-51 d,#0a1030, 52-99 #eee6d2,#0a1030, +|14: 0-0 d,#e8c46a, 1-1 d,#0a1030, 2-49 #eee6d2,#0a1030, 50-50 d,#e8c46a, 51-51 d,#0a1030, 52-99 #eee6d2,#0a1030, +|15: 0-0 d,#e8c46a, 1-1 d,#0a1030, 2-49 #eee6d2,#0a1030, 50-50 d,#e8c46a, 51-51 d,#0a1030, 52-99 #eee6d2,#0a1030, +|16: 0-0 d,#e8c46a, 1-1 d,#0a1030, 2-49 #eee6d2,#0a1030, 50-50 d,#e8c46a, 51-51 d,#0a1030, 52-99 #eee6d2,#0a1030, +|17: 0-1 #ffffff,#6a78b1, 2-2 d,#0f1a4a, 3-29 #9aa6d9,#0f1a4a, 30-37 #e8c46a,#0f1a4a, 38-49 #9aa6d9,#0f1a4a, 50-50 d,#e8c46a, 51-51 d,#0a1030, 52-99 #eee6d2,#0a1030, +|18: 0-2 d,#0f1a4a, 3-20 #9aa6d9,#0f1a4a, 21-49 d,#0f1a4a, 50-50 d,#e8c46a, 51-51 d,#0a1030, 52-99 #eee6d2,#0a1030, +|19: 0-0 d,#e8c46a, 1-1 d,#0a1030, 2-49 #eee6d2,#0a1030, 50-50 d,#e8c46a, 51-51 d,#0a1030, 52-99 #eee6d2,#0a1030, +|20: 0-0 d,#e8c46a, 1-1 d,#0a1030, 2-49 #eee6d2,#0a1030, 50-50 d,#e8c46a, 51-51 d,#0a1030, 52-99 #eee6d2,#0a1030, +|21: 0-0 d,#e8c46a, 1-49 d,#0a1030, 50-50 d,#e8c46a, 51-51 d,#0a1030, 52-99 #eee6d2,#0a1030, +|22: 0-0 d,#e8c46a, 1-1 d,#0a1030, 2-49 #eee6d2,#0a1030, 50-50 d,#e8c46a, 51-51 d,#0a1030, 52-99 #eee6d2,#0a1030, +|23: 0-0 d,#e8c46a, 1-1 d,#0a1030, 2-49 #eee6d2,#0a1030, 50-50 d,#e8c46a, 51-51 d,#0a1030, 52-99 #eee6d2,#0a1030, +|24: 0-0 d,#e8c46a, 1-1 d,#0a1030, 2-49 #eee6d2,#0a1030, 50-50 d,#e8c46a, 51-51 d,#0a1030, 52-99 #eee6d2,#0a1030, +|25: 0-0 d,#e8c46a, 1-1 d,#0a1030, 2-49 #eee6d2,#0a1030, 50-50 d,#e8c46a, 51-51 d,#0a1030, 52-99 #eee6d2,#0a1030, +|26: 0-0 d,#e8c46a, 1-1 d,#0a1030, 2-49 #eee6d2,#0a1030, 50-50 d,#e8c46a, 51-51 d,#0a1030, 52-99 #eee6d2,#0a1030, +|27: 0-0 d,#e8c46a, 1-1 d,#0a1030, 2-49 #eee6d2,#0a1030, 50-50 d,#e8c46a, 51-51 d,#0a1030, 52-99 #eee6d2,#0a1030, +|28: 0-0 d,#e8c46a, 1-1 d,#0a1030, 2-49 #eee6d2,#0a1030, 50-50 d,#e8c46a, 51-51 d,#0a1030, 52-99 #eee6d2,#0a1030, +|29: 0-0 d,#e8c46a, 1-1 d,#0a1030, 2-49 #eee6d2,#0a1030, 50-50 d,#e8c46a, 51-51 d,#0a1030, 52-99 #eee6d2,#0a1030, +|30: 0-0 d,#e8c46a, 1-1 d,#0a1030, 2-49 #eee6d2,#0a1030, 50-50 d,#e8c46a, 51-51 d,#0a1030, 52-99 #eee6d2,#0a1030, diff --git a/test/snapshots/themesel.golden b/test/snapshots/themesel.golden index 455808ab..8d453978 100644 --- a/test/snapshots/themesel.golden +++ b/test/snapshots/themesel.golden @@ -529,7 +529,7 @@ |27: 19 Theme helix |28: 20 Theme dark |29: 21 Theme acme -|30: 22 Theme acid +|30: 22 Theme lapis == style step-helix grid=100x31 |0: 0-70 #949494,#121212, 71-99 d,#121212, |1: 0-1 #ffffff,#826f46, 2-2 d,#3a3a3a, 3-34 #bdbdbd,#3a3a3a, 35-99 d,#3a3a3a, @@ -561,7 +561,7 @@ |27: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808,b 7-17 #bdbdbd,#080808, 18-99 d,#080808, |28: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-16 #bdbdbd,#080808, 17-99 d,#080808, |29: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-16 #bdbdbd,#080808, 17-99 d,#080808, -|30: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-16 #bdbdbd,#080808, 17-99 d,#080808, +|30: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-17 #bdbdbd,#080808, 18-99 d,#080808, == snap step-dark grid=100x31 cursor=7,27 |17: 10 Theme solarium Theme |18: 11 Theme spectrum @@ -575,8 +575,8 @@ |26: 19 Theme helix |27: 20 Theme dark |28: 21 Theme acme -|29: 22 Theme acid -|30: 23 Theme acme_helix +|29: 22 Theme lapis +|30: 23 Theme acid == style step-dark grid=100x31 |0: 0-70 #969696,#2c2a3e, 71-99 d,#2c2a3e, |1: 0-1 #ffffff,#867a4e, 2-2 d,#484566, 3-34 #c8c8c8,#484566, 35-99 d,#484566, @@ -621,9 +621,9 @@ |25: 19 Theme helix |26: 20 Theme dark |27: 21 Theme acme -|28: 22 Theme acid -|29: 23 Theme acme_helix -|30: 24 Theme adwaita_dark +|28: 22 Theme lapis +|29: 23 Theme acid +|30: 24 Theme acme_helix == style step-acme grid=100x31 |0: 0-70 #000000,#eaffff, 71-99 d,#eaffff, |1: 0-1 #000000,#8888cc, 2-2 d,#eaffff, 3-34 #000000,#eaffff, 35-99 d,#eaffff, @@ -653,9 +653,9 @@ |25: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-17 #000000,#ffffea, 18-99 d,#ffffea, |26: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-16 #000000,#ffffea, 17-99 d,#ffffea, |27: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea,b 7-16 #000000,#ffffea, 17-99 d,#ffffea, -|28: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-16 #000000,#ffffea, 17-99 d,#ffffea, -|29: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-22 #000000,#ffffea, 23-99 d,#ffffea, -|30: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-24 #000000,#ffffea, 25-99 d,#ffffea, +|28: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-17 #000000,#ffffea, 18-99 d,#ffffea, +|29: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-16 #000000,#ffffea, 17-99 d,#ffffea, +|30: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-22 #000000,#ffffea, 23-99 d,#ffffea, == snap step-generated grid=100x31 cursor=7,27 |17: 12 Theme harvest Theme |18: 13 Theme clay @@ -667,42 +667,42 @@ |24: 19 Theme helix |25: 20 Theme dark |26: 21 Theme acme -|27: 22 Theme acid -|28: 23 Theme acme_helix -|29: 24 Theme adwaita_dark -|30: 25 Theme adwaita_light +|27: 22 Theme lapis +|28: 23 Theme acid +|29: 24 Theme acme_helix +|30: 25 Theme adwaita_dark == style step-generated grid=100x31 -|0: 0-70 #e0e0e0,#1e262e, 71-99 d,#1e262e, -|1: 0-1 #ffffff,#4f7b96, 2-2 d,#1e262e, 3-34 #e0e0e0,#1e262e, 35-99 d,#1e262e, -|2: 0-1 #ffffff,#303840, 2-2 d,#1e262e, 3-59 #e0e0e0,#1e262e, 60-99 d,#1e262e, -|3: 0-0 d,#303840, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-12 #e0e0e0,#161e26, 13-99 d,#161e26, -|4: 0-0 d,#303840, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-12 #e0e0e0,#161e26, 13-99 d,#161e26, -|5: 0-0 d,#303840, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-12 #e0e0e0,#161e26, 13-99 d,#161e26, -|6: 0-0 d,#303840, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-12 #e0e0e0,#161e26, 13-99 d,#161e26, -|7: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-12 #e0e0e0,#161e26, 13-99 d,#161e26, -|8: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-12 #e0e0e0,#161e26, 13-99 d,#161e26, -|9: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-12 #e0e0e0,#161e26, 13-99 d,#161e26, -|10: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-12 #e0e0e0,#161e26, 13-99 d,#161e26, -|11: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-12 #e0e0e0,#161e26, 13-99 d,#161e26, -|12: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-13 #e0e0e0,#161e26, 14-99 d,#161e26, -|13: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-13 #e0e0e0,#161e26, 14-99 d,#161e26, -|14: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-13 #e0e0e0,#161e26, 14-99 d,#161e26, -|15: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-13 #e0e0e0,#161e26, 14-99 d,#161e26, -|16: 0-1 #000000,#ffa060, 2-2 d,#1e262e, 3-61 #e0e0e0,#1e262e, 62-99 d,#1e262e, -|17: 0-0 d,#303840, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-19 #e0e0e0,#161e26, 20-92 d,#161e26, 93-93 d,#1e262e, 94-98 #e0e0e0,#1e262e, 99-99 d,#1e262e, -|18: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-16 #e0e0e0,#161e26, 17-99 d,#161e26, -|19: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-23 #e0e0e0,#161e26, 24-99 d,#161e26, -|20: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-22 #e0e0e0,#161e26, 23-99 d,#161e26, -|21: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-25 #e0e0e0,#161e26, 26-99 d,#161e26, -|22: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-99 d,#161e26, -|23: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-34 #e0e0e0,#161e26, 35-99 d,#161e26, -|24: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-17 #e0e0e0,#161e26, 18-99 d,#161e26, -|25: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-16 #e0e0e0,#161e26, 17-99 d,#161e26, -|26: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-16 #e0e0e0,#161e26, 17-99 d,#161e26, -|27: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26,b 7-16 #e0e0e0,#161e26, 17-99 d,#161e26, -|28: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-22 #e0e0e0,#161e26, 23-99 d,#161e26, -|29: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-24 #e0e0e0,#161e26, 25-99 d,#161e26, -|30: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-25 #e0e0e0,#161e26, 26-99 d,#161e26, +|0: 0-70 #9aa6d9,#0f1a4a, 71-99 d,#0f1a4a, +|1: 0-1 #ffffff,#8a3a3a, 2-2 d,#25397d, 3-34 #eee6d2,#25397d, 35-99 d,#25397d, +|2: 0-1 #ffffff,#6a78b1, 2-2 d,#0f1a4a, 3-32 #9aa6d9,#0f1a4a, 33-37 #e8c46a,#0f1a4a, 38-59 #9aa6d9,#0f1a4a, 60-99 d,#0f1a4a, +|3: 0-0 d,#e8c46a, 1-1 d,#0a1030, 2-6 #5a6aa8,#0a1030, 7-12 #eee6d2,#0a1030, 13-99 d,#0a1030, +|4: 0-0 d,#e8c46a, 1-1 d,#0a1030, 2-6 #5a6aa8,#0a1030, 7-12 #eee6d2,#0a1030, 13-99 d,#0a1030, +|5: 0-0 d,#e8c46a, 1-1 d,#0a1030, 2-6 #5a6aa8,#0a1030, 7-12 #eee6d2,#0a1030, 13-99 d,#0a1030, +|6: 0-0 d,#e8c46a, 1-1 d,#0a1030, 2-6 #5a6aa8,#0a1030, 7-12 #eee6d2,#0a1030, 13-99 d,#0a1030, +|7: 0-0 d,#050a24, 1-1 d,#0a1030, 2-6 #5a6aa8,#0a1030, 7-12 #eee6d2,#0a1030, 13-99 d,#0a1030, +|8: 0-0 d,#050a24, 1-1 d,#0a1030, 2-6 #5a6aa8,#0a1030, 7-12 #eee6d2,#0a1030, 13-99 d,#0a1030, +|9: 0-0 d,#050a24, 1-1 d,#0a1030, 2-6 #5a6aa8,#0a1030, 7-12 #eee6d2,#0a1030, 13-99 d,#0a1030, +|10: 0-0 d,#050a24, 1-1 d,#0a1030, 2-6 #5a6aa8,#0a1030, 7-12 #eee6d2,#0a1030, 13-99 d,#0a1030, +|11: 0-0 d,#050a24, 1-1 d,#0a1030, 2-6 #5a6aa8,#0a1030, 7-12 #eee6d2,#0a1030, 13-99 d,#0a1030, +|12: 0-0 d,#050a24, 1-1 d,#0a1030, 2-6 #5a6aa8,#0a1030, 7-13 #eee6d2,#0a1030, 14-99 d,#0a1030, +|13: 0-0 d,#050a24, 1-1 d,#0a1030, 2-6 #5a6aa8,#0a1030, 7-13 #eee6d2,#0a1030, 14-99 d,#0a1030, +|14: 0-0 d,#050a24, 1-1 d,#0a1030, 2-6 #5a6aa8,#0a1030, 7-13 #eee6d2,#0a1030, 14-99 d,#0a1030, +|15: 0-0 d,#050a24, 1-1 d,#0a1030, 2-6 #5a6aa8,#0a1030, 7-13 #eee6d2,#0a1030, 14-99 d,#0a1030, +|16: 0-1 #000000,#e8c46a, 2-2 d,#25397d, 3-32 #eee6d2,#25397d, 33-39 #f2d280,#25397d, 40-61 #eee6d2,#25397d, 62-99 d,#25397d, +|17: 0-0 d,#e8c46a, 1-1 d,#0a1030, 2-6 #5a6aa8,#0a1030, 7-19 #eee6d2,#0a1030, 20-92 d,#0a1030, 93-93 d,#0f1a4a, 94-98 #9aa6d9,#0f1a4a, 99-99 d,#0f1a4a, +|18: 0-0 d,#050a24, 1-1 d,#0a1030, 2-6 #5a6aa8,#0a1030, 7-16 #eee6d2,#0a1030, 17-99 d,#0a1030, +|19: 0-0 d,#050a24, 1-1 d,#0a1030, 2-6 #5a6aa8,#0a1030, 7-23 #eee6d2,#0a1030, 24-99 d,#0a1030, +|20: 0-0 d,#050a24, 1-1 d,#0a1030, 2-6 #5a6aa8,#0a1030, 7-22 #eee6d2,#0a1030, 23-99 d,#0a1030, +|21: 0-0 d,#050a24, 1-1 d,#0a1030, 2-6 #5a6aa8,#0a1030, 7-25 #eee6d2,#0a1030, 26-99 d,#0a1030, +|22: 0-0 d,#050a24, 1-1 d,#0a1030, 2-6 #5a6aa8,#0a1030, 7-99 d,#0a1030, +|23: 0-0 d,#050a24, 1-1 d,#0a1030, 2-6 #5a6aa8,#0a1030, 7-34 #eee6d2,#0a1030, 35-99 d,#0a1030, +|24: 0-0 d,#050a24, 1-1 d,#0a1030, 2-6 #5a6aa8,#0a1030, 7-17 #eee6d2,#0a1030, 18-99 d,#0a1030, +|25: 0-0 d,#050a24, 1-1 d,#0a1030, 2-6 #5a6aa8,#0a1030, 7-16 #eee6d2,#0a1030, 17-99 d,#0a1030, +|26: 0-0 d,#050a24, 1-1 d,#0a1030, 2-6 #5a6aa8,#0a1030, 7-16 #eee6d2,#0a1030, 17-99 d,#0a1030, +|27: 0-0 d,#050a24, 1-1 d,#0a1030, 2-6 #6a7ab8,#0a1030,b 7-17 #eee6d2,#0a1030, 18-99 d,#0a1030, +|28: 0-0 d,#050a24, 1-1 d,#0a1030, 2-6 #5a6aa8,#0a1030, 7-16 #eee6d2,#0a1030, 17-99 d,#0a1030, +|29: 0-0 d,#050a24, 1-1 d,#0a1030, 2-6 #5a6aa8,#0a1030, 7-22 #eee6d2,#0a1030, 23-99 d,#0a1030, +|30: 0-0 d,#050a24, 1-1 d,#0a1030, 2-6 #5a6aa8,#0a1030, 7-24 #eee6d2,#0a1030, 25-99 d,#0a1030, == snap step-back grid=100x31 cursor=7,26 == style step-back grid=100x31 |0: 0-70 #000000,#eaffff, 71-99 d,#eaffff, @@ -732,25 +732,25 @@ |24: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-17 #000000,#ffffea, 18-99 d,#ffffea, |25: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-16 #000000,#ffffea, 17-99 d,#ffffea, |26: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea,b 7-16 #000000,#ffffea, 17-99 d,#ffffea, -|27: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-16 #000000,#ffffea, 17-99 d,#ffffea, -|28: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-22 #000000,#ffffea, 23-99 d,#ffffea, -|29: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-24 #000000,#ffffea, 25-99 d,#ffffea, -|30: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-25 #000000,#ffffea, 26-99 d,#ffffea, +|27: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-17 #000000,#ffffea, 18-99 d,#ffffea, +|28: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-16 #000000,#ffffea, 17-99 d,#ffffea, +|29: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-22 #000000,#ffffea, 23-99 d,#ffffea, +|30: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-24 #000000,#ffffea, 25-99 d,#ffffea, == snap tail grid=100x31 cursor=7,29 -|17: 234 Theme wolf_alabaster_dark -|18: 235 Theme wolf_alabaster_dark_bg -|19: 236 Theme wolf_alabaster_dark_mono -|20: 237 Theme wolf_alabaster_light -|21: 238 Theme wolf_alabaster_light_bg -|22: 239 Theme wolf_alabaster_light_mono -|23: 240 Theme yellowed -|24: 241 Theme yo -|25: 242 Theme yo_berry -|26: 243 Theme yo_light -|27: 244 Theme zed_onedark -|28: 245 Theme zed_onelight -|29: 246 Theme zenburn -|30: 247 +|17: 235 Theme wolf_alabaster_dark +|18: 236 Theme wolf_alabaster_dark_bg +|19: 237 Theme wolf_alabaster_dark_mono +|20: 238 Theme wolf_alabaster_light +|21: 239 Theme wolf_alabaster_light_bg +|22: 240 Theme wolf_alabaster_light_mono +|23: 241 Theme yellowed +|24: 242 Theme yo +|25: 243 Theme yo_berry +|26: 244 Theme yo_light +|27: 245 Theme zed_onedark +|28: 246 Theme zed_onelight +|29: 247 Theme zenburn +|30: 248 == snap picked grid=100x31 cursor=7,3 |16: 14 line 14 |17: 15 line 15 -- cgit v1.3 From ff252345bc3e1edc1aee93ca826f294bf9a7874f Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 06:41:28 -0300 Subject: G6: a wheel's notch scrolls the core at once and the picture glides after it A mouse wheel's notch (SDL's whole-number step, notchOf) scrolls the core by its lines at once, so keys and clicks act where the text is, and the picture follows on the Motion flavour's spring at half again its pace, critically damped whatever the flavour (text never passes its mark), carried from its current speed (animation.Spring.carry), so notches in a row flow on. How far the rows moved is measured on the next frame against the last (Glide.measure: a wrapped line's rows, a terminal's output, with a couple of rows' slack for the cursor's line and its number); the rows that left are kept, nearest first, and drawn in the gap the lag opens; the lag is never more than the body's height. A touchpad's fractions move the picture under the finger as before; `Motion off` lands a notch at once; a PDF page scrolls as before. The glide runs on the core's continuous path: Pardes.shell_continuous keeps frames coming every loop while it moves and carries a virtual clock with it; idle, nothing. The test feed takes wheel events (ESC]777;mouse;wheel;;;BEL). Tests: notch vs fraction; a carried spring keeps its speed and never passes its mark; a notch over two-row lines lags two rows, keeps the rows that left, settles frame by frame and leaves the continuous path, and lands at once with Motion off. Gates as G5. Feel review: .scratch/render/scroll/glide-{smooth,crisp,bouncy,off}{,-quarter}.mp4 and strips (a notch down, three, then two up). --- docs/effects.md | 18 +++ src/animation.zig | 26 ++++ src/gui/gui.zig | 381 +++++++++++++++++++++++++++++++++++++++++++++++++++++- src/pardes.zig | 6 +- 4 files changed, 424 insertions(+), 7 deletions(-) (limited to 'docs') diff --git a/docs/effects.md b/docs/effects.md index 8a60d116..04757aca 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -39,6 +39,7 @@ why. The verdict is the user's. | G3 cursor | follows `Motion`; `CursorBlink on\|off` (on) | glide on by default through the default flavour (smooth); blink on (decision 6) | .scratch/render/cursor/cursor-flavours-grid.mp4 (and quarter speed), raw/// | pending | | G4 bloom, vignette, grain | `Bloom`, `Vignette`, `Grain` `0..3` (off) | bundled post passes; none touches a tag, grip, notice or the cursor (pardesSpare); each capped per theme (pardesCap) | .scratch/render/{bloom,vignette,grain}/ (levels 0-3 on forge, dusk, acme; crops; mp4 stepping through the levels) | pending | | G5 lapis | `Theme lapis` (its `decor`) | a native theme with ornament: tag plaques with hard shadows inside their bands (the focused one striped), dots, rail checker, title shadow | .scratch/render/lapis/ (whole window at 1x and 2x, crops, a slide mp4) | pending | +| G6 smooth scroll | follows `Motion` (on through the default smooth; `Motion off` lands at once) | a mouse wheel's notch glides, a touchpad follows the finger | .scratch/render/scroll/ (glide-.mp4, quarter speed, strips) | pending | ## Motion flavours @@ -143,3 +144,20 @@ file name's letters. Left out: Crimson Pro and Departure Mono, the ornaments, a decor crossfade (it snaps while the colours fade). The tty shows the colours alone: a tag row abuts its body and its neighbour, so there is no cell for a shadow row or column. + +Notes on G6: a mouse wheel's notch (SDL's whole-number step) scrolls the +core by its lines at once, so keys and clicks act where the text is, and +the picture follows on the Motion flavour's spring at half again its pace, +critically damped whatever the flavour (text never passes its mark; §8.1), +from its current speed, so notches in a row carry on without a stall. How +far the rows moved is measured on the next frame against the last (a +wrapped line's rows, a terminal's output), within a couple of rows' slack +for the cursor's line and its number moving with the scroll; the rows that +left are kept and drawn in the gap the lag opens, and a lag is never more +than the body's height (a flick past that jumps). A touchpad's fractions +keep moving the picture under the finger, as before; there is no momentum +(SDL reports no finger lift on a wheel). The glide runs on the core's +continuous path (Pardes.shell_continuous), so the shell draws every loop +while it moves and a virtual clock moves with it; idle, it costs nothing. +A PDF page scrolls as before. The test feed takes wheel events +(ESC]777;mouse;wheel;;;BEL). diff --git a/src/animation.zig b/src/animation.zig index 9002a2c6..12d352d1 100644 --- a/src/animation.zig +++ b/src/animation.zig @@ -744,6 +744,14 @@ pub const Spring = struct { return spring.at(now_ns).value; } + /// Moves what it shows by `by` at `now_ns`, its speed kept, toward the + /// same target (a scroll's picture falling behind the text it follows), + /// at `motion`'s pace, never past its mark. + pub fn carry(spring: *Spring, by: f32, now_ns: u64, motion: Motion) void { + const state = spring.at(now_ns); + spring.* = .{ .omega = motion.omega, .zeta = 1, .target = spring.target, .from = state.value + by, .velocity = state.velocity, .from_ns = now_ns, .settled = false }; + } + /// Heads for `target` from wherever it is at `now_ns`, moving as it was, /// at the pace and damping `motion` gives: at once when it is `off`. pub fn retarget(spring: *Spring, target: f32, now_ns: u64, motion: Motion) void { @@ -769,6 +777,24 @@ pub const Spring = struct { } }; +test "a carried spring keeps its speed, heads back to its mark and never passes it" { + const ms = std.time.ns_per_ms; + var spring: Spring = .{}; + const smooth = Motion.of(.smooth); + spring.carry(-3, 0, smooth); + try std.testing.expectEqual(@as(f32, -3), spring.value(0)); + const mid = spring.at(40 * ms); + try std.testing.expect(mid.value > -3 and mid.value < 0 and mid.velocity > 0); + // Another notch mid-way: one more row behind, still moving back. + spring.carry(-1, 40 * ms, smooth); + const after = spring.at(40 * ms); + try std.testing.expectApproxEqAbs(mid.value - 1, after.value, 1e-5); + try std.testing.expectApproxEqAbs(mid.velocity, after.velocity, 1e-4); + var t: u64 = 40 * ms; + while (spring.step(t)) : (t += frame_ns) try std.testing.expect(spring.value(t) <= 1e-4); + try std.testing.expect(t < 400 * ms); +} + test "a spring settles without overshoot and keeps its velocity when retargeted" { var spring: Spring = .{}; const ms = std.time.ns_per_ms; diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 092bb3bf..449cae15 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -1866,6 +1866,10 @@ const Gui = struct { scroll_lag: f32 = 0, // picture position - core position, in rows scroll_edge: []pardes.Cell = &.{}, // the row that just left the pane scroll_edge_len: u16 = 0, + /// G6: a wheel's notches, whole lines, scroll the core at once and the + /// picture follows on a spring (Glide), never the other way round. + scroll_notch: i32 = 0, + glide: Glide = .{}, /// The cursor's blink (blinkNow): when the frame drawn last arrived, /// a redraw owed for an edge, and this frame's cursor mode and alpha. @@ -2574,6 +2578,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u defer if (g.font_bytes.len != 0) gpa.free(g.font_bytes); // set by Font, if it ran defer c.ui_font_free(g.font); defer gpa.free(g.scroll_edge); // grown on demand by stepScroll + defer g.glide.deinit(gpa); resetGlyphAtlas(&g); if (attach) |requested| return attachRequested(gpa, &g, requested); @@ -3104,6 +3109,7 @@ const StdinFeed = struct { var seq_start: usize = 0; while (seq_start < len) { // ESC]777;mouse;;;;BEL + // ESC]777;mouse;wheel;;;BEL // A mouse event at window pixels, built as SDL would and run // through dispatch, so a driver exercises the real pointer path // (pixel to cell, tag and body hits). SGR mouse carries only cells. @@ -3118,11 +3124,21 @@ const StdinFeed = struct { if (g) |gp| { var parts = std.mem.splitScalar(u8, body[0..end], ';'); const kind = parts.next() orelse ""; - const button = std.fmt.parseInt(u8, parts.next() orelse "", 10) catch 0; + const second = parts.next() orelse ""; + const button = std.fmt.parseInt(u8, second, 10) catch 0; const x = std.fmt.parseFloat(f32, parts.next() orelse "") catch 0; const y = std.fmt.parseFloat(f32, parts.next() orelse "") catch 0; var sev = std.mem.zeroes(c.SDL_Event); - if (std.mem.eql(u8, kind, "motion")) { + if (std.mem.eql(u8, kind, "wheel")) { + // ESC]777;mouse;wheel;;;BEL: + // a whole number is a mouse's notches, else a touchpad's. + const amount = std.fmt.parseFloat(f32, second) catch 0; + sev.type = c.SDL_EVENT_MOUSE_WHEEL; + sev.wheel.y = amount; + sev.wheel.integer_y = @intFromFloat(@trunc(amount)); + sev.wheel.mouse_x = x; + sev.wheel.mouse_y = y; + } else if (std.mem.eql(u8, kind, "motion")) { sev.type = c.SDL_EVENT_MOUSE_MOTION; sev.motion.x = x; sev.motion.y = y; @@ -3533,7 +3549,12 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { g.scroll_pane = id; g.scroll_col = mc.col; g.scroll_row = mc.row; - g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y); + // A mouse wheel's notch is a whole line and + // glides (G6); a touchpad's fraction moves the + // picture by itself, under the finger. + if (notchOf(w.y, w.integer_y)) |lines| { + g.scroll_notch +|= lines; + } else g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y); } } } else { @@ -4213,7 +4234,7 @@ 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 or + const polls = g.scroll_lag != 0 or g.scroll_delta != 0 or g.scroll_notch != 0 or g.glide.moving() 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: @@ -4797,14 +4818,356 @@ test "fractional scroll reversals preserve signed distance" { try std.testing.expect(reverse.lag >= 0 and reverse.lag < 1); } +/// A mouse wheel's step as whole lines (down positive), or null for a +/// touchpad's fraction, which follows the finger as it is. +fn notchOf(raw_y: f32, integer_y: i32) ?i32 { + if (!std.math.isFinite(raw_y) or integer_y == 0 or raw_y != @round(raw_y)) return null; + return @intFromFloat(std.math.clamp(-raw_y, -64, 64)); +} + +test "a wheel's notch is whole lines, a touchpad's fraction is not" { + try std.testing.expectEqual(@as(?i32, 1), notchOf(-1, -1)); + try std.testing.expectEqual(@as(?i32, -3), notchOf(3, 3)); + try std.testing.expectEqual(@as(?i32, null), notchOf(-0.25, 0)); + try std.testing.expectEqual(@as(?i32, null), notchOf(-1.5, -1)); + try std.testing.expectEqual(@as(?i32, null), notchOf(std.math.nan(f32), 1)); +} + +/// G6 (docs/render-pipeline.md §9.1): a wheel's lines scroll the core at +/// once, so every key and click acts on where the text is, and the picture +/// glides there on the Motion flavour's spring (critically damped: text +/// never passes its mark). How far the rows moved is measured on the next +/// frame against the last, a line wrapped over several rows or a terminal's +/// output included, and the rows that left are kept to draw in the gap. +const Glide = struct { + spring: pardes.animation.Spring = .{}, + /// The picture's offset from the core's rows now (rows; negative, the + /// picture still shows what is above). + offset: f32 = 0, + now_ns: u64 = 0, + pane: usize = 0, + rect: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 }, + body_y: u16 = 0, + body_h: u16 = 0, + /// A scroll made and not yet measured: its direction, and the body's + /// rows before it. + pending: bool = false, + dir: i8 = 0, + before: []pardes.Cell = &.{}, + /// The rows past the body's top (`above`) and bottom (`below`), nearest + /// first, `bw` cells each: what the gap shows while the picture lags. + above: []pardes.Cell = &.{}, + below: []pardes.Cell = &.{}, + nabove: u16 = 0, + nbelow: u16 = 0, + /// The Motion flavour the last loop step read. + motion: pardes.animation.Motion = .{}, + + fn moving(glide: *const Glide) bool { + return !glide.spring.settled or glide.pending; + } + + fn stop(glide: *Glide) void { + glide.spring = .{}; + glide.offset = 0; + glide.pending = false; + glide.nabove = 0; + glide.nbelow = 0; + } + + fn deinit(glide: *Glide, gpa: std.mem.Allocator) void { + gpa.free(glide.before); + gpa.free(glide.above); + gpa.free(glide.below); + } + + fn bw(glide: *const Glide) u16 { + return glide.rect.w -| config.GUTTER; + } + + /// The core's rows moved by `rows` (down positive) since the last + /// frame: the picture now lags by as many, carrying its speed. + fn kick(glide: *Glide, rows: f32, now_ns: u64, motion: pardes.animation.Motion) void { + // Follows input: the flavour's pace, half again, never overshooting. + var pace = motion; + pace.omega = motion.omega * 1.5; + glide.spring.carry(-rows, now_ns, pace); + // Never more than a body's rows behind: a flick past that jumps. + const limit: f32 = @floatFromInt(glide.body_h); + const now = glide.spring.value(now_ns); + if (@abs(now) > limit) glide.spring.carry(std.math.clamp(now, -limit, limit) - now, now_ns, pace); + glide.offset = glide.spring.value(now_ns); + } + + /// Finds how far the pane's body rows moved, `dir` rows at a time, by + /// the rows of `surface` against `before`; keeps the rows that left. + fn measure(glide: *Glide, gpa: std.mem.Allocator, surface: *const pardes.Surface) void { + glide.pending = false; + const w = glide.bw(); + const h = glide.body_h; + const x0 = glide.rect.x + config.GUTTER; + if (w == 0 or h == 0 or x0 + w > surface.cols or glide.body_y + h > surface.rows or glide.before.len < @as(usize, w) * h) return glide.stop(); + const now = struct { + fn row(s: *const pardes.Surface, x: u16, y: u16, n: u16) []const pardes.Cell { + return s.cells[@as(usize, y) * s.cols + x ..][0..n]; + } + }; + // The least shift under which the rows agree, but for a few: the + // cursor's line and its number's weight move with the scroll. + const shift: u16 = found: for (1..h) |k| { + const kk: u16 = @intCast(k); + const allowed = @max(2, (h - kk) / 8); + var misses: usize = 0; + for (0..h - kk) |r| { + const rr: u16 = @intCast(r); + const old_row = glide.before[@as(usize, if (glide.dir > 0) rr + kk else rr) * w ..][0..w]; + const new_row = now.row(surface, x0, glide.body_y + if (glide.dir > 0) rr else rr + kk, w); + if (!sameRow(old_row, new_row)) { + misses += 1; + if (misses > allowed) continue :found; + } + } + // Too few rows left to tell: no glide. + if (h - kk < 3) break :found 0; + break :found kk; + } else 0; + if (shift == 0) return glide.stop(); + // The rows that left: past the top going down, the bottom going up. + if (!glide.ensure(gpa)) return glide.stop(); + const keep = if (glide.dir > 0) &glide.above else &glide.below; + const count = if (glide.dir > 0) &glide.nabove else &glide.nbelow; + const kept = @min(count.*, h - shift); + std.mem.copyBackwards(pardes.Cell, keep.*[@as(usize, shift) * w ..][0 .. @as(usize, kept) * w], keep.*[0 .. @as(usize, kept) * w]); + for (0..shift) |i| { + const src_row: usize = if (glide.dir > 0) shift - 1 - i else h - shift + i; + @memcpy(keep.*[i * w ..][0..w], glide.before[src_row * w ..][0..w]); + } + count.* = kept + shift; + // What came into view is no longer past the other edge. + const other = if (glide.dir > 0) &glide.below else &glide.above; + const other_n = if (glide.dir > 0) &glide.nbelow else &glide.nabove; + const dropped = @min(other_n.*, shift); + std.mem.copyForwards(pardes.Cell, other.*[0 .. @as(usize, other_n.* - dropped) * w], other.*[@as(usize, dropped) * w ..][0 .. @as(usize, other_n.* - dropped) * w]); + other_n.* -= dropped; + glide.kick(@as(f32, @floatFromInt(shift)) * @as(f32, @floatFromInt(glide.dir)), glide.now_ns, glide.motion); + } + + fn ensure(glide: *Glide, gpa: std.mem.Allocator) bool { + const n = @as(usize, glide.bw()) * glide.body_h; + inline for (.{ &glide.above, &glide.below }) |buffer| if (buffer.len < n) { + const grown = gpa.realloc(buffer.*, n) catch return false; + buffer.* = grown; + }; + return true; + } +}; + +fn sameRow(a: []const pardes.Cell, b: []const pardes.Cell) bool { + for (a, b) |x, y| { + if (x.default != y.default) return false; + if (x.default) continue; + if (!std.mem.eql(u8, x.grapheme(), y.grapheme()) or !std.meta.eql(x.style, y.style)) return false; + } + return true; +} + +/// Feeds a wheel's notches to the core, snapshotting the body first so the +/// next frame can measure the move (Glide.measure); advances the glide and +/// asks for frames while it moves. +fn stepGlide(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { + const glide = &g.glide; + // The glide runs on the core's continuous path: frames every loop, and + // a virtual clock carried on. + defer core.shell_continuous = glide.moving(); + glide.now_ns = core.now_ns; + glide.motion = pardes.animation.Motion.of(core.settings.motion); + if (!glide.spring.settled) { + _ = glide.spring.step(core.now_ns); + core.needs_frame = true; + if (glide.spring.settled) { + glide.offset = 0; + glide.nabove = 0; + glide.nbelow = 0; + } + } + const notch = g.scroll_notch; + if (notch == 0) return; + g.scroll_notch = 0; + const id = g.scroll_pane orelse return; + const pane = core.panes[id] orelse return resetScroll(g); + const r = core.rects[id]; + if (r.h <= pane.tag_rows or r.w <= config.GUTTER) return resetScroll(g); + const body_y = core.bodyTop(pane, r); + const body_h = r.h -| pane.tag_rows; + // A new pane or place: what was kept belongs to another picture. + if (glide.pane != id or !std.meta.eql(glide.rect, r) or glide.body_y != body_y or glide.body_h != body_h or glide.pending) glide.stop(); + glide.pane = id; + glide.rect = r; + glide.body_y = body_y; + glide.body_h = body_h; + // The body as the last frame drew it, which the move is measured from. + const s = &core.surface; + const w = r.w - config.GUTTER; + const x0 = r.x + config.GUTTER; + const animate = !glide.motion.instant and x0 + w <= s.cols and body_y + body_h <= s.rows and s.cells.len != 0; + if (animate) { + const n = @as(usize, w) * body_h; + if (glide.before.len < n) glide.before = gpa.realloc(glide.before, n) catch &.{}; + if (glide.before.len >= n) { + for (0..body_h) |row| @memcpy(glide.before[row * w ..][0..w], s.cells[(@as(usize, body_y) + row) * s.cols + x0 ..][0..w]); + } + } + var left = notch; + var moved = false; + while (left != 0) { + const down = left > 0; + left += if (down) -1 else 1; + const was = pane.scroll(); + core.update(.{ .mouse = .{ .button = if (down) .wheel_down else .wheel_up, .kind = .press, .col = g.scroll_col, .row = g.scroll_row } }); + if (pane.scroll() == was) break; + moved = true; + } + if (!moved) return; + core.needs_frame = true; + if (animate and glide.before.len >= @as(usize, w) * body_h) { + glide.pending = true; + glide.dir = @intCast(std.math.sign(notch)); + } +} + +/// The glide's picture: the pane's body rows moved by the offset, and in +/// the gap it opens the rows that left, as `emitScrollRows` draws a +/// touchpad's fraction. +fn emitGlideRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pardes.Surface, layout: CellLayout, win_w: f32, win_h: f32, page: Ground) u32 { + const glide = &g.glide; + for (surface.panelTracks()) |track| + if (track.active() and track.pane == glide.pane) return 0; + const r = glide.rect; + const bh = glide.body_h; + const bw = glide.bw(); + const x0 = r.x + config.GUTTER; + const y0 = glide.body_y; + if (bw == 0 or bh == 0 or x0 + bw > surface.cols or y0 + bh > surface.rows) return 0; + // The scissor the shifted rows are drawn in is the scroll's. + g.scroll_rect = r; + g.scroll_body_y = y0; + g.scroll_body_h = bh; + var shifted = layout; + shifted.y_off -= glide.offset * layout.h; + const cursor_idx: u32 = if (surface.cursor) |cu| @as(u32, cu.y) * surface.cols + cu.x else std.math.maxInt(u32); + const cursor_bar = if (surface.cursor) |cu| cu.bar else false; + var n: u32 = 0; + var row = y0; + while (row < y0 + bh) : (row += 1) { + const line = surface.cells[@as(usize, row) * surface.cols ..][0..surface.cols]; + const row_cursor: ?usize = if (surface.cursor) |cu| if (cu.y == row) cu.x else null else null; + var col = x0; + while (col < x0 + bw) : (col += 1) { + const sidx: u32 = @as(u32, row) * surface.cols + col; + emitInstance(g, instances, base + n, col, row, shifted, win_w, win_h, null, cellFontRole(surface.at(col, row)), line, col, row_cursor, false, sidx == cursor_idx and !cursor_bar, page); + n += 1; + } + } + // The gap: rows past the top while the picture lags a move down, past + // the bottom for one up; as many as it opens. + const gap: u16 = @intFromFloat(@min(@ceil(@abs(glide.offset)), @as(f32, @floatFromInt(bh)))); + const kept = if (glide.offset < 0) glide.nabove else glide.nbelow; + const rows = if (glide.offset < 0) glide.above else glide.below; + for (0..@min(gap, kept)) |i| { + const cells = rows[i * bw ..][0..bw]; + // Row -1 - i above the body, or bh + i below it, in grid rows. + const at_row: i32 = if (glide.offset < 0) @as(i32, y0) - 1 - @as(i32, @intCast(i)) else @as(i32, y0) + bh + @as(i32, @intCast(i)); + var at = shifted; + // emitInstance takes an unsigned row: move the layout instead. + at.y_off += @as(f32, @floatFromInt(at_row - @as(i32, y0))) * layout.h; + for (0..bw) |c_i| { + emitInstance(g, instances, base + n, x0 + @as(u16, @intCast(c_i)), y0, at, win_w, win_h, null, cellFontRole(&cells[c_i]), cells, c_i, null, false, false, page); + n += 1; + } + } + return n; +} + +test "a notch scrolls the core at once and the picture glides after it, a wrapped line's rows at a time" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); + defer core.deinit(); + // Lines of two rows each at this width, numbered so no two rows match. + var text: std.ArrayList(u8) = .empty; + defer text.deinit(gpa); + for (0..60) |i| { + try text.print(gpa, "{d:0>4} ", .{i}); + try text.appendNTimes(gpa, 'a' + @as(u8, @intCast(i % 26)), 100); + try text.append(gpa, '\n'); + } + _ = try core.setTestFile(text.items); + core.settings.motion = .smooth; + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + const ms = std.time.ns_per_ms; + var now: u64 = std.time.ns_per_s; + core.advance(now); + var s = try core.render(arena.allocator()); + var g: Gui = undefined; + g.glide = .{}; + defer g.glide.deinit(gpa); + g.scroll_delta = 0; + g.scroll_lag = 0; + g.scroll_edge_len = 0; + const r = core.rects[core.active]; + g.scroll_pane = core.active; + g.scroll_col = r.x + 10; + g.scroll_row = r.y + r.h - 2; + const body_y = core.bodyTop(core.panes[core.active].?, r); + const row_before = try gpa.dupe(pardes.Cell, s.cells[@as(usize, body_y + 1) * s.cols + r.x + config.GUTTER ..][0 .. r.w - config.GUTTER]); + defer gpa.free(row_before); + // One notch down: the core is a line on at once. + g.scroll_notch = 1; + const scrolled = core.panes[core.active].?.scroll(); + stepGlide(&g, core, gpa); + try std.testing.expectEqual(scrolled + 1, core.panes[core.active].?.scroll()); + try std.testing.expect(g.glide.pending); + // On the core's continuous path while it moves: frames every loop. + try std.testing.expect(core.shell_continuous and core.continuous()); + _ = arena.reset(.retain_capacity); + s = try core.render(arena.allocator()); + g.glide.measure(gpa, s); + // Its two rows: the picture two rows behind, the rows that left kept, + // nearest first. + try std.testing.expectApproxEqAbs(@as(f32, -2), g.glide.offset, 1e-4); + try std.testing.expectEqual(@as(u16, 2), g.glide.nabove); + try std.testing.expect(sameRow(g.glide.above[0 .. r.w - config.GUTTER], row_before)); + // Frame by frame it comes back, never past, and settles. + var frames: usize = 0; + while (g.glide.moving()) : (frames += 1) { + now += 7 * ms; + core.advance(now); + stepGlide(&g, core, gpa); + try std.testing.expect(g.glide.spring.value(now) <= 1e-4); + if (frames > 200) return error.NeverSettled; + } + try std.testing.expect(frames > 5); + try std.testing.expectEqual(@as(f32, 0), g.glide.offset); + try std.testing.expect(!core.shell_continuous); + // Motion off: a notch lands at once. + core.settings.motion = .off; + g.scroll_notch = 1; + stepGlide(&g, core, gpa); + try std.testing.expect(!g.glide.pending and !g.glide.moving()); +} + + fn resetScroll(g: *Gui) void { g.scroll_pane = null; g.scroll_delta = 0; g.scroll_lag = 0; g.scroll_edge_len = 0; + g.scroll_notch = 0; + g.glide.stop(); } fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { + stepGlide(g, core, gpa); const id = g.scroll_pane orelse return; const pane = core.panes[id] orelse { resetScroll(g); @@ -4813,7 +5176,7 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { const queued_delta = g.scroll_delta; g.scroll_delta = 0; if (queued_delta == 0) { - if (g.scroll_lag == 0) resetScroll(g); + if (g.scroll_lag == 0 and g.scroll_notch == 0 and !g.glide.moving()) resetScroll(g); return; } const r = core.rects[id]; @@ -4868,6 +5231,7 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { } fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pardes.Surface, layout: CellLayout, win_w: f32, win_h: f32, page: Ground) u32 { + if (g.glide.offset != 0) return emitGlideRows(g, instances, base, surface, layout, win_w, win_h, page); const scroll_pane = g.scroll_pane orelse return 0; if (g.scroll_lag == 0) return 0; for (surface.panelTracks()) |track| @@ -5434,6 +5798,10 @@ fn renderFrame( defer if (building) build_zone.end(); const layout = fixedCellLayout(g); const chrome = &surface.chrome; + // G6: what the scroll just done moved the pane's rows by, measured on + // this frame against the last (the glide follows it from there). + if (g.glide.pending) g.glide.measure(gpa, surface); + if (g.glide.moving()) g.glide.offset = g.glide.spring.value(g.glide.now_ns); cell_decor = CellDecor.of(chrome); // Plaques need an opaque window: with WindowOpacity under 100 the cells // are drawn as they are, and the plaques not at all. @@ -5570,7 +5938,8 @@ fn renderFrame( } 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; + // The shifted rows of a scroll: its body, and a glide's rows past it. + const capacity = std.math.add(u32, cell_total, 2 * cells + decor_len) catch return error.GpuCapacity; try ensureVbuf(g, capacity); const vptr: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, g.vxfer.?, false) orelse { diff --git a/src/pardes.zig b/src/pardes.zig index 54fe2df0..bbd8e7cd 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -4294,6 +4294,10 @@ pub const Pardes = struct { /// the pane and view origin it was last aimed in, and the last key or /// click, which a blink holds for. cursor_glide: animation.CursorGlide = .{}, + /// A pixel shell's own render-time motion is under way (its smooth + /// scroll, G6): the core then keeps the continuous path running, so the + /// shell draws every loop and a virtual clock moves on, as for a lift. + shell_continuous: bool = false, cursor_view: struct { serial: u32 = 0, line: i64 = 0 } = .{}, input_ns: u64 = 0, animate_theme_changes: bool = false, @@ -6527,7 +6531,7 @@ pub const Pardes = struct { /// is for the stepped animations, which count frames of `frame_ns`. pub fn continuous(p: *const Pardes) bool { for (p.lifts) |spring| if (!spring.settled) return true; - return p.cursor_glide.moving(); + return p.cursor_glide.moving() or p.shell_continuous; } /// For shells that only need a yes or no: macOS and the board, which -- cgit v1.3 From c1dbb16d069a54f52533f346655b0fc49026c9a4 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 06:58:19 -0300 Subject: G7: a moving pane casts a shadow, a retargeted move keeps its speed, a dissolve crossfades - Track.launch: a move retargeted mid-way (Presentation.sync) starts at the old move's speed along the new one's longest side (carriedLaunch, Motion.easeFrom/speedFrom), so a second change during a slide bends the motion instead of stopping it dead. The macOS track ABI grows the float (pardes.h, 48 bytes; its reserved byte is named motion, as in Zig). - A pane that slides, zooms or rises casts Lift's soft shadow at sin(pi x progress) of its strength, on the other panes' bodies and rails alone, under the same contrast ceiling, carried by its track. - The GUI's PanelDissolve is a crossfade: the new cell drawn first, the old one over it at 1 - progress, premultiplied, on the blending cell pipeline for such groups (new cells now emitted before old ones, as the old layers already came after the new). sRGB blend, not linear light (documented). The tty keeps its per-cell swap. Presentation.zig's and animation.zig's tests were not in any test root; pardes.zig's test block now takes them in. Tests: a retargeted move carries its speed and still lands exactly; a moving pane's shadow is clipped off every tag, grip and its own body, none at the start or the landing, most mid-way. GUI golden 18 changes by the shadow (frame 6 of a slide). Gates as G6. Feel review: .scratch/render/transitions/. --- docs/effects.md | 15 ++++++ shaders/prebuilt/ui.frag.glsl | 17 ++++--- shaders/prebuilt/ui.frag.spv | Bin 8448 -> 8176 bytes shaders/ui.frag.glsl | 17 ++++--- src/Presentation.zig | 39 +++++++++++++++- src/animation.zig | 33 ++++++++++++-- src/gui/gui.zig | 103 ++++++++++++++++++++++++++++++++++++------ src/macos.zig | 2 +- src/macos/pardes.h | 3 +- src/pardes.zig | 2 + 10 files changed, 199 insertions(+), 32 deletions(-) (limited to 'docs') diff --git a/docs/effects.md b/docs/effects.md index 04757aca..58c0098e 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -40,6 +40,7 @@ why. The verdict is the user's. | G4 bloom, vignette, grain | `Bloom`, `Vignette`, `Grain` `0..3` (off) | bundled post passes; none touches a tag, grip, notice or the cursor (pardesSpare); each capped per theme (pardesCap) | .scratch/render/{bloom,vignette,grain}/ (levels 0-3 on forge, dusk, acme; crops; mp4 stepping through the levels) | pending | | G5 lapis | `Theme lapis` (its `decor`) | a native theme with ornament: tag plaques with hard shadows inside their bands (the focused one striped), dots, rail checker, title shadow | .scratch/render/lapis/ (whole window at 1x and 2x, crops, a slide mp4) | pending | | G6 smooth scroll | follows `Motion` (on through the default smooth; `Motion off` lands at once) | a mouse wheel's notch glides, a touchpad follows the finger | .scratch/render/scroll/ (glide-.mp4, quarter speed, strips) | pending | +| G7 panel transitions | `PanelSlide`, `PanelZoom`, `PanelVertical`, `PanelDissolve` (off) with `Motion` | a moving pane casts a shadow; a retargeted move keeps its speed; the GUI's dissolve is a crossfade | .scratch/render/transitions/ (slide smooth, bouncy, retargeted; dissolve) | pending | ## Motion flavours @@ -161,3 +162,17 @@ continuous path (Pardes.shell_continuous), so the shell draws every loop while it moves and a virtual clock moves with it; idle, it costs nothing. A PDF page scrolls as before. The test feed takes wheel events (ESC]777;mouse;wheel;;;BEL). + +Notes on G7: the geometry already followed the Motion flavour's spring +(sxrrwplk); a move retargeted mid-way now carries its speed on +(Track.launch, from the old move's speed along the new one's longest +side), so a second Newcol during a slide bends the motion instead of +stopping it dead. A pane that slides, zooms or rises stands off the page +while it moves: a soft shadow as Lift's, at sin(pi x progress) of its +strength (none as it sets out or lands), on the other panes' bodies and +rails alone and under the same contrast ceiling, moving with the pane. The +GUI's PanelDissolve is a crossfade: a changed cell's new form drawn whole, +its old one over it at 1 - progress; the blend is in sRGB, not linear +light (a two-colour mix in linear light needs both in one shader; open +point 3). The tty keeps its per-cell swap. GUI golden 18-mid-transition +changes (the slide's shadow at frame 6). diff --git a/shaders/prebuilt/ui.frag.glsl b/shaders/prebuilt/ui.frag.glsl index 62dbbfa2..cb38fff1 100644 --- a/shaders/prebuilt/ui.frag.glsl +++ b/shaders/prebuilt/ui.frag.glsl @@ -82,13 +82,18 @@ void main() { } vec4 cell = vec4(mix(under.rgb, v_fg, a), mix(under.a, 1.0, a)); - // 3 = data dissolve. Changed cells retain the frozen old cell until their - // stable threshold, then atomically become the new cell. Exact endpoints - // mirror panel_animation.dissolveRevealed, including the zero hash. + // 3 = dissolve, a crossfade (G7): a changed cell's new form is drawn + // first, whole, and its frozen old one over it at 1 - progress, + // premultiplied (the frame's cells blend over, pipeline_over), so the + // old fades out as the new comes in; at the ends, exactly one of them. + // The tty keeps its per-cell swap (panel_animation.dissolveRevealed). if (effect == 3u) { - bool reveal = progress >= 1.0 || (progress > 0.0 && noise < progress); - if (oldLayer ? reveal : !reveal) discard; - o_col = cell; + if (!oldLayer) { + o_col = cell; + return; + } + if (progress >= 1.0) discard; + o_col = cell * (1.0 - progress); return; } diff --git a/shaders/prebuilt/ui.frag.spv b/shaders/prebuilt/ui.frag.spv index d83d9c55..15318214 100644 Binary files a/shaders/prebuilt/ui.frag.spv and b/shaders/prebuilt/ui.frag.spv differ diff --git a/shaders/ui.frag.glsl b/shaders/ui.frag.glsl index 62dbbfa2..cb38fff1 100644 --- a/shaders/ui.frag.glsl +++ b/shaders/ui.frag.glsl @@ -82,13 +82,18 @@ void main() { } vec4 cell = vec4(mix(under.rgb, v_fg, a), mix(under.a, 1.0, a)); - // 3 = data dissolve. Changed cells retain the frozen old cell until their - // stable threshold, then atomically become the new cell. Exact endpoints - // mirror panel_animation.dissolveRevealed, including the zero hash. + // 3 = dissolve, a crossfade (G7): a changed cell's new form is drawn + // first, whole, and its frozen old one over it at 1 - progress, + // premultiplied (the frame's cells blend over, pipeline_over), so the + // old fades out as the new comes in; at the ends, exactly one of them. + // The tty keeps its per-cell swap (panel_animation.dissolveRevealed). if (effect == 3u) { - bool reveal = progress >= 1.0 || (progress > 0.0 && noise < progress); - if (oldLayer ? reveal : !reveal) discard; - o_col = cell; + if (!oldLayer) { + o_col = cell; + return; + } + if (progress >= 1.0) discard; + o_col = cell * (1.0 - progress); return; } diff --git a/src/Presentation.zig b/src/Presentation.zig index 15ed9ea6..5cec73e0 100644 --- a/src/Presentation.zig +++ b/src/Presentation.zig @@ -183,7 +183,7 @@ pub fn sync(self: *Presentation, p: *Pardes) void { prior.?.from else old.box; - const next = stamp(p, .{ + var next = stamp(p, .{ .serial = pane.serial, .pane = @intCast(id), .phase = if (!same_lifetime or @@ -192,6 +192,10 @@ pub fn sync(self: *Presentation, p: *Pardes) void { .from = from, .to = target, }); + // Retargeted mid-way from where it shows: it keeps its speed. + if (same_lifetime and shown != null) if (prior) |old_move| { + next.launch = carriedLaunch(old_move, next); + }; self.tracks[id] = next; changed = true; animated_change = true; @@ -230,6 +234,23 @@ fn stamp(p: *const Pardes, track: Track) Track { return stamped; } +/// A retargeted move's starting speed, in its own lengths per unit of +/// progress, from the old move's speed now (cells per frame), along the +/// new move's longest side; within four lengths per unit either way. +fn carriedLaunch(old_move: Track, next: Track) f32 { + const v = old_move.velocity(); + if (next.frames() <= 1) return 0; + const steps: f32 = @floatFromInt(next.frames() - 1); + const d: [4]f32 = .{ next.to.x - next.from.x, next.to.y - next.from.y, next.to.w - next.from.w, next.to.h - next.from.h }; + const vs: [4]f32 = .{ v.x, v.y, v.w, v.h }; + var axis: usize = 0; + for (d, 0..) |value, i| if (@abs(value) > @abs(d[axis])) { + axis = i; + }; + if (@abs(d[axis]) < 1e-3) return 0; + return std.math.clamp(vs[axis] * steps / d[axis], -4, 4); +} + fn appendClosing(self: *Presentation, track: Track) bool { std.debug.assert(track.phase == .closing); const baseline = self.previous_layout[track.pane] orelse return false; @@ -784,3 +805,19 @@ test "presentation snapshots own compact body and tag rows across later edits" { try std.testing.expectEqual(@as(u8, 'B'), state.previous_body_layers[0].cells[0].text[0]); try std.testing.expectEqual(@as(u8, 'U'), state.previous_tag_layers[0].cells[0].text[0]); } + +test "a move retargeted mid-way carries its speed into the new one" { + const smooth = @intFromEnum(animation.Motion.Flavour.smooth) + 1; + const first: Track = .{ .serial = 1, .phase = .moving, .effect = .slide, .motion = smooth, .frame = 4, .frame_count = 14, .from = .{ .x = 0, .y = 1, .w = 40, .h = 20 }, .to = .{ .x = 40, .y = 1, .w = 40, .h = 20 } }; + const was = first.velocity(); + try std.testing.expect(was.x > 0.5); + var next: Track = .{ .serial = 1, .phase = .moving, .effect = .slide, .motion = smooth, .frame_count = 14, .from = first.presented(), .to = .{ .x = 60, .y = 1, .w = 40, .h = 20 } }; + next.launch = carriedLaunch(first, next); + try std.testing.expect(next.launch > 0); + // It sets off at the speed the old one had, not from a stop. + const now = next.velocity(); + try std.testing.expectApproxEqRel(was.x, now.x, 0.15); + // And still lands exactly. + next.frame = 13; + try std.testing.expectEqual(next.to.x, next.presented().x); +} diff --git a/src/animation.zig b/src/animation.zig index 12d352d1..855fef03 100644 --- a/src/animation.zig +++ b/src/animation.zig @@ -136,6 +136,10 @@ pub const Track = extern struct { frame_count: u16 = 0, from: Box = .{}, to: Box = .{}, + /// The move's speed as it starts, in its lengths per unit of progress: + /// 0 sets off as the flavour does; a move retargeted mid-way carries + /// the old one's speed on (Presentation.sync), so it never stops dead. + launch: f32 = 0, pub fn active(track: Track) bool { return track.effect != .off and track.frame < track.frames(); @@ -151,7 +155,7 @@ pub const Track = extern struct { if (track.phase == .closing and track.effect == .vertical) return progressEased(.in_cubic, track.frames(), track.frame); if (track.flavour()) |motion| - return motion.ease(track.progress()); + return motion.easeFrom(track.progress(), track.launch); return progressEased(track.effect.easing(), track.frames(), track.frame); } @@ -163,6 +167,16 @@ pub const Track = extern struct { return Motion.of(which); } + /// How fast its presented box moves now, in cells per frame of its + /// own (x, y, w, h); zero when it follows no flavour or has landed. + pub fn velocity(track: Track) Box { + const motion = track.flavour() orelse return .{}; + const u = track.progress(); + if (u >= 1 or track.frames() <= 1) return .{}; + const rate = motion.speedFrom(u, track.launch) / @as(f32, @floatFromInt(track.frames() - 1)); + return .{ .x = (track.to.x - track.from.x) * rate, .y = (track.to.y - track.from.y) * rate, .w = (track.to.w - track.from.w) * rate, .h = (track.to.h - track.from.h) * rate }; + } + fn progress(track: Track) f32 { if (track.frames() <= 1 or track.frame >= track.frames() - 1) return 1; return @as(f32, @floatFromInt(track.frame)) / @as(f32, @floatFromInt(track.frames() - 1)); @@ -180,7 +194,7 @@ pub const Track = extern struct { // box, so a click lands on the glyph it is drawn as). const motion = track.flavour() orelse return box; if (motion.stretch == 0) return box; - const factor = 1 + motion.stretch * std.math.clamp(motion.speed(track.progress()) / 4, -1, 1); + const factor = 1 + motion.stretch * std.math.clamp(motion.speedFrom(track.progress(), track.launch) / 4, -1, 1); if (@abs(track.to.x - track.from.x) >= @abs(track.to.y - track.from.y)) { const w = box.w * factor; box.x -= (w - box.w) / 2; @@ -685,16 +699,27 @@ pub const Motion = struct { /// flavour's spring (wind-up, overshoot and all), landing exactly on 1 /// at its end. `instant` is there at once. pub fn ease(motion: Motion, u: f32) f32 { + return motion.easeFrom(u, 0); + } + + /// `ease` for a move that starts already moving at `launch` lengths + /// per unit of `u` (a retarget): no wind-up then, the speed carried on. + pub fn easeFrom(motion: Motion, u: f32, launch: f32) f32 { if (motion.instant or u >= 1) return 1; if (u <= 0) return 0; - const spring: Spring = .{ .omega = motion.norm, .zeta = motion.zeta, .target = 1, .from = 0, .velocity = (motion.launch - 2 * motion.anticipate) * motion.norm, .settled = false }; + const kick = if (launch != 0) launch else (motion.launch - 2 * motion.anticipate) * motion.norm; + const spring: Spring = .{ .omega = motion.norm, .zeta = motion.zeta, .target = 1, .from = 0, .velocity = kick, .settled = false }; return spring.value(@intFromFloat(u * std.time.ns_per_s)); } /// The same move's speed at `u`, in lengths per unit of `u`. pub fn speed(motion: Motion, u: f32) f32 { + return motion.speedFrom(u, 0); + } + + pub fn speedFrom(motion: Motion, u: f32, launch: f32) f32 { const h: f32 = 1.0 / 256.0; - return (motion.ease(@min(1, u + h)) - motion.ease(@max(0, u - h))) / (@min(1, u + h) - @max(0, u - h)); + return (motion.easeFrom(@min(1, u + h), launch) - motion.easeFrom(@max(0, u - h), launch)) / (@min(1, u + h) - @max(0, u - h)); } }; diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 449cae15..b5da9ad6 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -5963,7 +5963,9 @@ fn renderFrame( 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( + // The new cell, then (a dissolve) the old one fading out over + // it, as the old layers come after the new ones. + emitSurfaceCell( g, chrome, instances, @@ -5973,16 +5975,16 @@ fn renderFrame( 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], + if (data_effect and !data_diff) null else track, + line, + line_cover, col, - null, - true, + line_cursor, false, + cursors_shown and at == cursor_idx and !cursor_bar and bodyLayerAt(surface, col, row) == null, page, ); - emitSurfaceCell( + if (data_diff) emitSurfaceCell( g, chrome, instances, @@ -5992,13 +5994,13 @@ fn renderFrame( layout, win_w, win_h, - if (data_effect and !data_diff) null else track, - line, - line_cover, + track, + surface.previous_cells[@as(usize, row) * surface.cols ..][0..surface.cols], + previous_covered[@as(usize, row) * surface.cols ..][0..surface.cols], col, - line_cursor, + null, + true, false, - cursors_shown and at == cursor_idx and !cursor_bar and bodyLayerAt(surface, col, row) == null, page, ); } @@ -6104,7 +6106,10 @@ fn renderFrame( } const has_shifted = index == 0 and shifted != 0; if ((group.cell_count != 0 or has_shifted) and g.vbuf != null) { - c.SDL_BindGPUGraphicsPipeline(rp, if (cell_decor.nplaques != 0) g.pipeline_over else g.pipeline); + // Blended over what is under: a theme's plaques show through + // blank tag cells; a dissolve's old cells fade over its new. + const dissolving = if (group.track) |active| active.effect == .dissolve else false; + c.SDL_BindGPUGraphicsPipeline(rp, if (cell_decor.nplaques != 0 or dissolving) g.pipeline_over else 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 (group.cell_count != 0) { @@ -8715,6 +8720,34 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g } else null; try addLift(g, gpa, lift_style, ceilings, box, rim_edge, bodies, grip.lift, track, win_w, win_h); } + // G7: a pane on the move stands off the page, rising as it sets + // out and settling as it lands, its shadow on the other panes' + // bodies only (never a tag, a grip or its own text), moving + // with it. + if (track) |active| if (active.phase != .closing and (active.effect == .slide or active.effect == .zoom or active.effect == .vertical)) { + const steps: f32 = @floatFromInt(@max(1, active.frames() -| 1)); + const u = std.math.clamp(@as(f32, @floatFromInt(active.frame)) / steps, 0, 1); + const lift = @sin(std.math.pi * u); + var box: [4]f32 = .{ std.math.inf(f32), std.math.inf(f32), -std.math.inf(f32), -std.math.inf(f32) }; + var others: [pardes.MAX_PANES][4]f32 = undefined; + var nothers: usize = 0; + for (placed) |part| { + if (part.kind != .tag and part.kind != .rail and part.kind != .body and part.kind != .grip) continue; + const r = bodyPixels(part, layout); + if (part.owner == active.pane) { + box = .{ @min(box[0], r[0]), @min(box[1], r[1]), @max(box[2], r[2]), @max(box[3], r[3]) }; + } else if ((part.kind == .body or part.kind == .rail) and nothers < others.len) { + others[nothers] = r; + nothers += 1; + } + } + if (box[2] > box[0] and lift > 0.01) { + const h: f32 = @floatFromInt(g.cell_h); + const first = g.decor.items.len; + try addSoft(g, gpa, box, Shadow.sigma * h * lift, Shadow.side * h * lift, Shadow.drop * h * lift, @min(Shadow.strength * lift, ceilings.shadow), .{ 0, 0, 0 }, track, win_w, win_h); + try clipToBodies(g, gpa, first, others[0..nothers]); + } + }; } // 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 @@ -9287,6 +9320,50 @@ 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); } +test "a pane on the move casts a shadow on the other panes' bodies alone, most at mid-way" { + 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)); + try std.testing.expect(core.executeBuiltinLine(0, "Theme acme")); + for (0..40) |_| core.update(.tick); + 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 serial = core.panes[1].?.serial; + var strengths: [3]f32 = @splat(0); + for ([_]u16{ 0, 6, 12 }, 0..) |frame, i| { + g.decor.clearRetainingCapacity(); + const moving: pardes.animation.Track = .{ .pane = 1, .serial = serial, .phase = .moving, .effect = .slide, .frame = frame, .frame_count = 13, .from = .{ .x = 0, .y = 1, .w = 40, .h = 23 }, .to = .{ .x = 40, .y = 1, .w = 40, .h = 23 } }; + var groups = makeGroups(&.{moving}, true); + if (!moving.active()) groups = makeGroups(&.{}, true); + try buildDecor(&g, gpa, s, &groups, layout, 800, 480, false); + for (g.decor.items) |item| { + if (item.effect & soft_shadow_bit == 0) continue; + strengths[i] = @max(strengths[i], item.fr); + // Clipped to pane 0's body or rail, never pane 1's, never a tag. + try std.testing.expect(item.clip_x1 >= item.clip_x0); + for (s.regionList()) |region| { + if (region.kind != .tag and region.kind != .grip and !(region.owner == 1 and (region.kind == .body or region.kind == .rail))) continue; + const r = bodyPixels(region, layout); + try std.testing.expect(item.clip_x1 <= r[0] or item.clip_x0 >= r[2] or item.clip_y1 <= r[1] or item.clip_y0 >= r[3]); + } + } + } + // Rising as it sets out, gone as it lands. + try std.testing.expectEqual(@as(f32, 0), strengths[0]); + try std.testing.expect(strengths[1] > 0.05); + try std.testing.expectEqual(@as(f32, 0), strengths[2]); +} + /// What a bundled post pass leaves alone (Post.Frame.spare), in window /// pixels: every tag with its grip, the column and workspace tags, and the /// notices, the chrome the focus indicators live in. A grip, the gap after diff --git a/src/macos.zig b/src/macos.zig index d6daf944..4ff13b23 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -2995,7 +2995,7 @@ test "mac panel mask is a literal normalized grayscale texture" { test "mac panel ABI has the documented compact byte layout" { try std.testing.expectEqual(@as(usize, 16), @sizeOf(PanelBox)); - try std.testing.expectEqual(@as(usize, 44), @sizeOf(PanelTrack)); + try std.testing.expectEqual(@as(usize, 48), @sizeOf(PanelTrack)); try std.testing.expectEqual(@as(usize, 0), @offsetOf(PanelTrack, "serial")); try std.testing.expectEqual(@as(usize, 4), @offsetOf(PanelTrack, "pane")); try std.testing.expectEqual(@as(usize, 5), @offsetOf(PanelTrack, "phase")); diff --git a/src/macos/pardes.h b/src/macos/pardes.h index 29f8a37d..46453429 100644 --- a/src/macos/pardes.h +++ b/src/macos/pardes.h @@ -108,11 +108,12 @@ typedef struct { uint8_t pane; uint8_t phase; uint8_t effect; - uint8_t reserved0; + uint8_t motion; uint16_t frame; uint16_t frame_count; pardes_panel_box_s from; pardes_panel_box_s to; + float launch; } pardes_panel_track_s; #define PARDES_PANEL_OPENING 0u diff --git a/src/pardes.zig b/src/pardes.zig index bbd8e7cd..1f2c3175 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -471,6 +471,8 @@ test { _ = @import("body_layer.zig"); _ = @import("draw.zig"); _ = @import("tag_layer.zig"); + _ = @import("Presentation.zig"); + _ = @import("animation.zig"); } pub const Builtin = builtins.registry.Builtin(); -- cgit v1.3 From dd586f1d8937f67e95504da167216d5413f05262 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 07:11:01 -0300 Subject: G8: a notice leaves quicker than it arrives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notice chip's motion was mostly there (a drop easing out on the Motion flavour, a dissolve easing in, Lift's shadow in tier 4 at its fade). Its dissolve took 300 ms against a 180 ms fall, the wrong way round for §8.1 (leaving quicker than arriving, 100 to 160 ms): MessageDissolve now defaults to 150. The field comments had the two easings swapped; the stack test pins a longer dissolve for the case it checks. docs/config.md and docs/effects.md say so. Tests: both timings in their bands, leaving quicker. Gates as G7 (no golden moved). Feel review: .scratch/render/notices/. --- docs/config.md | 2 +- docs/effects.md | 8 ++++++++ src/Messages.zig | 9 +++++++++ src/config.zig | 8 +++++--- 4 files changed, 23 insertions(+), 4 deletions(-) (limited to 'docs') diff --git a/docs/config.md b/docs/config.md index b3cff3a4..db15289b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -137,7 +137,7 @@ colour whole by half way (graphical frontends slide it out from under the tagline as it fades up; a terminal only fades it in), stays until the next key or click as it always has, then lingers for `MessageLinger` milliseconds (default 800) before it dissolves into the page. `MessageFall` (default 180) and `MessageDissolve` -(default 300) set how long the fall and the dissolve take, also in +(default 150: leaving is quicker than arriving) set how long the fall and the dissolve take, also in milliseconds; each timing is at most 60000, and `Config` reports all three. `MessageLinger 0` with `MessageAnimation off` restores the old behaviour, a message cleared by the very input that follows it. An updated line on a row already showing one swaps diff --git a/docs/effects.md b/docs/effects.md index 58c0098e..2fc68c81 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -41,6 +41,7 @@ why. The verdict is the user's. | G5 lapis | `Theme lapis` (its `decor`) | a native theme with ornament: tag plaques with hard shadows inside their bands (the focused one striped), dots, rail checker, title shadow | .scratch/render/lapis/ (whole window at 1x and 2x, crops, a slide mp4) | pending | | G6 smooth scroll | follows `Motion` (on through the default smooth; `Motion off` lands at once) | a mouse wheel's notch glides, a touchpad follows the finger | .scratch/render/scroll/ (glide-.mp4, quarter speed, strips) | pending | | G7 panel transitions | `PanelSlide`, `PanelZoom`, `PanelVertical`, `PanelDissolve` (off) with `Motion` | a moving pane casts a shadow; a retargeted move keeps its speed; the GUI's dissolve is a crossfade | .scratch/render/transitions/ (slide smooth, bouncy, retargeted; dissolve) | pending | +| G8 notice chips | follows `Motion` and `Lift` | a notice floats in tier 4 on a lift (shadow) while Lift is on, drops in easing out and dissolves easing in, quicker than it came (150 ms against 180) | .scratch/render/notices/ (acme, forge) | pending | ## Motion flavours @@ -176,3 +177,10 @@ its old one over it at 1 - progress; the blend is in sRGB, not linear light (a two-colour mix in linear light needs both in one shader; open point 3). The tty keeps its per-cell swap. GUI golden 18-mid-transition changes (the slide's shadow at frame 6). + +Notes on G8: most of it was there: the drop eases out on the Motion +flavour's spring (sxrrwplk), the dissolve eases in, and a notice floats in +tier 4 with Lift's shadow at its fade while Lift is on, on the bodies +alone. What G8 changed: leaving was slower than arriving (a 300 ms +dissolve against a 180 ms fall); §8.1 wants leaving the quicker, in 100 to +160 ms, so MessageDissolve now defaults to 150 (a test holds both bands). diff --git a/src/Messages.zig b/src/Messages.zig index 7d284e6b..36cd44da 100644 --- a/src/Messages.zig +++ b/src/Messages.zig @@ -768,6 +768,8 @@ test "messages stack, oldest on top, each on its own timing" { const pane = p.panes[p.active].?; try std.testing.expect(p.executeBuiltinLine(p.active, "Verbose off")); try std.testing.expect(p.executeBuiltinLine(p.active, "MessageLinger 160")); + // A dissolve longer than a fall, so the second is caught mid-way. + try std.testing.expect(p.executeBuiltinLine(p.active, "MessageDissolve 300")); // `Verbose off` announced itself before it took effect. pane.msg_len = 0; pane.msg_life = .{}; @@ -968,3 +970,10 @@ test "an idle core wants no wake and a lingering message sleeps to its linger's p.advance(long_due); try std.testing.expectEqual(Pane.MessagePhase.leaving, pane.msg_life.phase); } + +test "a notice leaves quicker than it arrives, each within its §8.1 band" { + const settings: config.Runtime = .{}; + try std.testing.expect(settings.message_dissolve_ms < settings.message_fall_ms); + try std.testing.expect(settings.message_fall_ms >= 150 and settings.message_fall_ms <= 220); + try std.testing.expect(settings.message_dissolve_ms >= 100 and settings.message_dissolve_ms <= 160); +} diff --git a/src/config.zig b/src/config.zig index 43d2cfda..5734cd0c 100644 --- a/src/config.zig +++ b/src/config.zig @@ -658,10 +658,12 @@ pub const Runtime = struct { cursor_blink: bool = true, /// How long a message stays after the key or click that dismisses it. message_linger_ms: u16 = 800, - /// How long its ease-in fall into the row takes. + /// How long its fall into the row takes, easing out (§8.1: arriving, + /// 150 to 220 ms). message_fall_ms: u16 = 180, - /// How long its ease-out dissolve takes. - message_dissolve_ms: u16 = 300, + /// How long its dissolve takes, easing in: leaving is quicker than + /// arriving (§8.1: 100 to 160 ms). + message_dissolve_ms: u16 = 150, /// Where Dump writes and a relative Restore looks; empty is the default /// ($XDG_DATA_HOME/pardes, else ~/.local/share/pardes). dump_dir: Text(limits.host_path_cap) = .{}, -- cgit v1.3 From 99c088e277e7d79d7b4341d3f546c02e7254e107 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 07:17:07 -0300 Subject: G9: SelectionGlow, a soft halo round the body's selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SelectionGlow on|off (off; a pixel shell's, Chrome.selection_glow): each block of selected rows in a pane body casts Lift's soft kind in the selection's own colour, the block its caster (never touched) and a cursor beside it joined to the caster; clipped to the bodies (never a tag or a grip); at most 22% (§8.2), less where the text round it would drop below min(its contrast, 4.5); fading in over 100 ms with an ease-out, on the core's continuous path (shell_continuous); none while panes move. Blocks come from the cells in the theme's selection colour (documented). Tests: the halo's colour, its clip to the body, nothing at its first frame and up to its cap after 100 ms, fading done. Gates as G8. Feel review: .scratch/render/selection/. --- docs/config.md | 2 + docs/effects.md | 13 +++++ src/config.zig | 12 +++- src/detached/wire.zig | 2 +- src/draw.zig | 1 + src/gui/gui.zig | 152 +++++++++++++++++++++++++++++++++++++++++++++++++- src/surface.zig | 2 + 7 files changed, 179 insertions(+), 5 deletions(-) (limited to 'docs') diff --git a/docs/config.md b/docs/config.md index db15289b..29671e4e 100644 --- a/docs/config.md +++ b/docs/config.md @@ -565,6 +565,8 @@ Lift auto a shadow on a light page; on a dark one the others recede Lift off InactiveDim 30 the unfocused panes' text fades 30% toward its ground Motion smooth off, crisp, smooth (the default), bouncy or playful +SelectionGlow on a soft halo of the selection's colour round it in the body, + fading in over 100 ms, never over a tag, a grip or the cursor CursorBlink on the cursor blinks, solid while typing and half a second after, eased at each edge, solid after 10 idle seconds GripWidth 150 the grip's button and the scrollbar under it, percent of the diff --git a/docs/effects.md b/docs/effects.md index 2fc68c81..61b969c2 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -42,6 +42,7 @@ why. The verdict is the user's. | G6 smooth scroll | follows `Motion` (on through the default smooth; `Motion off` lands at once) | a mouse wheel's notch glides, a touchpad follows the finger | .scratch/render/scroll/ (glide-.mp4, quarter speed, strips) | pending | | G7 panel transitions | `PanelSlide`, `PanelZoom`, `PanelVertical`, `PanelDissolve` (off) with `Motion` | a moving pane casts a shadow; a retargeted move keeps its speed; the GUI's dissolve is a crossfade | .scratch/render/transitions/ (slide smooth, bouncy, retargeted; dissolve) | pending | | G8 notice chips | follows `Motion` and `Lift` | a notice floats in tier 4 on a lift (shadow) while Lift is on, drops in easing out and dissolves easing in, quicker than it came (150 ms against 180) | .scratch/render/notices/ (acme, forge) | pending | +| G9 selection glow | `SelectionGlow on\|off` (off) | a soft halo of the selection's colour round each block of it in a body, fading in over 100 ms | .scratch/render/selection/ (forge, acme, lapis; lapis-glow-zoom.png) | pending | ## Motion flavours @@ -184,3 +185,15 @@ tier 4 with Lift's shadow at its fade while Lift is on, on the bodies alone. What G8 changed: leaving was slower than arriving (a 300 ms dissolve against a 180 ms fall); §8.1 wants leaving the quicker, in 100 to 160 ms, so MessageDissolve now defaults to 150 (a test holds both bands). + +Notes on G9: the halo is Lift's soft kind in the selection's own colour, +cast by each block of selected rows in a pane body (the rows that touch), +with the block as its caster, so the selection itself is never touched; +a cursor beside a block joins the caster, so it is never glowed over +either. It stays on bodies (never a tag or a grip), is at most 22% (§8.2: +coloured glows at most 25%) and less where the text round it would drop +below min(its contrast, 4.5); it comes up over 100 ms with an ease-out +(micro-feedback), on the core's continuous path, and is not drawn while +panes move. The blocks are read from the cells painted in the theme's +selection colour, a pixel shell's inference (§1.5, smell 4) until +selections are regions. diff --git a/src/config.zig b/src/config.zig index 5734cd0c..d4efed0e 100644 --- a/src/config.zig +++ b/src/config.zig @@ -121,6 +121,7 @@ pub const leader_path = paths: { if (builtins.capabilities.window_blur) table.set(.WindowBlur, null); if (builtins.capabilities.ligatures) table.set(.Ligatures, null); if (builtins.capabilities.lift) table.set(.CursorBlink, null); + if (builtins.capabilities.lift) table.set(.SelectionGlow, null); if (builtins.capabilities.lift) table.set(.Lift, null); table.set(.Motion, null); table.set(.InactiveDim, null); @@ -656,6 +657,9 @@ pub const Runtime = struct { /// solid while typing and for half a second after, eased at each edge, /// solid again after ten idle seconds. cursor_blink: bool = true, + /// G9: a soft halo round the selection in the body, fading in over + /// 100 ms, a pixel shell's (docs/effects.md). + selection_glow: bool = false, /// How long a message stays after the key or click that dismisses it. message_linger_ms: u16 = 800, /// How long its fall into the row takes, easing out (§8.1: arriving, @@ -874,7 +878,7 @@ pub const Runtime = struct { /// The message timings a setting word sets, each in milliseconds. pub const MessageTime = enum { linger, fall, dissolve }; - pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, syntax_bold, verbose, message_animation, ligatures, cursor_blink }; + pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, syntax_bold, verbose, message_animation, ligatures, cursor_blink, selection_glow }; /// A bundled post pass, a Shadertoy file in shaders/post/: the CRT, and /// G4's bloom, vignette and grain (docs/effects.md). pub const Scene = enum { crt, bloom, vignette, grain }; @@ -931,6 +935,7 @@ pub const Runtime = struct { .{ .word = "MessageAnimation", .action = .{ .toggle = .message_animation } }, .{ .word = "Ligatures", .action = .{ .toggle = .ligatures }, .availability = .ligatures }, .{ .word = "CursorBlink", .action = .{ .toggle = .cursor_blink }, .availability = .lift }, + .{ .word = "SelectionGlow", .action = .{ .toggle = .selection_glow }, .availability = .lift }, .{ .word = "Lift", .action = .lift, .availability = .lift }, .{ .word = "Motion", .action = .motion }, .{ .word = "InactiveDim", .action = .inactive_dim }, @@ -1006,9 +1011,9 @@ pub const Runtime = struct { .toggle => |field| if (field == .ligatures) { if (setting.availability != .ligatures) @compileError("the ligatures toggle must use the ligatures capability"); - } else if (field == .cursor_blink) { + } else if (field == .cursor_blink or field == .selection_glow) { if (setting.availability != .lift) - @compileError("CursorBlink must use the lift capability (a pixel shell's)"); + @compileError("CursorBlink and SelectionGlow must use the lift capability (a pixel shell's)"); } else if (setting.availability != null) @compileError("unconditional toggles cannot carry a backend capability"), else => if (setting.availability != null) @@ -1661,6 +1666,7 @@ pub const Runtime = struct { .message_animation => "MessageAnimation off\n", .ligatures => "Ligatures off\n", .cursor_blink => "CursorBlink off\n", + .selection_glow => "SelectionGlow on\n", }, .shell => "Shell requested (new panes) fish\n", .tagline_size => "TaglineSize 73\n", diff --git a/src/detached/wire.zig b/src/detached/wire.zig index 9f9c716a..3326277d 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -13,7 +13,7 @@ pub const version: u16 = 8; /// The Chrome palette is carried field by field as its declaration lists /// them, so any change to it changes the wire: bump `version`, then this. -const chrome_layout: u64 = 0xf852cb229ae38f51; +const chrome_layout: u64 = 0x7f982e44ef87de72; comptime { @setEvalBranchQuota(10_000); var hash = std.hash.Fnv1a_64.init(); diff --git a/src/draw.zig b/src/draw.zig index 5f14ae21..6c0cc077 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -472,6 +472,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { .rail_px = @intCast(@min(255, @as(u32, p.theme().rail_px orelse 12) * p.settings.grip_width / 100)), .name_ink = chrome.tag_name_fg, .active_name_ink = if (p.settings.focus_tint) chrome.tag_active_name_fg else chrome.tag_name_fg, + .selection_glow = @intFromBool(p.settings.selection_glow), }; if (p.theme().decor) |decor| { s.chrome.decor_dots = decor.page_dots; diff --git a/src/gui/gui.zig b/src/gui/gui.zig index b5da9ad6..f74d5a0a 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -1870,6 +1870,8 @@ const Gui = struct { /// picture follows on a spring (Glide), never the other way round. scroll_notch: i32 = 0, glide: Glide = .{}, + /// G9: the selection's glow blocks as last drawn, for their fade-in. + sel_glow: SelGlow = .{}, /// The cursor's blink (blinkNow): when the frame drawn last arrived, /// a redraw owed for an edge, and this frame's cursor mode and alpha. @@ -4978,7 +4980,7 @@ fn stepGlide(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { const glide = &g.glide; // The glide runs on the core's continuous path: frames every loop, and // a virtual clock carried on. - defer core.shell_continuous = glide.moving(); + defer core.shell_continuous = glide.moving() or g.sel_glow.fading; glide.now_ns = core.now_ns; glide.motion = pardes.animation.Motion.of(core.settings.motion); if (!glide.spring.settled) { @@ -5801,6 +5803,9 @@ fn renderFrame( // G6: what the scroll just done moved the pane's rows by, measured on // this frame against the last (the glide follows it from there). if (g.glide.pending) g.glide.measure(gpa, surface); + g.sel_glow.now_ns = if (core) |p| p.now_ns else c.SDL_GetTicksNS(); + // Off, or while panes move, no glow and nothing fading. + if (surface.chrome.selection_glow == 0 or surface.panelTracks().len != 0) g.sel_glow = .{ .now_ns = g.sel_glow.now_ns }; if (g.glide.moving()) g.glide.offset = g.glide.spring.value(g.glide.now_ns); cell_decor = CellDecor.of(chrome); // Plaques need an opaque window: with WindowOpacity under 100 the cells @@ -8690,6 +8695,8 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g }; const bodies = body_buf[0..nbodies]; try paneDecor(g, gpa, placed, groups, index, chrome, layout, surface.cols, surface.rows, win_w, win_h, track); + // G9, while nothing moves: a halo round the body's selection. + if (index == 0 and groups.tracks == 0 and chrome.selection_glow != 0) try selectionGlow(g, gpa, surface, ceilings, layout, win_w, win_h); // A single spine runs down each column, through every tag and body. if (index == 0) for (regions) |column| { if (column.kind != .column) continue; @@ -8928,6 +8935,99 @@ fn dp(logical: f32) f32 { return @max(1, @round(logical * display_scale)); } +/// G9's state: the selection's blocks last drawn and when each appeared. +const SelGlow = struct { + const Block = struct { box: [4]u16, start_ns: u64 }; + blocks: [32]Block = undefined, + len: usize = 0, + now_ns: u64 = 0, + /// A block is still fading in: the core keeps frames coming. + fading: bool = false, +}; + +/// How long a glow takes to come up (§8.1's micro-feedback: 80 to 120 ms). +const sel_glow_ns: u64 = 100 * std.time.ns_per_ms; + +/// G9: a soft halo of the selection's own colour round each block of it in +/// a pane body (rows of selected cells that touch), fading in over 100 ms +/// with an ease-out. The selection itself is the halo's caster, never +/// touched; so is a cursor beside it; the halo stays on bodies (never a +/// tag or a grip) and at most 22%, less where the text round it would drop +/// below min(its contrast, 4.5). +fn selectionGlow(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, ceilings: LiftCeilings, layout: CellLayout, win_w: f32, win_h: f32) !void { + _ = ceilings; + const chrome = &surface.chrome; + const sel = chrome.sel_bg; + const page = chrome.page orelse bg_default; + const fg = chrome.fg orelse fg_default; + const cap = @min(0.22, lightenCeiling(fg, page, sel)); + var blocks: [32][4]u16 = undefined; + var nblocks: usize = 0; + var bodies: [pardes.MAX_PANES][4]f32 = undefined; + var nbodies: usize = 0; + for (surface.regionList()) |body| { + if (body.kind != .body or body.rect.w == 0) continue; + if (nbodies < bodies.len) { + bodies[nbodies] = bodyPixels(body, layout); + nbodies += 1; + } + var open: ?[4]u16 = null; // x0, y0, x1, y1 of the block being grown + var row = body.rect.y; + while (row < body.rect.y + body.rect.h and row < surface.rows) : (row += 1) { + var x0: ?u16 = null; + var x1: u16 = 0; + var col = body.rect.x; + while (col < body.rect.x + body.rect.w and col < surface.cols) : (col += 1) { + const cell = &surface.cells[@as(usize, row) * surface.cols + col]; + const selected = !cell.default and switch (cell.style.bg) { + .rgb => |rgb| std.mem.eql(u8, &rgb, &sel), + else => false, + }; + if (!selected) continue; + if (x0 == null) x0 = col; + x1 = col + 1; + } + if (x0) |start| { + if (open) |*block| { + block.* = .{ @min(block[0], start), block[1], @max(block[2], x1), row + 1 }; + } else open = .{ start, row, x1, row + 1 }; + } else if (open) |block| { + if (nblocks < blocks.len) blocks[nblocks] = block; + nblocks += 1; + open = null; + } + } + if (open) |block| if (nblocks < blocks.len) { + blocks[nblocks] = block; + nblocks += 1; + }; + } + nblocks = @min(nblocks, blocks.len); + // Each block keeps the start of the one it overlaps from the last frame. + var next: SelGlow = .{ .now_ns = g.sel_glow.now_ns }; + for (blocks[0..nblocks]) |block| { + const start = for (g.sel_glow.blocks[0..g.sel_glow.len]) |old| { + if (old.box[0] < block[2] and block[0] < old.box[2] and old.box[1] < block[3] and block[1] < old.box[3]) break old.start_ns; + } else g.sel_glow.now_ns; + next.blocks[next.len] = .{ .box = block, .start_ns = start }; + next.len += 1; + const t = @as(f32, @floatFromInt(g.sel_glow.now_ns -| start)) / @as(f32, @floatFromInt(sel_glow_ns)); + if (t < 1) next.fading = true; + const u = std.math.clamp(t, 0, 1); + const fade = 1 - (1 - u) * (1 - u) * (1 - u); + var caster: [4]f32 = .{ @as(f32, @floatFromInt(block[0])) * layout.w, @as(f32, @floatFromInt(block[1])) * layout.h, @as(f32, @floatFromInt(block[2])) * layout.w, @as(f32, @floatFromInt(block[3])) * layout.h }; + // A cursor beside the selection is not glowed over: it joins the + // caster. + if (surface.cursor) |cu| if (cu.x + 1 >= block[0] and cu.x <= block[2] and cu.y + 1 >= block[1] and cu.y <= block[3]) { + caster = .{ @min(caster[0], @as(f32, @floatFromInt(cu.x)) * layout.w), @min(caster[1], @as(f32, @floatFromInt(cu.y)) * layout.h), @max(caster[2], @as(f32, @floatFromInt(cu.x + 1)) * layout.w), @max(caster[3], @as(f32, @floatFromInt(cu.y + 1)) * layout.h) }; + }; + const first = g.decor.items.len; + try addSoft(g, gpa, caster, 0.3 * layout.h, 0, 0, cap * fade, sel, null, win_w, win_h); + try clipToBodies(g, gpa, first, bodies[0..nbodies]); + } + g.sel_glow = next; +} + /// A theme's tag plaques (Chrome.decor_box_*), drawn under the cells of /// group `index`: each pane tag, and with the still panes the column and /// workspace tags, becomes a framed box inside its own band with a hard @@ -9364,6 +9464,56 @@ test "a pane on the move casts a shadow on the other panes' bodies alone, most a try std.testing.expectEqual(@as(f32, 0), strengths[2]); } +test "the selection glows round its block, on the body only, fading in over 100 ms" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); + defer core.deinit(); + _ = try core.setTestFile("some text on a line\n" ** 30); + core.settings.selection_glow = true; + pardes.test_api.sync(core); + const s = try core.render(core.scratch.allocator()); + try std.testing.expectEqual(@as(u8, 1), s.chrome.selection_glow); + // Two rows of a selection in the body, as the core paints one. + const body = for (s.regionList()) |region| { + if (region.kind == .body) break region; + } else return error.NoBody; + for (0..2) |dy| for (5..12) |x| { + const cell = &s.cells[(@as(usize, body.rect.y) + 3 + dy) * s.cols + body.rect.x + x]; + cell.default = false; + cell.style.bg = .{ .rgb = s.chrome.sel_bg }; + }; + s.cursor = null; + 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; + g.sel_glow = .{ .now_ns = std.time.ns_per_s }; + var strengths: [2]f32 = .{ 0, 0 }; + for ([_]u64{ 0, 150 * std.time.ns_per_ms }, 0..) |later, i| { + g.decor.clearRetainingCapacity(); + g.sel_glow.now_ns = std.time.ns_per_s + later; + var groups = makeGroups(&.{}, false); + try buildDecor(&g, gpa, s, &groups, fixedCellLayout(&g), 800, 480, true); + for (g.decor.items) |item| { + if (item.effect & soft_shadow_bit == 0) continue; + const rgb: [3]u8 = .{ @intFromFloat(@round(item.br * 255)), @intFromFloat(@round(item.bg * 255)), @intFromFloat(@round(item.bb * 255)) }; + if (!std.mem.eql(u8, &rgb, &s.chrome.sel_bg)) continue; + strengths[i] = @max(strengths[i], item.fr); + // On the body alone. + const r = bodyPixels(body, fixedCellLayout(&g)); + try std.testing.expect(item.clip_x0 >= r[0] - 0.01 and item.clip_x1 <= r[2] + 0.01 and item.clip_y0 >= r[1] - 0.01 and item.clip_y1 <= r[3] + 0.01); + } + try std.testing.expectEqual(@as(usize, 1), g.sel_glow.len); + } + // Just appeared: nothing yet, and still fading; later, up to its cap. + try std.testing.expectEqual(@as(f32, 0), strengths[0]); + try std.testing.expect(strengths[1] > 0.05 and strengths[1] <= 0.22 + 1e-4); + try std.testing.expect(!g.sel_glow.fading); +} + /// What a bundled post pass leaves alone (Post.Frame.spare), in window /// pixels: every tag with its grip, the column and workspace tags, and the /// notices, the chrome the focus indicators live in. A grip, the gap after diff --git a/src/surface.zig b/src/surface.zig index 5b850a8b..7256ecc1 100644 --- a/src/surface.zig +++ b/src/surface.zig @@ -448,6 +448,8 @@ pub const Chrome = struct { decor_title_px: u8 = 2, name_ink: [3]u8 = .{ 0, 0, 0 }, active_name_ink: [3]u8 = .{ 0, 0, 0 }, + /// G9: a pixel shell glows round the body's selection (SelectionGlow). + selection_glow: u8 = 0, }; /// The canonical interface: what a frame of pardes IS. The tty shell writes -- cgit v1.3 From 5526c68beec454cfe607a579723b97b4848e7d3a Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 07:25:32 -0300 Subject: G10: the theme's chrome fades through OKLab, easing in and out ChromeTheme.interpolate mixes each colour in OKLab (colors.mixOklab), so a fade between hues keeps its lightness instead of going dark and muddy mid way; animation.Fade eases it in and out (smooth), and one retargeted mid fade eases out from where it shows, so it never stalls. Still 10 frames (160 ms). The page, text and syntax colours change at once as before. Tests: OKLab round-trips its ends exactly and keeps a red-to-green mid-way brighter than the sRGB mix. Gates as G9 (no golden moved). Feel review: .scratch/render/crossfade/. --- docs/effects.md | 10 +++++++++ src/animation.zig | 11 +++++++++- src/colors.zig | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 84 insertions(+), 2 deletions(-) (limited to 'docs') diff --git a/docs/effects.md b/docs/effects.md index 61b969c2..0e0ad51a 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -43,6 +43,7 @@ why. The verdict is the user's. | G7 panel transitions | `PanelSlide`, `PanelZoom`, `PanelVertical`, `PanelDissolve` (off) with `Motion` | a moving pane casts a shadow; a retargeted move keeps its speed; the GUI's dissolve is a crossfade | .scratch/render/transitions/ (slide smooth, bouncy, retargeted; dissolve) | pending | | G8 notice chips | follows `Motion` and `Lift` | a notice floats in tier 4 on a lift (shadow) while Lift is on, drops in easing out and dissolves easing in, quicker than it came (150 ms against 180) | .scratch/render/notices/ (acme, forge) | pending | | G9 selection glow | `SelectionGlow on\|off` (off) | a soft halo of the selection's colour round each block of it in a body, fading in over 100 ms | .scratch/render/selection/ (forge, acme, lapis; lapis-glow-zoom.png) | pending | +| G10 theme crossfade | always (a theme change) | the chrome's colours fade through OKLab, easing in and out (out when retargeted) | .scratch/render/crossfade/ (forge, lapis, acme cut short by dusk) | pending | ## Motion flavours @@ -197,3 +198,12 @@ below min(its contrast, 4.5); it comes up over 100 ms with an ease-out panes move. The blocks are read from the cells painted in the theme's selection colour, a pixel shell's inference (§1.5, smell 4) until selections are regions. + +Notes on G10: a theme change already faded the chrome (tags, rules, grips, +rails) over 10 frames (160 ms, in §8.1's 150 to 250 for a state change), +mixing sRGB bytes linearly in time. Now each colour goes through OKLab, so +a fade between two hues keeps its lightness instead of dipping dark and +muddy, on a smooth ease-in-out; a change made mid-fade sets off from where +it shows and eases out, so it moves at once instead of stalling. The page, +its text and the syntax colours still change at once with the theme, as +before: fading them would re-resolve every cell each frame. diff --git a/src/animation.zig b/src/animation.zig index 855fef03..713a8a30 100644 --- a/src/animation.zig +++ b/src/animation.zig @@ -1132,6 +1132,9 @@ pub fn Fade(comptime Value: type) type { to: Value, displayed: Value, step: u16 = transition_steps, + /// Set off from partway through another fade: it eases out (moving + /// at once) instead of in and out, so it never stalls (§7.2). + retargeted: bool = false, pub fn init(value: Value) Self { return .{ .from = value, .to = value, .displayed = value }; @@ -1142,19 +1145,25 @@ pub fn Fade(comptime Value: type) type { } pub fn retarget(a: *Self, target: Value) void { + a.retargeted = a.isActive(); a.from = a.displayed; a.to = target; a.step = if (std.meta.eql(a.from, target)) transition_steps else 0; if (a.step == transition_steps) a.displayed = target; } + /// A state change eases in and out (§8.1); a retargeted one out. + const resolution: u16 = 1000; + pub fn advance(a: *Self) void { if (!a.isActive()) return; a.step += 1; + const t = @as(f32, @floatFromInt(a.step)) / @as(f32, @floatFromInt(transition_steps)); + const eased = sample(if (a.retargeted) .out_cubic else .smooth, t); a.displayed = if (a.step == transition_steps) a.to else - Value.interpolate(a.from, a.to, a.step, transition_steps); + Value.interpolate(a.from, a.to, @intFromFloat(@round(eased * resolution)), resolution); } pub fn snap(a: *Self, value: Value) void { diff --git a/src/colors.zig b/src/colors.zig index b44e84f9..8319a112 100644 --- a/src/colors.zig +++ b/src/colors.zig @@ -117,6 +117,65 @@ test "a derived separator or tint keeps the theme's own when it already stands o try std.testing.expect(pushed >= own and own >= plain); } +/// sRGB bytes to OKLab and back (Björn Ottosson's), and a mix in it. +fn toOklab(rgb: [3]u8) [3]f32 { + var l: [3]f32 = undefined; + for (&l, rgb) |*out, v| { + const x = @as(f32, @floatFromInt(v)) / 255; + out.* = if (x <= 0.04045) x / 12.92 else std.math.pow(f32, (x + 0.055) / 1.055, 2.4); + } + const lm = std.math.cbrt(0.4122214708 * l[0] + 0.5363325363 * l[1] + 0.0514459929 * l[2]); + const mm = std.math.cbrt(0.2119034982 * l[0] + 0.6806995451 * l[1] + 0.1073969566 * l[2]); + const sm = std.math.cbrt(0.0883024619 * l[0] + 0.2817188376 * l[1] + 0.6299787005 * l[2]); + return .{ + 0.2104542553 * lm + 0.7936177850 * mm - 0.0040720468 * sm, + 1.9779984951 * lm - 2.4285922050 * mm + 0.4505937099 * sm, + 0.0259040371 * lm + 0.7827717662 * mm - 0.8086757660 * sm, + }; +} + +fn fromOklab(lab: [3]f32) [3]u8 { + const lm = lab[0] + 0.3963377774 * lab[1] + 0.2158037573 * lab[2]; + const mm = lab[0] - 0.1055613458 * lab[1] - 0.0638541728 * lab[2]; + const sm = lab[0] - 0.0894841775 * lab[1] - 1.2914855480 * lab[2]; + const l = lm * lm * lm; + const m = mm * mm * mm; + const s = sm * sm * sm; + const linear: [3]f32 = .{ + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s, + }; + var out: [3]u8 = undefined; + for (&out, linear) |*o, v| { + const x = std.math.clamp(v, 0, 1); + const srgb = if (x <= 0.0031308) x * 12.92 else 1.055 * std.math.pow(f32, x, 1.0 / 2.4) - 0.055; + o.* = @intFromFloat(@round(std.math.clamp(srgb, 0, 1) * 255)); + } + return out; +} + +pub fn mixOklab(a: [3]u8, b: [3]u8, t: f32) [3]u8 { + if (std.mem.eql(u8, &a, &b)) return a; + const x = toOklab(a); + const y = toOklab(b); + return fromOklab(.{ x[0] + (y[0] - x[0]) * t, x[1] + (y[1] - x[1]) * t, x[2] + (y[2] - x[2]) * t }); +} + +test "an OKLab fade holds its ends exactly and keeps a mid-way bright" { + for ([_][3]u8{ .{ 0, 0, 0 }, .{ 255, 255, 255 }, .{ 0x0c, 0x0c, 0x0e }, .{ 0xe8, 0xc4, 0x6a }, .{ 0x1c, 0x51, 0x72 } }) |rgb| { + try std.testing.expectEqual(rgb, fromOklab(toOklab(rgb))); + try std.testing.expectEqual(rgb, mixOklab(rgb, .{ 10, 200, 30 }, 0)); + } + // Red to green through sRGB bytes dips dark at the middle; through + // OKLab it keeps its lightness. + const red: [3]u8 = .{ 255, 0, 0 }; + const green: [3]u8 = .{ 0, 200, 0 }; + const srgb_mid = mix(red, green); + const lab_mid = mixOklab(red, green, 0.5); + try std.testing.expect(relativeLuminance(lab_mid) > relativeLuminance(srgb_mid) * 1.3); +} + pub fn mix(a: [3]u8, b: [3]u8) [3]u8 { var out: [3]u8 = undefined; for (&out, a, b) |*c, x, y| c.* = @intCast((@as(u16, x) + y) / 2); @@ -361,10 +420,14 @@ pub const ChromeTheme = struct { }; } + /// Partway from one chrome to another, each colour through OKLab + /// (G10): a fade keeps its lightness even, never going muddy or dark + /// between two hues as a mix of sRGB bytes does. pub fn interpolate(from: ChromeTheme, to: ChromeTheme, step: u16, steps: u16) ChromeTheme { var out: ChromeTheme = undefined; + const t = @as(f32, @floatFromInt(@min(step, steps))) / @as(f32, @floatFromInt(@max(1, steps))); inline for (@typeInfo(ChromeTheme).@"struct".fields) |field| - @field(out, field.name) = animation.interpolateRgb(@field(from, field.name), @field(to, field.name), step, steps); + @field(out, field.name) = if (step == 0) @field(from, field.name) else if (step >= steps) @field(to, field.name) else mixOklab(@field(from, field.name), @field(to, field.name), t); return out; } }; -- cgit v1.3