From beebcce70ce649c56311574ec3ba4763d76bf1d4 Mon Sep 17 00:00:00 2001 From: elwrcl Date: Thu, 3 Sep 2026 02:20:59 +0300 Subject: [PATCH] ghostty glsl --- .gitignore | 4 +- hosts/copland.ulu.nix | 2 + modules/home/ghostty/cursor-smear.glsl | 173 ++++++++++++++++++ modules/home/ghostty/degauss-sound.py | 164 +++++++++++++++++ modules/home/ghostty/degauss.glsl | 107 +++++++++++ .../home/ghostty/home-degauss-sound.ulu.nix | 43 +++++ .../home/ghostty/home-ghostty-shaders.ulu.nix | 76 ++++++++ 7 files changed, 568 insertions(+), 1 deletion(-) create mode 100644 modules/home/ghostty/cursor-smear.glsl create mode 100644 modules/home/ghostty/degauss-sound.py create mode 100644 modules/home/ghostty/degauss.glsl create mode 100644 modules/home/ghostty/home-degauss-sound.ulu.nix create mode 100644 modules/home/ghostty/home-ghostty-shaders.ulu.nix diff --git a/.gitignore b/.gitignore index b9aee81..a3b0bbd 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,6 @@ !*.md !*.png !*.yml -!*.json \ No newline at end of file +!*.json +!*.glsl +!*.py \ No newline at end of file diff --git a/hosts/copland.ulu.nix b/hosts/copland.ulu.nix index 7bc1b85..7ab0297 100644 --- a/hosts/copland.ulu.nix +++ b/hosts/copland.ulu.nix @@ -28,6 +28,8 @@ in imports = [ config.flake.homeModules.home-base config.flake.homeModules.home-ghostty + config.flake.homeModules.home-ghostty-shaders + config.flake.homeModules.home-degauss-sound config.flake.homeModules.home-nushell config.flake.homeModules.home-nushell-bash-compat config.flake.homeModules.home-nushell-completions diff --git a/modules/home/ghostty/cursor-smear.glsl b/modules/home/ghostty/cursor-smear.glsl new file mode 100644 index 0000000..75a8d95 --- /dev/null +++ b/modules/home/ghostty/cursor-smear.glsl @@ -0,0 +1,173 @@ +///cursor-smear.glsl + +const float DURATION = 0.45; +const float TRAIL_OPACITY = 0.65; +void processEdge(vec2 p, vec2 a, vec2 b, inout float minDist, inout float inside) { + vec2 edge = b - a; + vec2 pa = p - a; + float lenSq = dot(edge, edge); + float invLenSq = 1.0 / lenSq; + + float t = clamp(dot(pa, edge) * invLenSq, 0.0, 1.0); + vec2 diff = pa - edge * t; + minDist = min(minDist, dot(diff, diff)); + + float crossZ = edge.x * pa.y - edge.y * pa.x; + inside = min(inside, step(0.0, crossZ)); +} + +// signed distance field for hexagon (negative inside, positive outside) +float sdHexagon(in vec2 p, in vec2 v0, in vec2 v1, in vec2 v2, in vec2 v3, in vec2 v4, in vec2 v5) { + float minDist = 1e20; + float inside = 1.0; + + processEdge(p, v0, v1, minDist, inside); + processEdge(p, v1, v2, minDist, inside); + processEdge(p, v2, v3, minDist, inside); + processEdge(p, v3, v4, minDist, inside); + processEdge(p, v4, v5, minDist, inside); + processEdge(p, v5, v0, minDist, inside); + + float dist = sqrt(max(minDist, 0.0)); + return mix(dist, -dist, inside); +} + +// signed distance field for rectangle (negative inside, positive outside) +float sdRectangle(in vec2 p, in vec2 center, in vec2 halfSize) { + vec2 d = abs(p - center) - halfSize; + return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0); +} + +// represents cursor as a quad with four corners +struct Quad { + vec2 topLeft; + vec2 topRight; + vec2 bottomLeft; + vec2 bottomRight; +}; + +// construct quad from top-left position and size +Quad getQuad(vec2 pos, vec2 size) { + Quad q; + q.topLeft = pos; + q.topRight = pos + vec2(size.x, 0.0); + q.bottomLeft = pos - vec2(0.0, size.y); + q.bottomRight = pos + vec2(size.x, -size.y); + return q; +} + +// selecting 3 corners from quad based on movement direction +// sel.x: 0=left, 1=right | sel.y: 0=top, 1=bottom +void selectTrailCorners(Quad q, vec2 sel, out vec2 p1, out vec2 p2, out vec2 p3) { + p1 = mix(mix(q.topRight, q.topLeft, sel.x), + mix(q.bottomRight, q.bottomLeft, sel.x), + sel.y); + + p2 = mix(mix(q.topLeft, q.bottomLeft, sel.x), + mix(q.topRight, q.bottomRight, sel.x), + sel.y); + p3 = mix(mix(q.bottomRight, q.topRight, sel.x), + mix(q.bottomLeft, q.topLeft, sel.x), + sel.y); +} + +// select 4 corners from quad based on movement direction +void selectCorners(Quad q, vec2 sel, out vec2 p1, out vec2 p2, out vec2 p3, out vec2 p4) { + selectTrailCorners(q, sel, p1, p2, p3); + + p4 = mix(mix(q.bottomLeft, q.bottomRight, sel.x), + mix(q.topLeft, q.topRight, sel.x), + sel.y); +} + +// cubic ease-out function for smooth animation (expects clamped input) +float easeClamped(float x) { + float t = 1.0 - x; + return 1.0 - t * t * t; +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; + vec4 background = texture(iChannel0, uv); + fragColor = background; + + // an unfocused split still reports cursor changes, and its frames arrive far + // apart, so a trail there is both wrong and jumpy. + if (iFocus == 0) { + return; + } + + // calculate animation progress with easing + float baseProgress = clamp((iTime - iTimeCursorChange) / DURATION, 0.0, 1.0); + + // skip further work when animation is complete + if (baseProgress >= 1.0) { + return; + } + + // precompute reused values + float invResY = 1.0 / iResolution.y; + float scale = 2.0 * invResY; + float aaWidth = scale; + vec2 normOffset = iResolution.xy * invResY; + + // normalize cursor positions and sizes to screen-independent coordinates + vec2 currentPos = iCurrentCursor.xy * scale - normOffset; + vec2 previousPos = iPreviousCursor.xy * scale - normOffset; + vec2 currentSize = iCurrentCursor.zw * scale; + vec2 previousSize = iPreviousCursor.zw * scale; + + // determine movement direction and construct cursor quads + vec2 deltaPos = currentPos - previousPos; + Quad currentCursor = getQuad(currentPos, currentSize); + Quad previousCursor = getQuad(previousPos, previousSize); + vec2 selector = step(vec2(0.0), deltaPos); + + // select corners based on movement direction + vec2 currP1, currP2, currP3, currP4; + vec2 prevP1, prevP2, prevP3; + selectCorners(currentCursor, selector, currP1, currP2, currP3, currP4); + selectTrailCorners(previousCursor, selector, prevP1, prevP2, prevP3); + + float easedProgress = easeClamped(baseProgress); + float stretchedProgress = min(baseProgress * 2.0, 1.0); + float easedProgressDouble = easeClamped(stretchedProgress); + + // create trailing effect by moving diagonal point slower + vec2 trailP1 = mix(prevP1, currP1, easedProgress); + vec2 trailP2 = mix(prevP2, currP2, easedProgressDouble); + vec2 trailP3 = mix(prevP3, currP3, easedProgressDouble); + + // compute hexagon SDF and convert to alpha with antialiasing + vec2 normCoord = fragCoord * scale - normOffset; + float sdfHex = sdHexagon(normCoord, trailP1, trailP2, currP2, currP4, currP3, trailP3); + float alpha = 1.0 - smoothstep(-aaWidth, aaWidth, sdfHex); + + // computing current cursor SDF + vec2 halfCurrentSize = currentSize * 0.5; + vec2 currentCenter = currentPos + vec2(halfCurrentSize.x, -halfCurrentSize.y); + float sdfCurrentCursor = sdRectangle(normCoord, currentCenter, halfCurrentSize); + + // calculating line length (distance between cursor centers) + vec2 previousCenter = previousPos + vec2(previousSize.x * 0.5, -previousSize.y * 0.5); + // max() because a cursor change that does not move it (a recolour, say) + // gives a zero-length line, and smoothstep with equal edges is undefined. + float lineLength = max(distance(currentCenter, previousCenter), 1e-4); + + // cursor already carries the palette accent, so the trail needs no color + // of its own and never fights the theme. + vec3 trailColor = iCurrentCursorColor.rgb; + + // fade along the length, squared so the tail + float distFromCursor = distance(normCoord, currentCenter); + float lengthFade = 1.0 - smoothstep(0.0, lineLength, distFromCursor); + float timeFade = 1.0 - baseProgress * baseProgress; + float strength = TRAIL_OPACITY * lengthFade * lengthFade * timeFade; + + // blend trail color with background + vec3 originalColor = fragColor.rgb; + fragColor.rgb = mix(fragColor.rgb, trailColor, alpha * strength); + + // remove trail where it overlaps with current cursor + fragColor.rgb = mix(fragColor.rgb, originalColor, step(sdfCurrentCursor, 0.0)); +} diff --git a/modules/home/ghostty/degauss-sound.py b/modules/home/ghostty/degauss-sound.py new file mode 100644 index 0000000..cf384ef --- /dev/null +++ b/modules/home/ghostty/degauss-sound.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +""" +degauss-sound "thwoonngg" every time. + + degauss-sound # play it + degauss-sound -o out.wav # write a file instead + degauss-sound --seed 42 # reproducible, for tuning + degauss-sound --duration 1.5 # match your shader's DURATION +""" + +import argparse +import array +import math +import os +import random +import subprocess +import sys +import tempfile +import wave + +SR = 48000 +TABLE_BITS = 12 +TABLE_SIZE = 1 << TABLE_BITS +TABLE_MASK = TABLE_SIZE - 1 +SINE = [math.sin(2.0 * math.pi * i / TABLE_SIZE) for i in range(TABLE_SIZE)] + + +def synth(duration, rng): + n = int(SR * duration) + out = array.array("d", bytes(8 * n)) + + # randomized tube personality + f0 = rng.uniform(46.0, 74.0) + glide = rng.uniform(3.0, 14.0) + h2 = rng.uniform(0.30, 0.60) + h3 = rng.uniform(0.12, 0.32) + h4 = rng.uniform(0.00, 0.09) + h5 = rng.uniform(0.03, 0.15) + + warble_hz = rng.uniform(5.5, 10.5) + warble_depth = rng.uniform(0.28, 0.55) + wander_hz = rng.uniform(1.3, 3.4) + wander_depth = rng.uniform(0.4, 1.1) + + decay = rng.uniform(2.1, 3.4) + rattle_amt = rng.uniform(0.025, 0.095) + rattle_decay = rng.uniform(6.0, 14.0) + thump_f = rng.uniform(33.0, 54.0) + thump_amt = rng.uniform(0.30, 0.70) + thump_decay = rng.uniform(11.0, 22.0) + second_kick = rng.random() < 0.35 + kick_at = rng.uniform(0.22, 0.45) + kick_amt = rng.uniform(0.25, 0.55) + + # phase accumulators + scale = TABLE_SIZE / SR + ph = 0.0 + ph_warble = 0.0 + ph_wander = 0.0 + ph_thump = 0.0 + dt = 1.0 / SR + inv_dur = 1.0 / duration + + peak = 0.0 + for i in range(n): + t = i * dt + k = t * inv_dur + + # coil tone, gliding down + f = f0 - glide * k + ph += f * scale + p = int(ph) + tone = SINE[p & TABLE_MASK] + tone += h2 * SINE[(p << 1) & TABLE_MASK] + tone += h3 * SINE[(p * 3) & TABLE_MASK] + tone += h4 * SINE[(p << 2) & TABLE_MASK] + tone += h5 * SINE[(p * 5) & TABLE_MASK] + + # warble, itself slowly wandering + ph_wander += wander_hz * scale + ph_warble += (warble_hz + wander_depth * SINE[int(ph_wander) & TABLE_MASK]) * scale + warble = 1.0 - warble_depth + warble_depth * SINE[int(ph_warble) & TABLE_MASK] + + # envelope: near-instant attack, exponential decay + env = math.exp(-decay * t) * (1.0 - math.exp(-220.0 * t)) + if second_kick: + d = t - kick_at + if d > 0.0: + env += kick_amt * math.exp(-decay * 1.6 * d) * (1.0 - math.exp(-160.0 * d)) + + # opening clunk + ph_thump += thump_f * scale + thump = thump_amt * SINE[int(ph_thump) & TABLE_MASK] * math.exp(-thump_decay * t) + + # shadow-mask rattle + rattle = rattle_amt * (rng.random() * 2.0 - 1.0) * math.exp(-rattle_decay * t) + + s = tone * warble * env + thump + rattle + out[i] = s + a = s if s >= 0.0 else -s + if a > peak: + peak = a + + # normalize, then fade the tail so it never clicks + gain = 0.85 / peak if peak > 0.0 else 0.0 + fade = int(SR * 0.04) + pcm = array.array("h", bytes(2 * n)) + for i in range(n): + s = out[i] * gain + if i >= n - fade: + s *= (n - i) / fade + v = int(s * 32767.0) + pcm[i] = 32767 if v > 32767 else (-32768 if v < -32768 else v) + return pcm + + +def write_wav(path, pcm): + with wave.open(path, "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(SR) + w.writeframes(pcm.tobytes()) + + +def play(path, volume): + for cmd in (["pw-play", "--volume=%s" % volume, path], + ["paplay", path], + ["aplay", "-q", path]): + try: + return subprocess.run(cmd, stderr=subprocess.DEVNULL).returncode + except FileNotFoundError: + continue + print("degauss-sound: no player found (pw-play/paplay/aplay)", file=sys.stderr) + return 1 + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("-o", "--out", help="write a wav here instead of playing") + ap.add_argument("-d", "--duration", type=float, default=1.5, + help="seconds; match your shader's DURATION (default 1.5)") + ap.add_argument("-s", "--seed", type=int, help="reproducible output") + ap.add_argument("-v", "--volume", default="0.5", help="pw-play volume") + args = ap.parse_args() + + rng = random.Random(args.seed) + pcm = synth(args.duration, rng) + + if args.out: + write_wav(args.out, pcm) + return 0 + + tmpdir = os.environ.get("XDG_RUNTIME_DIR") or tempfile.gettempdir() + fd, path = tempfile.mkstemp(suffix=".wav", dir=tmpdir) + os.close(fd) + try: + write_wav(path, pcm) + return play(path, args.volume) + finally: + os.unlink(path) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/modules/home/ghostty/degauss.glsl b/modules/home/ghostty/degauss.glsl new file mode 100644 index 0000000..87603d3 --- /dev/null +++ b/modules/home/ghostty/degauss.glsl @@ -0,0 +1,107 @@ +///degauss.glsl +const float BOOT_DELAY = 0.45; + +// How hard the coil hits. +const float WOBBLE_AMP = 0.055; +const float WAVE_FREQ = 9.0; +const float WAVE_SPEED = 3.2; +const float WAVE_GLIDE = 0.35; +const float WARBLE_HZ = 7.0; +const float WARBLE_DEPTH = 0.25; +const float CHROMA_AMP = 0.016; +const float FLASH_AMP = 0.55; +const float JITTER_AMP = 0.006; +const float SQUEEZE_AMP = 0.075; +const float BAND_SWEEPS = 2.0; +const bool JUMP_DEGAUSS = false; +const float CURSOR_JUMP = 0.60; + +#define TAU 6.28318530718 +float envelope(float t) { + if (t <= 0.0 || t >= DURATION) return 0.0; + float k = t / DURATION; + float attack = 1.0 - exp(-45.0 * t); + float ring = exp(-2.3 * k); + float tail = 1.0 - k * k * k * k; + return attack * ring * tail; +} +float coilPhase(float t) { + float k = clamp(t / DURATION, 0.0, 1.0); + return WAVE_SPEED * t * (1.0 - 0.5 * WAVE_GLIDE * k); +} + +float hash11(float x) { + return fract(sin(x * 78.233) * 43758.5453); +} +vec4 sampleTerm(vec2 uv) { + vec2 halfTexel = 0.5 / iResolution.xy; + return texture(iChannel0, clamp(uv, halfTexel, 1.0 - halfTexel)); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; + + float amp = 0.0; + float t = 0.0; + + float boot = iTime - BOOT_DELAY; + float bootAmp = envelope(boot); + if (bootAmp > amp) { amp = bootAmp; t = boot; } + + if (JUMP_DEGAUSS) { + float jump = abs(iCurrentCursor.y - iPreviousCursor.y); + float elapsed = iTime - iTimeCursorChange; + if (jump > CURSOR_JUMP * iResolution.y) { + float e = envelope(elapsed); + if (e > amp) { amp = e; t = elapsed; } + } + } + + if (amp <= 0.0001) { + fragColor = texture(iChannel0, uv); + return; + } + + // coil's own tremble, so the picture pulses along with the warble in the + // sound instead of sliding smoothly under it. + amp *= 1.0 - WARBLE_DEPTH + WARBLE_DEPTH * sin(t * TAU * WARBLE_HZ); + + float ph = coilPhase(t); + + // magnetic distortion + vec2 p = uv - 0.5; + + float wave = sin(p.y * WAVE_FREQ * TAU - ph * TAU); + float wave2 = sin(p.y * WAVE_FREQ * 2.3 * TAU + ph * 0.62 * TAU); + float warp = wave * 0.7 + wave2 * 0.3; + + // vertical breathing + p.y /= 1.0 + amp * SQUEEZE_AMP * sin(t * TAU * 3.0); + p *= 1.0 - amp * 0.04 * sin(t * TAU * 2.0); + + // per-scanline jitter + float line = floor(uv.y * iResolution.y); + float jitter = (hash11(line + floor(t * 90.0)) - 0.5) * 2.0; + + p.x += amp * (WOBBLE_AMP * warp + JITTER_AMP * jitter); + + vec2 duv = p + 0.5; + + // misconvergence + float ca = amp * CHROMA_AMP * (0.5 + abs(warp)); + vec4 cr = sampleTerm(duv + vec2(ca, 0.0)); + vec4 cg = sampleTerm(duv); + vec4 cb = sampleTerm(duv - vec2(ca, 0.0)); + + vec4 col = vec4(cr.r, cg.g, cb.b, cg.a); + + // coil pulse sweeping down the tube + float bandY = 1.0 - fract(t / DURATION * BAND_SWEEPS); + float band = exp(-pow((uv.y - bandY) * 9.0, 2.0)); + + // background-opacity to solid for the length of the flash. + float glow = amp * FLASH_AMP * (0.25 + 0.75 * band); + col.rgb += glow * (iForegroundColor * 0.22 + col.rgb * 0.9); + + fragColor = col; +} diff --git a/modules/home/ghostty/home-degauss-sound.ulu.nix b/modules/home/ghostty/home-degauss-sound.ulu.nix new file mode 100644 index 0000000..5b58354 --- /dev/null +++ b/modules/home/ghostty/home-degauss-sound.ulu.nix @@ -0,0 +1,43 @@ +{ ... }: +{ + flake.homeModules.home-degauss-sound = + { pkgs, lib, ... }: + let + duration = "1.5"; + variants = 8; + generatorSource = lib.concatStringsSep "\n" ( + lib.drop 1 (lib.splitString "\n" (builtins.readFile ./degauss-sound.py)) + ); + + generator = pkgs.writers.writePython3Bin "degauss-gen" { + flakeIgnore = [ + "E501" + "W291" + ]; + } generatorSource; + + samples = pkgs.runCommandLocal "degauss-samples" { nativeBuildInputs = [ generator ]; } '' + mkdir -p $out + for i in $(seq 1 ${toString variants}); do + degauss-gen --seed "$i" --duration ${duration} --out "$out/degauss-$i.wav" + done + ''; + + degaussSound = pkgs.writeShellApplication { + name = "degauss-sound"; + runtimeInputs = [ pkgs.pipewire ]; + text = '' + n=$(( (RANDOM % ${toString variants}) + 1 )) + exec pw-play --volume="''${DEGAUSS_VOLUME:-0.35}" "${samples}/degauss-$n.wav" + ''; + }; + in + { + home.packages = [ degaussSound ]; + programs.nushell.extraConfig = lib.mkBefore '' + if $nu.is-interactive and ($env.TERM_PROGRAM? == "ghostty") and ($env.ZELLIJ? | is-empty) { + job spawn { ^${lib.getExe degaussSound} } | ignore + } + ''; + }; +} diff --git a/modules/home/ghostty/home-ghostty-shaders.ulu.nix b/modules/home/ghostty/home-ghostty-shaders.ulu.nix new file mode 100644 index 0000000..6f75c98 --- /dev/null +++ b/modules/home/ghostty/home-ghostty-shaders.ulu.nix @@ -0,0 +1,76 @@ +{ ... }: +{ + flake.homeModules.home-ghostty-shaders = + { config, pkgs, ... }: + let + preamble = pkgs.writeText "ghostty-shader-preamble.glsl" '' + #version 330 core + #define CURSORSTYLE_BLOCK 0 + #define CURSORSTYLE_BLOCK_HOLLOW 1 + #define CURSORSTYLE_BAR 2 + #define CURSORSTYLE_UNDERLINE 3 + #define CURSORSTYLE_LOCK 4 + 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 sampler2D iChannel0; + uniform vec4 iCurrentCursor; + uniform vec4 iPreviousCursor; + uniform vec4 iCurrentCursorColor; + uniform vec4 iPreviousCursorColor; + uniform vec4 iCurrentCursorStyle; + uniform vec4 iPreviousCursorStyle; + uniform vec4 iCursorVisible; + uniform float iTimeCursorChange; + uniform float iTimeFocus; + uniform int iFocus; + uniform vec3 iPalette[256]; + uniform vec3 iBackgroundColor; + uniform vec3 iForegroundColor; + uniform vec3 iCursorColor; + uniform vec3 iCursorText; + uniform vec3 iSelectionBackgroundColor; + uniform vec3 iSelectionForegroundColor; + out vec4 _ghosttyFragColor; + #line 1 + ''; + + entryPoint = pkgs.writeText "ghostty-shader-entry.glsl" '' + void main() { mainImage(_ghosttyFragColor, gl_FragCoord.xy); } + ''; + + compiled = + name: src: + pkgs.runCommandLocal "ghostty-shader-${name}.glsl" + { + nativeBuildInputs = [ pkgs.glslang ]; + } + '' + cat ${preamble} ${src} ${entryPoint} > wrapped.frag + glslangValidator -S frag wrapped.frag + cp ${src} $out + ''; + + shaderDir = "${config.xdg.configHome}/ghostty/shaders"; + in + { + xdg.configFile."ghostty/shaders/cursor-smear.glsl".source = + compiled "cursor-smear" ./cursor-smear.glsl; + xdg.configFile."ghostty/shaders/degauss.glsl".source = compiled "degauss" ./degauss.glsl; + + programs.ghostty.settings = { + custom-shader = [ + "${shaderDir}/cursor-smear.glsl" + "${shaderDir}/degauss.glsl" + ]; + custom-shader-animation = true; + }; + }; +}