Skip to content

Commit 5627ef8

Browse files
committed
feat(ghostty): add stellar cursor shaders
1 parent 5bb2e7d commit 5627ef8

3 files changed

Lines changed: 955 additions & 108 deletions

File tree

.config/ghostty/config

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ window-inherit-font-size = true
77
mouse-hide-while-typing = true
88

99
custom-shader = ~/.config/ghostty/cursor-blaze.glsl
10+
custom-shader = ~/.config/ghostty/quiet-star.glsl
11+
custom-shader-animation = true
1012

1113
# PS1 / LS_COLORS theme
1214
# Mirrors the xterm-256 colors used in home/.bashrc and home/.profile:

.config/ghostty/cursor-blaze.glsl

Lines changed: 184 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -1,130 +1,206 @@
1-
float ease(float x) {
2-
return pow(1.0 - x, 10.0);
3-
}
4-
5-
float sdBox(in vec2 p, in vec2 xy, in vec2 b)
1+
// Cursor Blaze
2+
//
3+
// A coronal cursor treatment that complements Stellar Dynamics:
4+
// - a shrinking white-blue cursor comet
5+
// - photospheric-white sparks on cursor movement
6+
// - a cool corona aura and restrained refraction
7+
// - faint scanlines and a vignette
8+
//
9+
// Ghostty exposes the terminal as iChannel0 and supplies cursor uniforms.
10+
11+
#define PI 3.14159265358979323846
12+
13+
// These are shared in spirit with quiet-star.glsl: a cool white corona
14+
// surrounding a slightly warmer stellar photosphere.
15+
const vec3 DEEP_NAVY = vec3(0.012, 0.027, 0.058);
16+
const vec3 CORONAL_BLUE = vec3(0.320, 0.600, 0.940);
17+
const vec3 CORONA_WHITE = vec3(0.720, 0.850, 1.000);
18+
const vec3 PHOTOSPHERE_WHITE = vec3(1.000, 0.940, 0.700);
19+
20+
float saturate(float value)
621
{
7-
vec2 d = abs(p - xy) - b;
8-
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
22+
return clamp(value, 0.0, 1.0);
923
}
1024

