ispyx 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +183 -2
  3. package/config.example.json +15 -0
  4. package/package.json +47 -4
  5. package/src/core/RESIDENT.md +45 -0
  6. package/src/core/auth.js +129 -0
  7. package/src/core/guard.js +287 -0
  8. package/src/core/image.js +117 -0
  9. package/src/core/ipc.js +139 -0
  10. package/src/core/launcher.js +70 -0
  11. package/src/core/mode.js +17 -0
  12. package/src/core/motion.js +57 -0
  13. package/src/core/panel/README.md +70 -0
  14. package/src/core/panel/index.js +144 -0
  15. package/src/core/panel/page.html +434 -0
  16. package/src/core/resident.js +271 -0
  17. package/src/core/server.js +832 -0
  18. package/src/effects/appicon.js +46 -0
  19. package/src/effects/scan.js +850 -0
  20. package/src/macos-entry.js +16 -0
  21. package/src/platform/CONTRACT.md +131 -0
  22. package/src/platform/darwin/PROTOCOL.md +82 -0
  23. package/src/platform/darwin/hotkey.js +41 -0
  24. package/src/platform/darwin/index.js +118 -0
  25. package/src/platform/darwin/keys.js +49 -0
  26. package/src/platform/darwin/layer.js +87 -0
  27. package/src/platform/darwin/overlay.js +83 -0
  28. package/src/platform/darwin/panel.js +25 -0
  29. package/src/platform/darwin/rpc-worker.js +132 -0
  30. package/src/platform/darwin/rpc.js +146 -0
  31. package/src/platform/fake/index.js +162 -0
  32. package/src/platform/index.js +13 -0
  33. package/src/platform/win32/auth-dialog.ps1 +140 -0
  34. package/src/platform/win32/autostart.js +54 -0
  35. package/src/platform/win32/dialog.js +41 -0
  36. package/src/platform/win32/hotkey.js +77 -0
  37. package/src/platform/win32/index.js +31 -0
  38. package/src/platform/win32/keys.js +31 -0
  39. package/src/platform/win32/layer.js +120 -0
  40. package/src/platform/win32/overlay.js +265 -0
  41. package/src/platform/win32/pump.js +37 -0
  42. package/src/platform/win32/tray-worker.js +172 -0
  43. package/src/platform/win32/tray.js +103 -0
  44. package/src/platform/win32/win32.js +577 -0
  45. package/src/server.js +14 -0
