aboutsummaryrefslogtreecommitdiff
path: root/dot_config/ghostty/shaders/crisp_cursor_tail.glsl
blob: a99a3ba79126583a87730914a8a2a0b19ace723f (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
// crisp_cursor_tail_v3.glsl
//
// A deliberately crisp Ghostty cursor trail:
// - no blur
// - no bloom or additive blending
// - the real cursor is never enlarged
// - several same-size cursor silhouettes fade behind it
//
// Requires Ghostty 1.3.0 or newer.

const float DURATION = 0.200;

// Strong enough to be clearly visible without additive glow.
const float MAX_OPACITY = 0.80;

const int TRAIL_COPIES = 13;

// Maximum trail length in approximate cursor-cell lengths.
const float MAX_TRAIL_CELLS = 3.5;

// Ignore tiny sub-cell position changes.
const float MIN_MOVE_CELLS = 0.20;

// Ghostty cannot tell the shader whether a move came from typing or navigation.
// Suppressing one-cell horizontal advances is the dependable approximation.
const bool SUPPRESS_SINGLE_CELL_HORIZONTAL = true;
const float CELL_TOLERANCE = 0.20;

// Suppress the large last-column -> first-column jump produced by line wrap.
const float WRAP_MIN_HORIZONTAL_CELLS = 4.0;

// Do not animate stale previous/current cursor state immediately after focus is
// restored. This also covers delayed frames from an unfocused surface.
const float REFOCUS_GUARD_TIME = 0.060;

// Higher values make the far end fade more strongly.
const float SPATIAL_FADE_POWER = 0.65;

// false: animate horizontal, vertical, and diagonal movement.
// true: only animate left/right movement.
const bool HORIZONTAL_ONLY = false;

vec3 srgbToLinear(vec3 c) {
    return mix(
        c / 12.92,
        pow((c + 0.055) / 1.055, vec3(2.4)),
        step(vec3(0.04045), c)
    );
}

// Convert pixel coordinates to the normalized coordinate system used by the
// known-working Ghostty cursor shaders. Positions use isPosition=1.0;
// dimensions use isPosition=0.0.
vec2 toNormalized(vec2 value, float isPosition) {
    return (value * 2.0 - iResolution.xy * isPosition) / iResolution.y;
}

float rectangleSdf(vec2 point, vec2 center, vec2 halfSize) {
    vec2 d = abs(point - center) - halfSize;
    return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
}

float rectangleMask(vec2 point, vec2 center, vec2 halfSize) {
    // Hard edge: intentionally no antialiasing or blur.
    return step(rectangleSdf(point, center, halfSize), 0.0);
}

float easeOutCubic(float x) {
    float y = 1.0 - x;
    return 1.0 - y * y * y;
}

void mainImage(out vec4 fragColor, in vec2 fragCoord) {
#if !defined(WEB)
    fragColor = texture(iChannel0, fragCoord.xy / iResolution.xy);
#endif

    // Ghostty deliberately renders unfocused surfaces less frequently. Drawing
    // from their stale cursor uniforms causes detached or hollow-looking trails.
    if (iFocus <= 0 || (iTime - iTimeFocus) < REFOCUS_GUARD_TIME) {
        return;
    }

    if (iResolution.x <= 0.0 || iResolution.y <= 0.0 ||
        any(lessThanEqual(iCurrentCursor.zw, vec2(0.0))) ||
        any(lessThanEqual(iPreviousCursor.zw, vec2(0.0)))) {
        return;
    }

    vec2 point = toNormalized(fragCoord, 1.0);

    vec4 currentCursor = vec4(
        toNormalized(iCurrentCursor.xy, 1.0),
        toNormalized(iCurrentCursor.zw, 0.0)
    );

    vec4 previousCursor = vec4(
        toNormalized(iPreviousCursor.xy, 1.0),
        toNormalized(iPreviousCursor.zw, 0.0)
    );

    // Ghostty cursor.xy is the left/top corner.
    vec2 offsetFactor = vec2(-0.5, 0.5);
    vec2 currentCenter =
        currentCursor.xy - currentCursor.zw * offsetFactor;
    vec2 previousCenter =
        previousCursor.xy - previousCursor.zw * offsetFactor;

    vec2 movement = previousCenter - currentCenter;
    float movementLength = length(movement);

    if (movementLength <= 0.000001) {
        return;
    }

    if (HORIZONTAL_ONLY && abs(movement.y) > 0.00001) {
        return;
    }

    vec2 direction = movement / movementLength;

    // Approximate one cell along the direction of travel.
    float cellLength =
        abs(direction.x) * currentCursor.z +
        abs(direction.y) * currentCursor.w;
    cellLength = max(cellLength, 0.000001);

    if ((movementLength / cellLength) < MIN_MOVE_CELLS) {
        return;
    }

    float horizontalCells = abs(movement.x) / currentCursor.z;
    float verticalCells = abs(movement.y) / currentCursor.w;

    bool singleCellHorizontal =
        verticalCells < CELL_TOLERANCE &&
        abs(horizontalCells - 1.0) <= CELL_TOLERANCE;
    if (SUPPRESS_SINGLE_CELL_HORIZONTAL && singleCellHorizontal) {
        return;
    }

    bool likelyLineWrap =
        abs(verticalCells - 1.0) <= CELL_TOLERANCE &&
        horizontalCells >= WRAP_MIN_HORIZONTAL_CELLS;
    if (likelyLineWrap) {
        return;
    }

    float elapsed = iTime - iTimeCursorChange;
    if (elapsed < 0.0) {
        return;
    }
    float progress = clamp(elapsed / DURATION, 0.0, 1.0);

    if (progress >= 1.0) {
        return;
    }

    // Linear persistence keeps the trail clearly visible for most of its
    // lifetime. The previous cubic curve became almost invisible too quickly.
    float remaining = 1.0 - progress;

    // Keep opacity strong through the first half, then fade smoothly.
    float timeOpacity = pow(remaining, 0.85);

    float cappedLength =
        min(movementLength, MAX_TRAIL_CELLS * cellLength);

    vec2 cappedPreviousCenter =
        currentCenter + direction * cappedLength;

    float trailAlpha = 0.0;

    for (int index = 1; index <= TRAIL_COPIES; ++index) {
        float distanceRank =
            float(index) / float(TRAIL_COPIES);

        // At t=0, copies span current -> previous. As time advances they all
        // collapse toward current, leaving the real cursor unchanged.
        vec2 copyCenter = mix(
            currentCenter,
            cappedPreviousCenter,
            distanceRank * remaining
        );

        float copyMask = rectangleMask(
            point,
            copyCenter,
            currentCursor.zw * 0.5
        );

        // Nearest copies are strongest; distant copies are progressively faint.
        float spatialFade = pow(
            1.0 - distanceRank * 0.82,
            SPATIAL_FADE_POWER
        );

        float copyAlpha =
            MAX_OPACITY * spatialFade * timeOpacity;

        // max(), not addition: overlapping copies never become a bright glow.
        trailAlpha = max(
            trailAlpha,
            copyMask * copyAlpha
        );
    }

    // Cut out the actual cursor rectangle so its apparent size and contrast are
    // controlled entirely by Ghostty, not by this shader.
    float currentCursorMask = rectangleMask(
        point,
        currentCenter,
        currentCursor.zw * 0.5
    );
    trailAlpha *= 1.0 - currentCursorMask;

    vec4 trailColor = vec4(
        srgbToLinear(iCurrentCursorColor.rgb),
        iCurrentCursorColor.a
    );

    fragColor = mix(
        fragColor,
        trailColor,
        trailAlpha * trailColor.a
    );
}