11-
float getSdfRectangle(in vec2 p, in vec2 xy, in vec2 b)
25+
float hash11(float value)
1226
{
13-
vec2 d = abs(p - xy) - b;
14-
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
27+
value = fract(value * 0.1031);
28+
value *= value + 33.33;
29+
value *= value + value;
30+
return fract(value);
1531
}
16-
// Based on Inigo Quilez's 2D distance functions article: https://iquilezles.org/articles/distfunctions2d/
17-
// Potencially optimized by eliminating conditionals and loops to enhance performance and reduce branching
18-
float seg(in vec2 p, in vec2 a, in vec2 b, inout float s, float d) {
19-
vec2 e = b - a;
20-
vec2 w = p - a;
21-
vec2 proj = a + e * clamp(dot(w, e) / dot(e, e), 0.0, 1.0);
22-
float segd = dot(p - proj, p - proj);
23-
d = min(d, segd);
24-
25-
float c0 = step(0.0, p.y - a.y);
26-
float c1 = 1.0 - step(0.0, p.y - b.y);
27-
float c2 = 1.0 - step(0.0, e.x * w.y - e.y * w.x);
28-
float allCond = c0 * c1 * c2;
29-
float noneCond = (1.0 - c0) * (1.0 - c1) * (1.0 - c2);
30-
float flip = mix(1.0, -1.0, step(0.5, allCond + noneCond));
31-
s *= flip;
32-
return d;
33-
}
34-
35-
float getSdfParallelogram(in vec2 p, in vec2 v0, in vec2 v1, in vec2 v2, in vec2 v3) {
36-
float s = 1.0;
37-
float d = dot(p - v0, p - v0);
3832

39-
d = seg(p, v0, v3, s, d);
40-
d = seg(p, v1, v0, s, d);
41-
d = seg(p, v2, v1, s, d);
42-
d = seg(p, v3, v2, s, d);
43-
44-
return s * sqrt(d);
33+
float smootherstep(float value)
34+
{
35+
value = saturate(value);
36+
return value * value * value * (value * (value * 6.0 - 15.0) + 10.0);
4537
}
4638

47-
vec2 normalize(vec2 value, float isPosition) {
48-
return (value * 2.0 - (iResolution.xy * isPosition)) / iResolution.y;
39+
vec2 cursorCenter(vec4 cursor)
40+
{
41+
return vec2(cursor.x + cursor.z * 0.5, cursor.y - cursor.w * 0.5);
4942
}
5043

51-
float blend(float t)
44+
float segmentDistance(vec2 point, vec2 start, vec2 end, out float along)
5245
{
53-
float sqr = t * t;
54-
return sqr / (2.0 * (sqr - t) + 1.0);
46+
vec2 segment = end - start;
47+
along = saturate(dot(point - start, segment) / max(dot(segment, segment), 0.0001));
48+
return length(point - (start + segment * along));
5549
}
5650

57-
float antialising(float distance) {
58-
return 1. - smoothstep(0., normalize(vec2(2., 2.), 0.).x, distance);
51+
float roundedBoxDistance(vec2 point, vec2 center, vec2 halfSize, float radius)
52+
{
53+
vec2 distanceToEdge = abs(point - center) - halfSize + radius;
54+
return length(max(distanceToEdge, 0.0))
55+
+ min(max(distanceToEdge.x, distanceToEdge.y), 0.0)
56+
- radius;
5957
}
6058

61-
float determineStartVertexFactor(vec2 a, vec2 b) {
62-
// Conditions using step
63-
float condition1 = step(b.x, a.x) * step(a.y, b.y); // a.x < b.x && a.y > b.y
64-
float condition2 = step(a.x, b.x) * step(b.y, a.y); // a.x > b.x && a.y < b.y
65-
66-
// If neither condition is met, return 1 (else case)
67-
return 1.0 - max(condition1, condition2);
68-
}
69-
vec2 getRectangleCenter(vec4 rectangle) {
70-
return vec2(rectangle.x + (rectangle.z / 2.), rectangle.y - (rectangle.w / 2.));
59+
vec3 terminalEmission(vec2 uv, vec2 offset, vec3 background)
60+
{
61+
vec3 sampleColor = texture(iChannel0, clamp(uv + offset, 0.0, 1.0)).rgb;
62+
float ink = smoothstep(0.08, 0.70, length(sampleColor - background));
63+
return max(sampleColor - background, 0.0) * ink;
7164
}
7265

73-
const vec4 TRAIL_COLOR = vec4(0.2, 0.8, 1.0, 1.0); // neon blue
74-
const vec4 CURRENT_CURSOR_COLOR = TRAIL_COLOR;
75-
const vec4 PREVIOUS_CURSOR_COLOR = TRAIL_COLOR;
76-
const vec4 TRAIL_COLOR_ACCENT = vec4(0.0, 0.4, 1.0, 1.0); // deeper blue
77-
const float DURATION = .5;
78-
const float OPACITY = .2;
79-
8066
void mainImage(out vec4 fragColor, in vec2 fragCoord)
8167
{
82-
#if !defined(WEB)
83-
fragColor = texture(iChannel0, fragCoord.xy / iResolution.xy);
84-
#endif
85-
//Normalization for fragCoord to a space of -1 to 1;
86-
vec2 vu = normalize(fragCoord, 1.);
87-
vec2 offsetFactor = vec2(-.5, 0.5);
88-
89-
//Normalization for cursor position and size;
90-
//cursor xy has the postion in a space of -1 to 1;
91-
//zw has the width and height
92-
vec4 currentCursor = vec4(normalize(iCurrentCursor.xy, 1.), normalize(iCurrentCursor.zw, 0.));
93-
vec4 previousCursor = vec4(normalize(iPreviousCursor.xy, 1.), normalize(iPreviousCursor.zw, 0.));
94-
95-
//When drawing a parellelogram between cursors for the trail i need to determine where to start at the top-left or top-right vertex of the cursor
96-
float vertexFactor = determineStartVertexFactor(currentCursor.xy, previousCursor.xy);
97-
float invertedVertexFactor = 1.0 - vertexFactor;
98-
99-
//Set every vertex of my parellogram
100-
vec2 v0 = vec2(currentCursor.x + currentCursor.z * vertexFactor, currentCursor.y - currentCursor.w);
101-
vec2 v1 = vec2(currentCursor.x + currentCursor.z * invertedVertexFactor, currentCursor.y);
102-
vec2 v2 = vec2(previousCursor.x + currentCursor.z * invertedVertexFactor, previousCursor.y);
103-
vec2 v3 = vec2(previousCursor.x + currentCursor.z * vertexFactor, previousCursor.y - previousCursor.w);
104-
105-
vec4 newColor = vec4(fragColor);
106-
107-
float progress = blend(clamp((iTime - iTimeCursorChange) / DURATION, 0.0, 1));
108-
float easedProgress = ease(progress);
109-
110-
//Distance between cursors determine the total length of the parallelogram;
111-
vec2 centerCC = getRectangleCenter(currentCursor);
112-
vec2 centerCP = getRectangleCenter(previousCursor);
113-
float lineLength = distance(centerCC, centerCP);
114-
float distanceToEnd = distance(vu.xy, centerCC);
115-
float alphaModifier = distanceToEnd / (lineLength * (easedProgress));
116-
117-
if (alphaModifier > 1.0) { // this change fixed it for me.
118-
alphaModifier = 1.0;
68+
vec2 resolution = iResolution.xy;
69+
vec2 uv = fragCoord / resolution;
70+
vec2 pixel = 1.0 / resolution;
71+
vec4 source = texture(iChannel0, uv);
72+
vec3 color = source.rgb;
73+
74+
vec2 current = cursorCenter(iCurrentCursor);
75+
vec2 previous = cursorCenter(iPreviousCursor);
76+
vec2 movement = current - previous;
77+
float movementLength = length(movement);
78+
float cellSize = max(max(iCurrentCursor.z, iCurrentCursor.w), 1.0);
79+
float cursorAge = max(iTime - iTimeCursorChange, 0.0);
80+
81+
// A subtle phosphor bloom around bright terminal glyphs. Keeping this
82+
// small preserves sharp text while still giving colours a soft halo.
83+
vec2 bloomNear = pixel * 1.5;
84+
vec2 bloomFar = pixel * 3.5;
85+
vec3 bloom = vec3(0.0);
86+
bloom += terminalEmission(uv, vec2(bloomNear.x, 0.0), iBackgroundColor);
87+
bloom += terminalEmission(uv, vec2(-bloomNear.x, 0.0), iBackgroundColor);
88+
bloom += terminalEmission(uv, vec2(0.0, bloomNear.y), iBackgroundColor);
89+
bloom += terminalEmission(uv, vec2(0.0, -bloomNear.y), iBackgroundColor);
90+
bloom += terminalEmission(uv, vec2(bloomFar.x, bloomFar.y), iBackgroundColor);
91+
bloom += terminalEmission(uv, vec2(-bloomFar.x, bloomFar.y), iBackgroundColor);
92+
bloom += terminalEmission(uv, vec2(bloomFar.x, -bloomFar.y), iBackgroundColor);
93+
bloom += terminalEmission(uv, vec2(-bloomFar.x, -bloomFar.y), iBackgroundColor);
94+
color += bloom * 0.018;
95+
96+
// Pull the old end of the trail towards the new cursor. Short movements
97+
// disappear quickly, while longer jumps linger just enough to stay
98+
// readable.
99+
float movementCells = movementLength / cellSize;
100+
float trailLifetime = mix(0.30, 0.52, smoothstep(0.5, 10.0, movementCells));
101+
float trailProgress = smootherstep(cursorAge / trailLifetime);
102+
float trailAlive = 1.0 - smoothstep(trailLifetime * 0.58, trailLifetime, cursorAge);
103+
vec2 trailStart = mix(previous, current, trailProgress);
104+
float along;
105+
float trailDistance = segmentDistance(fragCoord, trailStart, current, along);
106+
float trailWidth = cellSize * mix(0.38, 0.16, trailProgress);
107+
108+
// Taper and dim the old end so the trail reads as a comet rather than a
109+
// uniformly thick beam.
110+
float trailTaper = mix(0.28, 1.0, smootherstep(along));
111+
float localTrailWidth = trailWidth * trailTaper;
112+
float tailFade = mix(0.20, 1.0, smootherstep(along));
113+
float trailCore = 1.0
114+
- smoothstep(localTrailWidth * 0.12, localTrailWidth * 0.55, trailDistance);
115+
float trailBeam = 1.0
116+
- smoothstep(localTrailWidth * 0.45, localTrailWidth * 1.50, trailDistance);
117+
float trailAura = 1.0
118+
- smoothstep(localTrailWidth, localTrailWidth * 4.5, trailDistance);
119+
float shimmer = 0.90 + 0.10 * sin(along * 18.0 - iTime * 9.0);
120+
vec3 trailColor = mix(CORONAL_BLUE, CORONA_WHITE, smootherstep(along));
121+
trailColor = mix(
122+
trailColor,
123+
PHOTOSPHERE_WHITE,
124+
0.18 * sin(along * PI)
125+
);
126+
color += trailColor
127+
* (trailCore * 0.78 + trailBeam * 0.25 + trailAura * 0.10)
128+
* trailAlive
129+
* tailFade
130+
* shimmer;
131+
132+
// A few deterministic particles split gently away from the beam. The
133+
// cursor-change timestamp acts as a stable random seed for each movement.
134+
vec2 direction = movement / max(movementLength, 0.0001);
135+
vec2 normal = vec2(-direction.y, direction.x);
136+
float movementGate = smoothstep(cellSize * 0.20, cellSize * 0.90, movementLength);
137+
float sparkLife = 1.0 - smoothstep(0.12, 0.48, cursorAge);
138+
float eventSeed = floor(iTimeCursorChange * 120.0);
139+
for (int index = 0; index < 5; index++) {
140+
float id = float(index);
141+
float seedA = hash11(eventSeed + id * 17.17);
142+
float seedB = hash11(eventSeed + id * 41.73 + 9.2);
143+
float seedC = hash11(eventSeed + id * 73.91 + 2.8);
144+
vec2 sparkPosition = mix(previous, current, seedA);
145+
sparkPosition += normal * (seedB - 0.5) * cursorAge * (28.0 + 52.0 * seedC);
146+
sparkPosition -= direction * cursorAge * (8.0 + 22.0 * seedA);
147+
float sparkRadius = mix(2.2, 0.45, saturate(cursorAge / 0.48));
148+
float spark = 1.0 - smoothstep(sparkRadius * 0.25, sparkRadius, distance(fragCoord, sparkPosition));
149+
float twinkle = 0.78 + 0.22 * sin(iTime * 18.0 + id * 3.1);
150+
color += mix(CORONA_WHITE, PHOTOSPHERE_WHITE, seedB)
151+
* spark
152+
* sparkLife
153+
* movementGate
154+
* twinkle
155+
* 0.72;
119156
}
120157

121-
float sdfCursor = getSdfRectangle(vu, currentCursor.xy - (currentCursor.zw * offsetFactor), currentCursor.zw * 0.5);
122-
float sdfTrail = getSdfParallelogram(vu, v0, v1, v2, v3);
123-
124-
newColor = mix(newColor, TRAIL_COLOR_ACCENT, 1.0 - smoothstep(sdfTrail, -0.01, 0.001));
125-
newColor = mix(newColor, TRAIL_COLOR, antialising(sdfTrail));
126-
127-
newColor = mix(fragColor, newColor, 1.0 - alphaModifier);
128-
fragColor = mix(newColor, fragColor, step(sdfCursor, 0));
129-
158+
// An always-on aura outlines the actual cursor. It follows block, bar, and
159+
// underline cursors because it uses Ghostty's supplied cursor dimensions.
160+
vec2 cursorHalfSize = max(iCurrentCursor.zw * 0.5, vec2(0.75));
161+
float cursorDistance = roundedBoxDistance(
162+
fragCoord,
163+
current,
164+
cursorHalfSize,
165+
min(2.5, min(cursorHalfSize.x, cursorHalfSize.y))
166+
);
167+
float cursorOutside = step(0.0, cursorDistance);
168+
float cursorAura = (1.0 - smoothstep(0.0, 18.0, cursorDistance)) * cursorOutside;
169+
float cursorEdge = 1.0 - smoothstep(0.2, 1.8, abs(cursorDistance));
170+
float heartbeat = 0.86 + 0.14 * sin(iTime * 4.0);
171+
color += CORONA_WHITE * cursorAura * 0.11 * heartbeat;
172+
color += mix(CORONA_WHITE, PHOTOSPHERE_WHITE, 0.45)
173+
* cursorEdge
174+
* 0.30;
175+
176+
// Locally split RGB channels near an active trail. The cool coronal
177+
// fringe stays visible while moving but disappears while reading output.
178+
float radialDistance = distance(fragCoord, current);
179+
vec2 radial = (fragCoord - current) / max(radialDistance, 1.0);
180+
vec2 prismOffset = radial * pixel * 1.35;
181+
vec3 refracted = color;
182+
refracted.r = texture(iChannel0, clamp(uv + prismOffset, 0.0, 1.0)).r;
183+
refracted.b = texture(iChannel0, clamp(uv - prismOffset, 0.0, 1.0)).b;
184+
float prismMask = trailAlive
185+
* movementGate
186+
* (1.0 - smoothstep(20.0, 150.0, radialDistance))
187+
* 0.30;
188+
color = mix(color, refracted + (color - source.rgb), prismMask);
189+
190+
// A nearly invisible energy ripple in background pixels makes the cursor
191+
// feel luminous without tinting text.
192+
float backgroundMask = 1.0 - smoothstep(0.025, 0.16, length(source.rgb - iBackgroundColor));
193+
float angle = atan(fragCoord.y - current.y, fragCoord.x - current.x);
194+
float ripple = 0.5 + 0.5 * sin(radialDistance * 0.055 - iTime * 2.2 + angle * 3.0);
195+
float ambient = (1.0 - smoothstep(20.0, 330.0, radialDistance)) * ripple * backgroundMask;
196+
color += mix(DEEP_NAVY, CORONAL_BLUE, ripple) * ambient * 0.012;
197+
198+
// Finish with faint moving scanlines and a soft vignette.
199+
float scanline = 0.5 + 0.5 * sin(fragCoord.y * PI + iTime * 2.0);
200+
color *= 0.986 + scanline * 0.014;
201+
vec2 vignetteUv = uv * (1.0 - uv.yx);
202+
float vignette = pow(saturate(vignetteUv.x * vignetteUv.y * 18.0), 0.10);
203+
color *= mix(0.90, 1.0, vignette);
204+
205+
fragColor = vec4(clamp(color, 0.0, 1.0), source.a);
130206
}

0 commit comments

Comments
 (0)