@@ -0,0 +1,850 @@
1
+ // Screenshot animation: after a capture an eye opens at the top of the captured region, looks down and follows
2
+ // a glowing green scan line that sweeps the region from top to bottom. Two click-through, per-pixel-alpha windows;
3
+ // pixels are drawn in software into DIB sections, so nothing here depends on GDI+.
4
+ import platform from '../platform/index.js';
5
+
6
+ const clamp = (v, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v));
7
+ const mix = (a, b, t) => a + (b - a) * t;
8
+ const smooth = (t) => t * t * (3 - 2 * t);
9
+
10
+ /** The scan line: a bright green edge at the bottom with a glow trailing above it. */
11
+ export function renderBand(px, width, height, stride = width) {
12
+ const edgeFade = Math.max(8, width * 0.06);
13
+ for (let y = 0; y < height; y++) {
14
+ const d = height - 1 - y; // distance above the leading edge
15
+ const k = d < 3 ? 1 : 0.6 * Math.exp(-d / 16);
16
+ for (let x = 0; x < width; x++) {
17
+ const e = smooth(clamp(Math.min(x, width - 1 - x) / edgeFade));
18
+ const hit = Math.exp(-(((x - width / 2) / (width * 0.035)) ** 2)) * Math.exp(-d / 6); // where the laser lands
19
+ const a = clamp(k * e + 0.9 * hit);
20
+ const core = clamp((d < 3 ? 0.75 : 0) + hit);
21
+ let r = mix(40, 255, core), g = mix(230, 255, core), b = mix(110, 255, core);
22
+ if (d < 1.2) { r = 14; g = 70; b = 40; } // dark leading edge, so the line also shows on white
23
+ const i = (y * stride + x) * 4;
24
+ px[i] = b * a; px[i + 1] = g * a; px[i + 2] = r * a; px[i + 3] = a * 255;
25
+ }
26
+ }
27
+ }
28
+
29
+ /**
30
+ * The eye: white eyeball, green iris, dark pupil, a soft highlight. `open` 0..1 is the lid (0 = closed),
31
+ * (gx, gy) in -1..1 is where it looks.
32
+ */
33
+ /** Where the pupil centre is inside the eye sprite (sprite pixels). */
34
+ const eyeR = (size) => size / 2 - 7; // the rest of the sprite holds the outline ring and its glow
35
+ export function pupilPos(size, gx, gy) {
36
+ const R = eyeR(size);
37
+ return { x: size / 2 + gx * 0.36 * R + gx * 0.07 * R, y: size / 2 + gy * 0.40 * R + gy * 0.09 * R };
38
+ }
39
+
40
+ export function renderEye(px, size, open, gx, gy, glow = 0) {
41
+ const c = size / 2;
42
+ const R = eyeR(size);
43
+ const lid = Math.max(open, 0.02);
44
+ const ix = c + gx * 0.36 * R, iy = c + gy * 0.40 * R;
45
+ const rxI = 0.46 * R * (1 - 0.2 * Math.abs(gx)), ryI = 0.46 * R * (1 - 0.2 * Math.abs(gy));
46
+ const px0 = ix + gx * 0.07 * R, py0 = iy + gy * 0.09 * R;
47
+ const rxP = rxI * 0.56, ryP = ryI * 0.56;
48
+ const hx = ix + 0.5 * rxI, hy = iy - 0.38 * ryI, hr = 0.55 * rxI;
49
+ for (let y = 0; y < size; y++) {
50
+ for (let x = 0; x < size; x++) {
51
+ const dx = x + 0.5 - c, dy = y + 0.5 - c;
52
+ const sd = Math.hypot(dx, dy / lid) - R;
53
+ const a = clamp(0.5 - sd);
54
+ const i = (y * size + x) * 4;
55
+ // outline ring (dark, so the white eyeball shows on a white screen) with a soft green glow outside it
56
+ const darkA = clamp(2.5 + 0.5 - sd) * 0.92;
57
+ const glowA = 0.7 * Math.exp(-(((sd - 3.5) / 1.6) ** 2));
58
+ if (a <= 0) {
59
+ if (sd > 7 || (darkA <= 0 && glowA < 0.01)) { px[i] = px[i + 1] = px[i + 2] = px[i + 3] = 0; continue; }
60
+ // glow under dark, both over transparent (premultiplied)
61
+ const ga = glowA * (1 - darkA);
62
+ px[i] = (110 * ga + 22 * darkA); px[i + 1] = (255 * ga + 70 * darkA); px[i + 2] = (60 * ga + 40 * darkA); px[i + 3] = (ga + darkA) * 255;
63
+ continue;
64
+ }
65
+ const n = Math.hypot(dx, dy) / R;
66
+ const low = smooth(clamp((dy / R - 0.1) / 0.7));
67
+ let r = 254 - 30 * n ** 3 - 22 * low, g = 255 - 24 * n ** 3 - 4 * low, b = 254 - 30 * n ** 3 - 20 * low;
68
+ const di = Math.hypot((x + 0.5 - ix) / rxI, (y + 0.5 - iy) / ryI);
69
+ const ai = clamp((1 - di) * rxI);
70
+ if (ai > 0) {
71
+ const light = 0.22 * (((x + 0.5 - ix) / rxI) * 0.6 - ((y + 0.5 - iy) / ryI) * 0.6);
72
+ const t = clamp(1 - di + light);
73
+ r = mix(r, mix(8, 120, t), ai); g = mix(g, mix(120, 245, t), ai); b = mix(b, mix(30, 110, t), ai);
74
+ const dp = Math.hypot((x + 0.5 - px0) / rxP, (y + 0.5 - py0) / ryP);
75
+ const ap = clamp((1 - dp) * rxP);
76
+ if (ap > 0) {
77
+ const t2 = clamp(dp);
78
+ r = mix(r, mix(2, 10, t2), ap); g = mix(g, mix(18, 60, t2), ap); b = mix(b, mix(6, 24, t2), ap);
79
+ if (glow > 0) { // the laser charges in the pupil
80
+ const h = glow * (1 - t2) ** 1.5 * ap;
81
+ r = mix(r, 150, h); g = mix(g, 255, h); b = mix(b, 190, h);
82
+ }
83
+ }
84
+ }
85
+ const ah = clamp((1 - Math.hypot(x + 0.5 - hx, y + 0.5 - hy) / hr) * hr * 0.5) * 0.42;
86
+ if (ah > 0) { r = mix(r, 255, ah); g = mix(g, 255, ah); b = mix(b, 255, ah); }
87
+ // eye over its dark outline: at the rim (a < 1) the ring shows through
88
+ const ra = a + darkA * (1 - a);
89
+ px[i] = b * a + 40 * darkA * (1 - a); px[i + 1] = g * a + 70 * darkA * (1 - a); px[i + 2] = r * a + 22 * darkA * (1 - a); px[i + 3] = ra * 255;
90
+ }
91
+ }
92
+ }
93
+
94
+ /**
95
+ * The scanner light: a triangle from the pupil (ax, ay) to the scan line, which runs from x1 to x2 at height ey
96
+ * (all in buffer pixels). Brighter towards the line, bright rims along the two edges, faint horizontal striations.
97
+ * The buffer is cleared first.
98
+ */
99
+ export function drawWedge(px, width, height, ax, ay, x1, x2, ey) {
100
+ px.fill(0, 0, width * height * 4);
101
+ const span = ey - ay;
102
+ if (Math.abs(span) < 2) return;
103
+ const y0 = Math.max(0, Math.ceil(Math.min(ay, ey))), y1 = Math.min(height - 1, Math.floor(Math.max(ay, ey)));
104
+ for (let y = y0; y <= y1; y++) {
105
+ const sn = (y + 0.5 - ay) / span; // 0 at the pupil, 1 at the line
106
+ if (sn <= 0) continue;
107
+ const xl = ax + (x1 - ax) * sn, xr = ax + (x2 - ax) * sn;
108
+ const wd = xr - xl;
109
+ if (wd < 1) continue;
110
+ const stripe = 0.86 + 0.14 * Math.sin(y * 0.7);
111
+ const gain = 0.08 + 0.2 * sn;
112
+ const xs = Math.max(0, Math.floor(xl - 1)), xe = Math.min(width - 1, Math.ceil(xr + 1));
113
+ for (let x = xs; x <= xe; x++) {
114
+ const e = Math.min(x + 0.5 - xl, xr - (x + 0.5)); // distance to the nearest rim
115
+ const inside = clamp(e + 0.5);
116
+ if (inside <= 0) continue;
117
+ const rim = 0.55 * Math.exp(-((e / 2.2) ** 2)) * (0.4 + 0.6 * sn);
118
+ const a = clamp((gain * stripe + rim) * inside);
119
+ const i = (y * width + x) * 4;
120
+ px[i] = 60 * a; px[i + 1] = 255 * a; px[i + 2] = 110 * a; px[i + 3] = a * 255;
121
+ }
122
+ }
123
+ }
124
+
125
+ /** A green frame on the rectangle's edges: dark outer line (shows on white), bright core, glow inward, thick corner brackets. */
126
+ export function renderFrame(px, width, height) {
127
+ const reach = 34, arm = Math.min(60, Math.floor(Math.min(width, height) / 3));
128
+ px.fill(0, 0, width * height * 4);
129
+ for (let y = 0; y < height; y++) {
130
+ const dy = Math.min(y, height - 1 - y);
131
+ const full = dy < reach;
132
+ for (let x = 0; x < width; x++) {
133
+ const dx = Math.min(x, width - 1 - x);
134
+ if (!full && dx >= reach && width - reach - 1 > x) { x = width - reach - 1; continue; } // skip the empty middle
135
+ const d = Math.min(dx, dy);
136
+ const nearCorner = (dx < arm && dy < reach) || (dy < arm && dx < reach);
137
+ const thick = nearCorner ? 9 : 5; // corner brackets are thicker
138
+ let r, g, bl, a;
139
+ if (d < 2.5) { r = 14; g = 50; bl = 28; a = 0.95; } // dark outline
140
+ else if (d < 2.5 + thick) { const t = (d - 2.5) / thick; r = mix(150, 60, t); g = 255; bl = mix(190, 110, t); a = 1; } // bright core
141
+ else { const k = Math.exp(-(d - 2.5 - thick) / 10); r = 60; g = 255; bl = 110; a = 0.5 * k; }
142
+ if (a < 0.01) continue;
143
+ const i = (y * width + x) * 4;
144
+ px[i] = bl * a; px[i + 1] = g * a; px[i + 2] = r * a; px[i + 3] = a * 255;
145
+ }
146
+ }
147
+ }
148
+
149
+ const RIPPLE_COLORS = { left: [60, 255, 110], right: [40, 200, 255], middle: [255, 190, 40] }; // RGB
150
+ export const RIPPLE_MS = 430, RIPPLE_GAP_MS = 120;
151
+ const RIPPLE_SIZE = 132;
152
+
153
+ /** Expanding rings (one per click of a multi-click) with a dark outline, plus a dot where the button went down. */
154
+ export function renderRipple(px, size, t, rings, rgb) {
155
+ px.fill(0, 0, size * size * 4);
156
+ const c = size / 2;
157
+ for (let y = 0; y < size; y++) {
158
+ for (let x = 0; x < size; x++) {
159
+ const r = Math.hypot(x + 0.5 - c, y + 0.5 - c);
160
+ let pr = 0, pg = 0, pb = 0, pa = 0; // premultiplied accumulator
161
+ const add = (cr, cg, cb, a) => { if (a <= 0) return; pr = cr * a + pr * (1 - a); pg = cg * a + pg * (1 - a); pb = cb * a + pb * (1 - a); pa = a + pa * (1 - a); };
162
+ for (let k = 0; k < rings; k++) {
163
+ const u = (t - k * RIPPLE_GAP_MS) / RIPPLE_MS;
164
+ if (u <= 0 || u >= 1) continue;
165
+ const radius = 6 + 52 * (1 - (1 - u) ** 3);
166
+ const fade = (1 - u) ** 1.2;
167
+ const d = Math.abs(r - radius);
168
+ add(14, 50, 28, clamp(2.6 - Math.abs(d - 2.6)) * 0.7 * fade); // dark outline hugging the ring
169
+ add(rgb[0], rgb[1], rgb[2], clamp(2.2 - d) * fade); // bright ring
170
+ add(rgb[0], rgb[1], rgb[2], 0.18 * clamp(radius - r) * fade * 0.07); // faint fill
171
+ }
172
+ const u0 = t / 260;
173
+ if (u0 < 1) { // the press point
174
+ add(14, 50, 28, clamp(5.5 - r) * 0.8 * (1 - u0));
175
+ add(255, 255, 255, clamp(3.6 - r) * (1 - u0));
176
+ }
177
+ if (pa <= 0) continue;
178
+ const i = (y * size + x) * 4;
179
+ px[i] = pb; px[i + 1] = pg; px[i + 2] = pr; px[i + 3] = pa * 255;
180
+ }
181
+ }
182
+ }
183
+
184
+ /** A rectangle outline (dark line outside, bright line inside) with alpha `a`; coordinates are inclusive pixel edges. */
185
+ function drawBorder(px, W, H, x0, y0, x1, y1, rgb, a, thick) {
186
+ const set = (x, y, r, g, b, al) => {
187
+ if (x < 0 || y < 0 || x >= W || y >= H || al <= 0) return;
188
+ const i = (y * W + x) * 4;
189
+ if (al * 255 <= px[i + 3]) return;
190
+ px[i] = b * al; px[i + 1] = g * al; px[i + 2] = r * al; px[i + 3] = al * 255;
191
+ };
192
+ const ring = (inset, width, c, al) => {
193
+ const a0 = x0 + inset, b0 = y0 + inset, a1 = x1 - inset, b1 = y1 - inset;
194
+ for (let k = 0; k < width; k++) {
195
+ for (let x = a0 + k; x <= a1 - k; x++) { set(x, b0 + k, c[0], c[1], c[2], al); set(x, b1 - k, c[0], c[1], c[2], al); }
196
+ for (let y = b0 + k; y <= b1 - k; y++) { set(a0 + k, y, c[0], c[1], c[2], al); set(a1 - k, y, c[0], c[1], c[2], al); }
197
+ }
198
+ };
199
+ ring(0, 2, [14, 50, 28], 0.85 * a);
200
+ ring(2, thick, rgb, a);
201
+ }
202
+
203
+ const easeOut = (t) => 1 - (1 - t) ** 1.5;
204
+
205
+ /**
206
+ * A sonar ring of radius `r` around (cx, cy): bright thin front with a dark outer edge (so it shows on white) and a
207
+ * glow trailing `trail` px inward. Only the annulus is visited. `k` scales the strength (0..1).
208
+ */
209
+ function drawRing(px, W, H, cx, cy, r, trail, k) {
210
+ if (k <= 0.01) return;
211
+ const rout = r + 3.5, rin = Math.max(0, r - trail);
212
+ const y0 = Math.max(0, Math.floor(cy - rout)), y1 = Math.min(H - 1, Math.ceil(cy + rout));
213
+ for (let y = y0; y <= y1; y++) {
214
+ const dy = y + 0.5 - cy;
215
+ if (Math.abs(dy) > rout) continue;
216
+ const xo = Math.sqrt(rout * rout - dy * dy);
217
+ const xi = Math.abs(dy) < rin ? Math.sqrt(rin * rin - dy * dy) : 0;
218
+ for (const [xa, xb] of [[cx + xi, cx + xo], [cx - xo, cx - xi]]) {
219
+ for (let x = Math.max(0, Math.floor(xa)); x <= Math.min(W - 1, Math.ceil(xb)); x++) {
220
+ const d = r - Math.hypot(x + 0.5 - cx, dy); // > 0 behind the front
221
+ let al, red, grn, blu;
222
+ if (d < -3.2) continue;
223
+ if (d < 0) { al = 0.7 * clamp(1.6 - Math.abs(d + 1.7)) * k; red = 14; grn = 50; blu = 28; } // dark outer edge
224
+ else {
225
+ const core = clamp(2.2 - d);
226
+ al = Math.max(core, 0.5 * Math.exp(-d / 20)) * k;
227
+ red = mix(60, 255, core); grn = 255; blu = mix(110, 255, core);
228
+ }
229
+ if (al <= 0.01) continue;
230
+ const i = (y * W + x) * 4;
231
+ if (al * 255 <= px[i + 3]) continue;
232
+ px[i] = blu * al; px[i + 1] = grn * al; px[i + 2] = red * al; px[i + 3] = al * 255;
233
+ }
234
+ }
235
+ }
236
+ }
237
+
238
+ const KEY_LABELS = { ctrl: 'Ctrl', control: 'Ctrl', shift: 'Shift', alt: 'Alt', win: 'Win', windows: 'Win', meta: 'Win', cmd: 'Win', super: 'Win', enter: 'Enter', return: 'Enter', esc: 'Esc', escape: 'Esc', tab: 'Tab', space: 'Space', backspace: 'Backspace', delete: 'Del', del: 'Del', insert: 'Ins', ins: 'Ins', home: 'Home', end: 'End', pageup: 'PgUp', pgup: 'PgUp', pagedown: 'PgDn', pgdn: 'PgDn', left: '\u2190', up: '\u2191', right: '\u2192', down: '\u2193' };
239
+ /** "ctrl+a ctrl+c" -> [['Ctrl', 'A'], ['Ctrl', 'C']] */
240
+ export const keyLabels = (keys) => keys.trim().split(/\s+/).filter(Boolean).map((chord) => chord.split('+').filter(Boolean).map((k) => KEY_LABELS[k.toLowerCase()] ?? (k.length === 1 ? k.toUpperCase() : k[0].toUpperCase() + k.slice(1))));
241
+
242
+ const TRAIL_LIFE_MS = 750, TRAIL_FADE_MS = 320;
243
+
244
+ /** Draws a thick soft line segment into a premultiplied buffer, keeping the stronger alpha where strokes overlap. */
245
+ function stroke(px, width, height, x0, y0, x1, y1, radius, rgb, alpha) {
246
+ const n = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0)));
247
+ const R = Math.ceil(radius + 1);
248
+ for (let s = 0; s <= n; s++) {
249
+ const cx = x0 + ((x1 - x0) * s) / n, cy = y0 + ((y1 - y0) * s) / n;
250
+ const xa = Math.max(0, Math.floor(cx - R)), xb = Math.min(width - 1, Math.ceil(cx + R));
251
+ const ya = Math.max(0, Math.floor(cy - R)), yb = Math.min(height - 1, Math.ceil(cy + R));
252
+ for (let y = ya; y <= yb; y++) {
253
+ for (let x = xa; x <= xb; x++) {
254
+ const a = clamp(radius + 0.5 - Math.hypot(x + 0.5 - cx, y + 0.5 - cy)) * alpha;
255
+ const i = (y * width + x) * 4;
256
+ if (a * 255 <= px[i + 3]) continue;
257
+ px[i] = rgb[2] * a; px[i + 1] = rgb[1] * a; px[i + 2] = rgb[0] * a; px[i + 3] = a * 255;
258
+ }
259
+ }
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Point at u (0..1) between p1 and p2 on a centripetal Catmull-Rom spline through p0..p3. The trail samples the
265
+ * pointer once a frame, so a fast glide leaves a few points far apart; this draws the curve between them. The
266
+ * centripetal form never loops or overshoots, also where the pointer turns back.
267
+ */
268
+ export function catmullRom(p0, p1, p2, p3, u) {
269
+ const d = (a, b) => Math.max(1e-3, Math.sqrt(Math.hypot(b.x - a.x, b.y - a.y)));
270
+ const t1 = d(p0, p1), t2 = t1 + d(p1, p2), t3 = t2 + d(p2, p3);
271
+ const t = t1 + (t2 - t1) * u;
272
+ const lerp = (a, b, ta, tb) => ({ x: (a.x * (tb - t) + b.x * (t - ta)) / (tb - ta), y: (a.y * (tb - t) + b.y * (t - ta)) / (tb - ta) });
273
+ const a1 = lerp(p0, p1, 0, t1), a2 = lerp(p1, p2, t1, t2), a3 = lerp(p2, p3, t2, t3);
274
+ const b1 = lerp(a1, a2, 0, t2), b2 = lerp(a2, a3, t1, t3);
275
+ return lerp(b1, b2, t1, t2);
276
+ }
277
+
278
+ /** The start marker of a drag: dark ring, bright ring and a dot. */
279
+ function marker(px, width, height, cx, cy, rgb) {
280
+ for (let y = Math.max(0, Math.floor(cy - 16)); y <= Math.min(height - 1, Math.ceil(cy + 16)); y++) {
281
+ for (let x = Math.max(0, Math.floor(cx - 16)); x <= Math.min(width - 1, Math.ceil(cx + 16)); x++) {
282
+ const r = Math.hypot(x + 0.5 - cx, y + 0.5 - cy);
283
+ const dark = clamp(2.2 - Math.abs(r - 12.5)) * 0.85, bright = clamp(1.6 - Math.abs(r - 11)), dot = clamp(4 - r);
284
+ const a = Math.max(dark, bright, dot);
285
+ if (a <= 0) continue;
286
+ const k = bright >= dark * 0.9 || dot > 0.5 ? 1 : 0;
287
+ const i = (y * width + x) * 4;
288
+ const c = k ? rgb : [14, 50, 28];
289
+ px[i] = c[2] * a; px[i + 1] = c[1] * a; px[i + 2] = c[0] * a; px[i + 3] = a * 255;
290
+ }
291
+ }
292
+ }
293
+
294
+ const ease = (t) => t * t * (3 - 2 * t);
295
+ const T_OPEN = 280, T_SCAN = 1100, T_CLOSE = 320;
296
+ export const SCAN_TOTAL_MS = T_OPEN + T_SCAN + T_CLOSE;
297
+
298
+ let flip = false; // alternates the side of the eye between screenshots
299
+
300
+ export const FLASH_TOTAL_MS = 760;
301
+
302
+
303
+ export class ScanAnimation {
304
+ constructor({ enabled = true } = {}) {
305
+ this.enabled = enabled;
306
+ }
307
+
308
+ /**
309
+ * drag: a ring where the button went down and a glowing trail that follows the pointer (sampled every frame),
310
+ * fading with age. When `stopped` resolves (button released) the trail fades out and rings mark the end.
311
+ * `bounds` {left, top, right, bottom}: where the drag can go. Never throws.
312
+ */
313
+ async trail(start, bounds, stopped, cancelled = () => false, button = 'left', { marker: showMarker = true, life = TRAIL_LIFE_MS, ring = true } = {}) {
314
+ if (!this.enabled) return;
315
+ let layer = null;
316
+ let ended = false;
317
+ stopped.then(() => { ended = true; });
318
+ try {
319
+ platform.ensurePump();
320
+ const v = platform.virtualScreen();
321
+ const pad = 330;
322
+ const left = Math.max(v.x, Math.floor(Math.min(bounds.left, start.x) - pad)), top = Math.max(v.y, Math.floor(Math.min(bounds.top, start.y) - pad));
323
+ const right = Math.min(v.x + v.width, Math.ceil(Math.max(bounds.right, start.x) + pad)), bottom = Math.min(v.y + v.height, Math.ceil(Math.max(bounds.bottom, start.y) + pad));
324
+ layer = new platform.Layer(Math.max(40, right - left), Math.max(40, bottom - top));
325
+ const rgb = RIPPLE_COLORS[button] ?? RIPPLE_COLORS.left;
326
+ const pts = [{ x: start.x, y: start.y, t: 0 }];
327
+ const t0 = performance.now();
328
+ let endAt = 0, endPoint = null;
329
+ for (;;) {
330
+ const tf = performance.now(), t = tf - t0;
331
+ if (cancelled()) break;
332
+ if (ended && !endAt) { endAt = t; const c = platform.cursorPos(); endPoint = { x: c.x, y: c.y }; pts.push({ ...endPoint, t }); }
333
+ if (endAt && t - endAt > TRAIL_FADE_MS) break;
334
+ if (!endAt) {
335
+ const c = platform.cursorPos();
336
+ const last = pts[pts.length - 1];
337
+ if (Math.hypot(c.x - last.x, c.y - last.y) >= 1) pts.push({ x: c.x, y: c.y, t });
338
+ }
339
+ layer.px.fill(0);
340
+ const fade = endAt ? 1 - clamp((t - endAt) / TRAIL_FADE_MS) : 1;
341
+ for (let pass = 0; pass < 2; pass++) {
342
+ for (let k = 1; k < pts.length; k++) {
343
+ const age = (endAt ? Math.min(t, endAt) : t) - pts[k].t;
344
+ const a = clamp(1 - age / life) * fade;
345
+ if (a <= 0.02) continue;
346
+ // the curve through the samples, in pieces of about 12 px
347
+ const P0 = pts[Math.max(0, k - 2)], P1 = pts[k - 1], P2 = pts[k], P3 = pts[Math.min(pts.length - 1, k + 1)];
348
+ const pieces = Math.min(24, Math.max(1, Math.ceil(Math.hypot(P2.x - P1.x, P2.y - P1.y) / 12)));
349
+ let A = P1;
350
+ for (let s = 1; s <= pieces; s++) {
351
+ const B = s === pieces ? P2 : catmullRom(P0, P1, P2, P3, s / pieces);
352
+ if (pass === 0) stroke(layer.px, layer.width, layer.height, A.x - left, A.y - top, B.x - left, B.y - top, 5.2, [14, 50, 28], a * 0.75);
353
+ else stroke(layer.px, layer.width, layer.height, A.x - left, A.y - top, B.x - left, B.y - top, 3, k % 6 < 5 ? rgb : [255, 255, 255], a);
354
+ A = B;
355
+ }
356
+ }
357
+ }
358
+ if (showMarker) marker(layer.px, layer.width, layer.height, start.x - left, start.y - top, rgb);
359
+ layer.present(left, top, 255 * fade);
360
+ await new Promise((r) => setTimeout(r, Math.max(1, 16 - (performance.now() - tf))));
361
+ }
362
+ layer.destroy();
363
+ layer = null;
364
+ if (endPoint && ring && !cancelled()) await this.ripple(endPoint, button, 1, cancelled);
365
+ } catch (e) {
366
+ process.stderr.write(`ispyx: drag effect failed (${e.message})
367
+ `);
368
+ } finally {
369
+ layer?.destroy();
370
+ }
371
+ }
372
+
373
+ /**
374
+ * scroll: chevrons beside the pointer travel in the scroll direction while the wheel turns (until `stopped`
375
+ * resolves, at least 380 ms, then a short fade). dirV: +1 up / -1 down / 0, dirH: +1 right / -1 left / 0. Never throws.
376
+ */
377
+ async wheelCue(point, dirV, dirH, speed, stopped, cancelled = () => false) {
378
+ if (!this.enabled || (!dirV && !dirH)) return;
379
+ let layer = null;
380
+ let ended = false;
381
+ stopped.then(() => { ended = true; });
382
+ try {
383
+ platform.ensurePump();
384
+ const S = 180, c = S / 2;
385
+ layer = new platform.Layer(S, S);
386
+ const SP = 24, L = SP * 3, arm = 13;
387
+ const t0 = performance.now();
388
+ let endAt = 0;
389
+ for (;;) {
390
+ const tf = performance.now(), t = tf - t0;
391
+ if (cancelled()) break;
392
+ if (ended && t >= 380 && !endAt) endAt = t;
393
+ if (endAt && t - endAt > 220) break;
394
+ const fade = endAt ? 1 - clamp((t - endAt) / 220) : clamp(t / 90);
395
+ layer.px.fill(0);
396
+ const phase = (t * 0.09 * clamp(speed, 0.6, 3)) % SP;
397
+ for (let pass = 0; pass < 2; pass++) {
398
+ for (let k = 0; k < 4; k++) {
399
+ const pos = k * SP + phase; // 0..L+SP along the travel direction
400
+ const a = Math.sin(Math.PI * clamp(pos / L)) * fade;
401
+ if (a < 0.03) continue;
402
+ for (const [dir, horiz] of [[dirV, false], [dirH, true]]) {
403
+ if (!dir) continue;
404
+ const along = (pos - L / 2) * dir, anchor = horiz ? { x: c + along, y: c + 40 } : { x: c + 40, y: c - along };
405
+ // chevron: tip leads in the travel direction
406
+ const tip = horiz ? { x: anchor.x + 6 * dir, y: anchor.y } : { x: anchor.x, y: anchor.y - 6 * dir };
407
+ const e1 = horiz ? { x: anchor.x - 6 * dir, y: anchor.y - arm } : { x: anchor.x - arm, y: anchor.y + 6 * dir };
408
+ const e2 = horiz ? { x: anchor.x - 6 * dir, y: anchor.y + arm } : { x: anchor.x + arm, y: anchor.y + 6 * dir };
409
+ if (pass === 0) {
410
+ stroke(layer.px, S, S, e1.x, e1.y, tip.x, tip.y, 4.6, [14, 50, 28], a * 0.8);
411
+ stroke(layer.px, S, S, tip.x, tip.y, e2.x, e2.y, 4.6, [14, 50, 28], a * 0.8);
412
+ } else {
413
+ stroke(layer.px, S, S, e1.x, e1.y, tip.x, tip.y, 2.6, [60, 255, 110], a);
414
+ stroke(layer.px, S, S, tip.x, tip.y, e2.x, e2.y, 2.6, [60, 255, 110], a);
415
+ }
416
+ }
417
+ }
418
+ }
419
+ layer.present(point.x - c, point.y - c, 255);
420
+ await new Promise((r) => setTimeout(r, Math.max(1, 16 - (performance.now() - tf))));
421
+ }
422
+ } catch (e) {
423
+ process.stderr.write(`ispyx: scroll effect failed (${e.message})
424
+ `);
425
+ } finally {
426
+ layer?.destroy();
427
+ }
428
+ }
429
+
430
+ /**
431
+ * press_keys: key caps ("Ctrl" + "C") appear near the bottom of the screen the keys go to, hold, fade.
432
+ * `area` is the rectangle (desktop pixels) to centre them in. Never throws.
433
+ */
434
+ async keycaps(chords, area, cancelled = () => false) {
435
+ if (!this.enabled || !chords.length) return;
436
+ let layer = null;
437
+ try {
438
+ platform.ensurePump();
439
+ const pad = 16, capH = 46, gapPlus = 26, gapChord = 38;
440
+ const items = chords.map((chord) => chord.map((label) => ({ label, t: platform.textMask(label, 22, 600) })));
441
+ const plus = platform.textMask('+', 22, 600);
442
+ const capW = (it) => Math.max(capH, it.t.width + 30);
443
+ const widths = items.map((chord) => chord.reduce((sum, it) => sum + capW(it), 0) + gapPlus * (chord.length - 1));
444
+ const total = widths.reduce((a, b) => a + b, 0) + gapChord * (items.length - 1);
445
+ const W = Math.min(Math.round(total + pad * 2), Math.max(200, area.width - 20)), H = capH + pad * 2;
446
+ layer = new platform.Layer(W, H);
447
+ const px = layer.px;
448
+ const put = (x, y, r, g, b, a) => { // premultiplied "over"
449
+ if (a <= 0 || x < 0 || y < 0 || x >= W || y >= H) return;
450
+ const i = (y * W + x) * 4, k = 1 - a;
451
+ px[i] = b * a + px[i] * k; px[i + 1] = g * a + px[i + 1] * k; px[i + 2] = r * a + px[i + 2] * k; px[i + 3] = a * 255 + px[i + 3] * k;
452
+ };
453
+ const drawMask = (m, x0, y0, rgb, alpha = 1) => { for (let y = 0; y < m.height; y++) for (let x = 0; x < m.width; x++) put(x0 + x, y0 + y, rgb[0], rgb[1], rgb[2], (m.mask[y * m.width + x] / 255) * alpha); };
454
+ let x = Math.round((W - total) / 2);
455
+ const y0 = pad;
456
+ items.forEach((chord, ci) => {
457
+ chord.forEach((it, ki) => {
458
+ const cw = capW(it);
459
+ for (let yy = 0; yy < capH; yy++) {
460
+ for (let xx = 0; xx < cw; xx++) {
461
+ const dx = Math.min(xx, cw - 1 - xx), dy = Math.min(yy, capH - 1 - yy);
462
+ const rr = 10, cx = Math.max(0, rr - dx), cy = Math.max(0, rr - dy);
463
+ const dist = Math.hypot(cx, cy) - rr; // < 0 inside the rounded rectangle
464
+ const inside = clamp(0.5 - dist);
465
+ const edge = clamp(0.5 - (Math.abs(dist + 1.2) - 1.2));
466
+ const top = clamp(1 - yy / capH);
467
+ put(x + xx, y0 + yy, mix(26, 52, top * 0.5), mix(30, 56, top * 0.5), mix(36, 62, top * 0.5), inside * 0.94);
468
+ put(x + xx, y0 + yy, 60, 255, 110, edge * inside);
469
+ }
470
+ }
471
+ drawMask(it.t, x + Math.round((cw - it.t.width) / 2), y0 + Math.round((capH - it.t.height) / 2), [245, 250, 247]);
472
+ x += cw;
473
+ if (ki < chord.length - 1) { drawMask(plus, x + Math.round((gapPlus - plus.width) / 2), y0 + Math.round((capH - plus.height) / 2), [150, 255, 180]); x += gapPlus; }
474
+ });
475
+ if (ci < items.length - 1) x += gapChord;
476
+ });
477
+ const left = Math.round(area.left + (area.width - W) / 2), top = Math.round(area.top + area.height - H - 90);
478
+ const hold = 420 + 140 * chords.length;
479
+ const t0 = performance.now();
480
+ for (;;) {
481
+ const tf = performance.now(), t = tf - t0;
482
+ if (t >= hold + 260 || cancelled()) break;
483
+ const a = t < 110 ? ease(t / 110) : t < hold ? 1 : 1 - ease((t - hold) / 260);
484
+ layer.present(left, top + (1 - Math.min(1, t / 110)) * 14, 255 * a);
485
+ await new Promise((r) => setTimeout(r, Math.max(1, 16 - (performance.now() - tf))));
486
+ }
487
+ } catch (e) {
488
+ process.stderr.write(`ispyx: key effect failed (${e.message})\n`);
489
+ } finally {
490
+ layer?.destroy();
491
+ }
492
+ }
493
+
494
+ /**
495
+ * type_text: a small pill near the bottom of the screen with a keyboard icon, a progress bar and "37 / 120".
496
+ * It never shows the text. `progress.n` is the characters sent so far; the pill ends when `stopped` resolves.
497
+ */
498
+ async typing(total, progress, area, stopped, cancelled = () => false) {
499
+ if (!this.enabled || total < 1) return;
500
+ let layer = null;
501
+ let ended = false;
502
+ stopped.then(() => { ended = true; });
503
+ try {
504
+ platform.ensurePump();
505
+ const W = 300, H = 64;
506
+ layer = new platform.Layer(W, H);
507
+ const px = layer.px;
508
+ const glyphs = {};
509
+ for (const g of '0123456789/') glyphs[g] = platform.textMask(g, 17, 600);
510
+ const adv = Math.max(...'0123456789'.split('').map((g) => glyphs[g].width)) + 1;
511
+ const put = (x, y, r, g, b, a) => {
512
+ if (a <= 0 || x < 0 || y < 0 || x >= W || y >= H) return;
513
+ const i = (y * W + x) * 4, k = 1 - a;
514
+ px[i] = b * a + px[i] * k; px[i + 1] = g * a + px[i + 1] * k; px[i + 2] = r * a + px[i + 2] * k; px[i + 3] = a * 255 + px[i + 3] * k;
515
+ };
516
+ const roundRect = (x0, y0, w0, h0, rad, fill, alpha, border) => {
517
+ for (let y = 0; y < h0; y++) for (let x = 0; x < w0; x++) {
518
+ const dx = Math.min(x, w0 - 1 - x), dy = Math.min(y, h0 - 1 - y);
519
+ const dist = Math.hypot(Math.max(0, rad - dx), Math.max(0, rad - dy)) - rad;
520
+ const inside = clamp(0.5 - dist);
521
+ if (fill) put(x0 + x, y0 + y, fill[0], fill[1], fill[2], inside * alpha);
522
+ if (border) put(x0 + x, y0 + y, border[0], border[1], border[2], clamp(0.5 - (Math.abs(dist + 1.2) - 1.2)) * inside);
523
+ }
524
+ };
525
+ const draw = (n) => {
526
+ px.fill(0);
527
+ roundRect(0, 0, W, H, 14, [26, 30, 36], 0.94, [60, 255, 110]);
528
+ // keyboard icon
529
+ roundRect(16, 20, 32, 24, 5, null, 0, [60, 255, 110]);
530
+ for (let r = 0; r < 2; r++) for (let c = 0; c < 4; c++) roundRect(21 + c * 6, 25 + r * 6, 4, 4, 1, [150, 255, 180], 1, null);
531
+ roundRect(24, 37, 16, 3, 1, [150, 255, 180], 1, null);
532
+ // counter "37 / 120" (glyph by glyph; fixed advance for digits)
533
+ let x = 66;
534
+ for (const ch of String(n) + ' / ' + String(total)) {
535
+ if (ch === ' ') { x += 5; continue; }
536
+ const g = glyphs[ch]; if (!g) continue;
537
+ const dx = ch === '/' ? 0 : Math.round((adv - g.width) / 2);
538
+ for (let yy = 0; yy < g.height; yy++) for (let xx = 0; xx < g.width; xx++) put(x + dx + xx, 10 + yy, 245, 250, 247, g.mask[yy * g.width + xx] / 255);
539
+ x += ch === '/' ? g.width : adv;
540
+ }
541
+ // progress bar
542
+ const bx = 66, bw = W - 66 - 18, by = 40;
543
+ roundRect(bx, by, bw, 8, 4, [58, 64, 72], 1, null);
544
+ const fw = Math.max(0, Math.round(bw * clamp(n / total)));
545
+ if (fw >= 2) roundRect(bx, by, fw, 8, 4, [60, 255, 110], 1, null);
546
+ };
547
+ const left = Math.round(area.left + (area.width - W) / 2), top = Math.round(area.top + area.height - H - 90);
548
+ const t0 = performance.now();
549
+ let endAt = 0;
550
+ for (;;) {
551
+ const t = performance.now() - t0;
552
+ if (cancelled()) break;
553
+ if (ended && t >= 500 && !endAt) endAt = t;
554
+ if (endAt && t - endAt > 260) break;
555
+ draw(Math.min(progress.n, total));
556
+ const a = endAt ? 1 - ease(clamp((t - endAt) / 260)) : ease(clamp(t / 110));
557
+ layer.present(left, top + (1 - Math.min(1, t / 110)) * 14, 255 * a);
558
+ await new Promise((r) => setTimeout(r, 33));
559
+ }
560
+ } catch (e) {
561
+ process.stderr.write(`ispyx: typing effect failed (${e.message})\n`);
562
+ } finally {
563
+ layer?.destroy();
564
+ }
565
+ }
566
+
567
+ /**
568
+ * list_windows: an eye opens in the middle of the monitor and sends out one sonar ping: a ring that expands to
569
+ * the corners. A window's frame lights when the ring reaches its nearest edge (so the nearest windows light
570
+ * first), then everything fades. Green = usable, red = on the denylist, amber = elevated (cannot be controlled).
571
+ * `wins`: [{ rect, allowed, elevated }] in desktop pixels.
572
+ */
573
+ async radar(wins, area, cancelled = () => false, opts = {}) {
574
+ if (!this.enabled) return;
575
+ let frames = null, eye = null;
576
+ try {
577
+ const W = area.width, H = area.height;
578
+ // `opts.origin` (desktop pixels) puts the eye elsewhere than the middle of `area`: with several monitors one
579
+ // eye sits on one of them and the ring carries on across the others (`opts.reach`, `opts.t0` keep them in step)
580
+ const cx = opts.origin ? opts.origin.x - area.left : W / 2, cy = opts.origin ? opts.origin.y - area.top : H / 2;
581
+ const hasEye = cx >= 0 && cx < W && cy >= 0 && cy < H;
582
+ const list = wins
583
+ .map((x) => {
584
+ const l = Math.max(area.left, x.rect.left), t = Math.max(area.top, x.rect.top);
585
+ const r = Math.min(area.left + W, x.rect.left + x.rect.width), bt = Math.min(area.top + H, x.rect.top + x.rect.height);
586
+ const q = { x0: l - area.left, y0: t - area.top, x1: r - area.left - 1, y1: bt - area.top - 1, rgb: x.allowed === false ? [255, 80, 80] : x.elevated ? [255, 190, 40] : [60, 255, 110] };
587
+ q.dist = Math.hypot(Math.max(q.x0 - cx, 0, cx - q.x1), Math.max(q.y0 - cy, 0, cy - q.y1)); // eye to the nearest edge
588
+ return q;
589
+ })
590
+ .filter((q) => q.x1 - q.x0 >= 60 && q.y1 - q.y0 >= 40)
591
+ .slice(0, 14);
592
+ if (!list.length && !opts.origin) return;
593
+ platform.ensurePump();
594
+ const esize = Math.round(clamp(Math.min(W, H) * 0.11, 72, 124));
595
+ const reach = opts.reach ?? Math.hypot(cx, cy) + 60; // the rings run past the farthest corner
596
+ frames = new platform.Layer(W, H);
597
+ if (hasEye) eye = new platform.Layer(esize, esize);
598
+ const OPEN = 300, PING = 1300, FADE = 450;
599
+ const END = OPEN + PING;
600
+ const front = (t) => (t < OPEN ? -1 : easeOut(clamp((t - OPEN) / PING)) * reach); // ring radius, -1 = not yet
601
+ const t0 = opts.t0 ?? performance.now();
602
+ const stats = (this.stats = { frames: 0, drawMs: 0, presentMs: 0 }); // frame cost, for tuning
603
+ for (;;) {
604
+ const t = performance.now() - t0;
605
+ if (t >= END + FADE || cancelled()) break;
606
+ const tf = performance.now();
607
+ const out = t > END ? 1 - ease(clamp((t - END) / FADE)) : 1;
608
+ const r1 = front(t);
609
+ frames.px.fill(0);
610
+ if (r1 > 2 && r1 < reach) drawRing(frames.px, W, H, cx, cy, r1, 70, out * (1 - 0.5 * (r1 / reach)));
611
+ for (const q of list) {
612
+ if (r1 < q.dist) continue; // the first ring has not got there
613
+ const since = (r1 - q.dist) / reach * PING; // roughly how long ago the ring passed
614
+ drawBorder(frames.px, W, H, q.x0, q.y0, q.x1, q.y1, q.rgb, out, since < 170 ? 5 : 3);
615
+ }
616
+ const td = performance.now();
617
+ frames.present(area.left, area.top, 255);
618
+ stats.presentMs += performance.now() - td; stats.drawMs += td - tf; stats.frames++;
619
+ if (eye) {
620
+ const open = t < OPEN ? ease(t / OPEN) : t > END ? 1 - ease(clamp((t - END) / FADE)) : 1;
621
+ const pulse = Math.max(0, 1 - Math.abs(t - OPEN - 80) / 260); // the pupil flares as the ping leaves
622
+ renderEye(eye.px, esize, open, 0, 0.15, pulse);
623
+ eye.present(area.left + cx - esize / 2, area.top + cy - esize / 2, 255 * Math.min(ease(clamp(t / OPEN)), out));
624
+ }
625
+ await new Promise((r) => setTimeout(r, Math.max(1, 16 - (performance.now() - tf))));
626
+ }
627
+ } catch (e) {
628
+ process.stderr.write(`ispyx: window scan effect failed (${e.message})\n`);
629
+ } finally {
630
+ eye?.destroy();
631
+ frames?.destroy();
632
+ }
633
+ }
634
+
635
+ /**
636
+ * The radar over all monitors (`areas`): one eye in the middle of `home`, one ping that runs on across the others.
637
+ * This is the one entry point; `radar` alone draws a single monitor.
638
+ */
639
+ radarAll(wins, areas, home, cancelled = () => false) {
640
+ const origin = { x: home.left + home.width / 2, y: home.top + home.height / 2 };
641
+ const reach = Math.max(...areas.flatMap((a) => [a.left, a.left + a.width].flatMap((x) => [a.top, a.top + a.height].map((y) => Math.hypot(x - origin.x, y - origin.y))))) + 60;
642
+ const opts = { origin, reach, t0: performance.now() };
643
+ return Promise.all(areas.map((a) => this.radar(wins, a, cancelled, opts)));
644
+ }
645
+
646
+ /**
647
+ * wait: a small pill near the bottom of the screen with an hourglass, the time left ("4.2 s") and a bar that
648
+ * runs down. Lasts `ms`, then fades. Never throws.
649
+ */
650
+ async countdown(ms, area, cancelled = () => false) {
651
+ if (!this.enabled || ms < 1) return;
652
+ let layer = null;
653
+ try {
654
+ platform.ensurePump();
655
+ const W = 220, H = 64;
656
+ layer = new platform.Layer(W, H);
657
+ const px = layer.px;
658
+ const glyphs = {};
659
+ for (const g of '0123456789.s') glyphs[g] = platform.textMask(g, 17, 600);
660
+ const adv = Math.max(...'0123456789'.split('').map((g) => glyphs[g].width)) + 1;
661
+ const put = (x, y, r, g, b, a) => {
662
+ if (a <= 0 || x < 0 || y < 0 || x >= W || y >= H) return;
663
+ const i = (y * W + x) * 4, k = 1 - a;
664
+ px[i] = b * a + px[i] * k; px[i + 1] = g * a + px[i + 1] * k; px[i + 2] = r * a + px[i + 2] * k; px[i + 3] = a * 255 + px[i + 3] * k;
665
+ };
666
+ const roundRect = (x0, y0, w0, h0, rad, fill, alpha, border) => {
667
+ for (let y = 0; y < h0; y++) for (let x = 0; x < w0; x++) {
668
+ const dx = Math.min(x, w0 - 1 - x), dy = Math.min(y, h0 - 1 - y);
669
+ const dist = Math.hypot(Math.max(0, rad - dx), Math.max(0, rad - dy)) - rad;
670
+ const inside = clamp(0.5 - dist);
671
+ if (fill) put(x0 + x, y0 + y, fill[0], fill[1], fill[2], inside * alpha);
672
+ if (border) put(x0 + x, y0 + y, border[0], border[1], border[2], clamp(0.5 - (Math.abs(dist + 1.2) - 1.2)) * inside);
673
+ }
674
+ };
675
+ // hourglass: two triangles; the top one empties as the sand moves to the bottom one
676
+ const hourglass = (cx, cy, k) => {
677
+ const hw = 10, hh = 12;
678
+ for (let y = -hh; y <= hh; y++) {
679
+ const w0 = Math.abs(y) / hh * hw; // half width of the glass at this row
680
+ for (let x = -hw; x <= hw; x++) {
681
+ const edge = Math.abs(Math.abs(x) - w0);
682
+ const inside = Math.abs(x) <= w0;
683
+ if (edge < 1.1) put(cx + x, cy + y, 150, 255, 180, 1 - edge / 1.6);
684
+ else if (inside) {
685
+ const top = y < 0, depth = top ? -y / hh : y / hh; // 0 at the neck, 1 at the end
686
+ const filled = top ? depth > k * 0.9 : depth > 1 - k * 0.9; // top drains from the neck up, bottom fills from the end
687
+ if (filled) put(cx + x, cy + y, 60, 255, 110, 0.85);
688
+ }
689
+ }
690
+ }
691
+ };
692
+ const draw = (left, frac) => {
693
+ px.fill(0);
694
+ roundRect(0, 0, W, H, 14, [26, 30, 36], 0.94, [60, 255, 110]);
695
+ hourglass(30, 32, 1 - frac);
696
+ let x = 62;
697
+ const s = (left / 1000).toFixed(1);
698
+ for (const ch of s + ' s') {
699
+ if (ch === ' ') { x += 5; continue; }
700
+ const g = glyphs[ch]; if (!g) continue;
701
+ const dx = ch === '.' ? 0 : ch === 's' ? 0 : Math.round((adv - g.width) / 2);
702
+ for (let yy = 0; yy < g.height; yy++) for (let xx = 0; xx < g.width; xx++) put(x + dx + xx, 10 + yy, 245, 250, 247, g.mask[yy * g.width + xx] / 255);
703
+ x += ch === '.' || ch === 's' ? g.width + 1 : adv;
704
+ }
705
+ const bx = 62, bw = W - 62 - 18, by = 40;
706
+ roundRect(bx, by, bw, 8, 4, [58, 64, 72], 1, null);
707
+ const fw = Math.round(bw * clamp(frac));
708
+ if (fw >= 2) roundRect(bx, by, fw, 8, 4, [60, 255, 110], 1, null);
709
+ };
710
+ const left0 = Math.round(area.left + (area.width - W) / 2), top0 = Math.round(area.top + area.height - H - 90);
711
+ const t0 = performance.now(), FADE = 260;
712
+ for (;;) {
713
+ const tf = performance.now(), t = tf - t0;
714
+ if (t >= ms + FADE || cancelled()) break;
715
+ const remaining = Math.max(0, ms - t);
716
+ draw(remaining, remaining / ms);
717
+ const a = t > ms ? 1 - ease(clamp((t - ms) / FADE)) : ease(clamp(t / 160));
718
+ layer.present(left0, top0 + (1 - Math.min(1, t / 160)) * 14, 255 * a);
719
+ await new Promise((r) => setTimeout(r, Math.max(1, 33 - (performance.now() - tf))));
720
+ }
721
+ } catch (e) {
722
+ process.stderr.write(`ispyx: wait effect failed (${e.message})\n`);
723
+ } finally {
724
+ layer?.destroy();
725
+ }
726
+ }
727
+
728
+ /** click: expanding rings at the pressed point (colour by button, one ring per click). Never throws. */
729
+ async ripple(point, button = 'left', rings = 1, cancelled = () => false) {
730
+ if (!this.enabled) return;
731
+ let layer = null;
732
+ try {
733
+ platform.ensurePump();
734
+ layer = new platform.Layer(RIPPLE_SIZE, RIPPLE_SIZE);
735
+ const rgb = RIPPLE_COLORS[button] ?? RIPPLE_COLORS.left;
736
+ const total = RIPPLE_MS + (rings - 1) * RIPPLE_GAP_MS;
737
+ const t0 = performance.now();
738
+ for (;;) {
739
+ const tf = performance.now(), t = tf - t0;
740
+ if (t >= total || cancelled()) break;
741
+ renderRipple(layer.px, RIPPLE_SIZE, t, rings, rgb);
742
+ layer.present(point.x - RIPPLE_SIZE / 2, point.y - RIPPLE_SIZE / 2, 255);
743
+ await new Promise((r) => setTimeout(r, Math.max(1, 16 - (performance.now() - tf))));
744
+ }
745
+ } catch (e) {
746
+ process.stderr.write(`ispyx: click effect failed (${e.message})
747
+ `);
748
+ } finally {
749
+ layer?.destroy();
750
+ }
751
+ }
752
+
753
+ /** focus_window: a green frame lights up around the window (clipped to the desktop) and fades. Never throws. */
754
+ async flash(rect, cancelled = () => false) {
755
+ if (!this.enabled) return;
756
+ let layer = null;
757
+ try {
758
+ const v = platform.virtualScreen();
759
+ const left = Math.max(rect.left, v.x), top = Math.max(rect.top, v.y);
760
+ const right = Math.min(rect.left + rect.width, v.x + v.width), bottom = Math.min(rect.top + rect.height, v.y + v.height);
761
+ if (right - left < 20 || bottom - top < 20) return;
762
+ platform.ensurePump();
763
+ layer = new platform.Layer(right - left, bottom - top);
764
+ renderFrame(layer.px, layer.width, layer.height);
765
+ const t0 = performance.now();
766
+ for (;;) {
767
+ const tf = performance.now(), t = tf - t0;
768
+ if (t >= FLASH_TOTAL_MS || cancelled()) break;
769
+ // on, a short dip, on again (a double blink catches the eye), then fade
770
+ const a = t < 80 ? t / 80 : t < 280 ? 1 : t < 380 ? 1 - 0.65 * Math.sin(((t - 280) / 100) * Math.PI) : t < 480 ? 1 : 1 - ease((t - 480) / (FLASH_TOTAL_MS - 480));
771
+ layer.present(left, top, 255 * a);
772
+ await new Promise((r) => setTimeout(r, Math.max(1, 16 - (performance.now() - tf))));
773
+ }
774
+ } catch (e) {
775
+ process.stderr.write(`ispyx: focus effect failed (${e.message})
776
+ `);
777
+ } finally {
778
+ layer?.destroy();
779
+ }
780
+ }
781
+
782
+ /**
783
+ * Plays the animation over `rect` (desktop pixels) and resolves when it is over. `cancelled()` is polled each
784
+ * frame; the windows are always removed. Never throws: a failed animation must not break a screenshot.
785
+ */
786
+ async play(rect, cancelled = () => false) {
787
+ if (!this.enabled) return;
788
+ let wedge = null, band = null, eye = null;
789
+ try {
790
+ platform.ensurePump();
791
+ const size = Math.round(clamp(Math.min(rect.width, rect.height) * 0.3, 56, 150));
792
+ const bandH = 56;
793
+ // the eye floats beside the region (alternating sides; the side with more room if only one fits) and looks at it
794
+ // (room is measured on the monitor holding the region's centre; with no room, as for a full-screen scan,
795
+ // the eye sits inside the region, inset from its left or right edge)
796
+ const cxr = rect.left + rect.width / 2, cyr = rect.top + rect.height / 2;
797
+ const mon = platform.listMonitors().find((m) => cxr >= m.left && cxr < m.left + m.width && cyr >= m.top && cyr < m.top + m.height) ?? platform.virtualScreen();
798
+ const m = { x: mon.left ?? mon.x, y: mon.top ?? mon.y, width: mon.width, height: mon.height };
799
+ const gap = 18;
800
+ const roomL = rect.left - m.x, roomR = m.x + m.width - (rect.left + rect.width);
801
+ const fitsL = roomL >= size + gap, fitsR = roomR >= size + gap;
802
+ const left = fitsL && fitsR ? (flip = !flip) : fitsL ? true : fitsR ? false : (flip = !flip);
803
+ const ex = fitsL || fitsR
804
+ ? (left ? rect.left - size - gap : rect.left + rect.width + gap)
805
+ : (left ? Math.max(rect.left, m.x) + gap * 2 : Math.min(rect.left + rect.width, m.x + m.width) - size - gap * 2); // inside the region (on the monitor holding its centre)
806
+ const ey = clamp(rect.top + (rect.height - size) / 2, m.y, m.y + m.height - size);
807
+ const gx = left ? 0.95 : -0.95; // pupil towards the region
808
+ const pad = 4;
809
+ const lx = Math.floor(Math.min(ex, rect.left) - pad), rx = Math.ceil(Math.max(ex + size, rect.left + rect.width) + pad);
810
+ const ty = Math.floor(Math.min(ey, rect.top) - pad), by = Math.ceil(Math.max(ey + size, rect.top + rect.height) + pad);
811
+ wedge = new platform.Layer(rx - lx, by - ty);
812
+ band = new platform.Layer(Math.max(40, rect.width), bandH);
813
+ renderBand(band.px, band.width, bandH);
814
+ eye = new platform.Layer(size, size);
815
+ const midY = rect.top + rect.height / 2;
816
+ const t0 = performance.now();
817
+ for (;;) {
818
+ const tf = performance.now(), t = tf - t0;
819
+ if (t >= SCAN_TOTAL_MS || cancelled()) break;
820
+ let open, fade, p = 0;
821
+ if (t < T_OPEN) { open = ease(t / T_OPEN); fade = open; }
822
+ else if (t < T_OPEN + T_SCAN) { open = 1; fade = 1; p = ease((t - T_OPEN) / T_SCAN); }
823
+ else { const k = (t - T_OPEN - T_SCAN) / T_CLOSE; open = 1 - ease(k); fade = 1 - k; p = 1; }
824
+ const edge = rect.top + p * rect.height; // the scan line (its leading edge is the bottom)
825
+ // the pupil goes from upper right to lower right (mirrored when the eye is on the right) with the line
826
+ const gy = (p > 0 ? clamp((edge - midY) / (rect.height / 2), -1, 1) : -1) * 0.85;
827
+ const charge = p > 0 ? 1 : clamp((t - T_OPEN * 0.6) / (T_OPEN * 0.4)); // pupil glows just before the scan
828
+ renderEye(eye.px, size, open, gx, gy, charge);
829
+ const live = Math.min(1, p * 10, (1 - p) * 6);
830
+ if (p > 0 && p < 1) {
831
+ const pupil = pupilPos(size, gx, gy);
832
+ drawWedge(wedge.px, wedge.width, wedge.height, ex + pupil.x - lx, ey + pupil.y - ty, rect.left - lx, rect.left + rect.width - lx, edge - ty);
833
+ wedge.present(lx, ty, 255 * live);
834
+ band.present(rect.left + (rect.width - band.width) / 2, edge - bandH, 255 * live);
835
+ } else if (wedge.shown) {
836
+ wedge.present(lx, ty, 0, 1);
837
+ band.present(rect.left, rect.top, 0, 1);
838
+ }
839
+ eye.present(ex, ey, 255 * fade);
840
+ await new Promise((r) => setTimeout(r, Math.max(1, 16 - (performance.now() - tf))));
841
+ }
842
+ } catch (e) {
843
+ process.stderr.write(`ispyx: scan animation failed (${e.message})\n`);
844
+ } finally {
845
+ wedge?.destroy();
846
+ band?.destroy();
847
+ eye?.destroy();
848
+ }
849
+ }
850
+ }