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.
- package/LICENSE +21 -0
- package/README.md +183 -2
- package/config.example.json +15 -0
- package/package.json +47 -4
- package/src/core/RESIDENT.md +45 -0
- package/src/core/auth.js +129 -0
- package/src/core/guard.js +287 -0
- package/src/core/image.js +117 -0
- package/src/core/ipc.js +139 -0
- package/src/core/launcher.js +70 -0
- package/src/core/mode.js +17 -0
- package/src/core/motion.js +57 -0
- package/src/core/panel/README.md +70 -0
- package/src/core/panel/index.js +144 -0
- package/src/core/panel/page.html +434 -0
- package/src/core/resident.js +271 -0
- package/src/core/server.js +832 -0
- package/src/effects/appicon.js +46 -0
- package/src/effects/scan.js +850 -0
- package/src/macos-entry.js +16 -0
- package/src/platform/CONTRACT.md +131 -0
- package/src/platform/darwin/PROTOCOL.md +82 -0
- package/src/platform/darwin/hotkey.js +41 -0
- package/src/platform/darwin/index.js +118 -0
- package/src/platform/darwin/keys.js +49 -0
- package/src/platform/darwin/layer.js +87 -0
- package/src/platform/darwin/overlay.js +83 -0
- package/src/platform/darwin/panel.js +25 -0
- package/src/platform/darwin/rpc-worker.js +132 -0
- package/src/platform/darwin/rpc.js +146 -0
- package/src/platform/fake/index.js +162 -0
- package/src/platform/index.js +13 -0
- package/src/platform/win32/auth-dialog.ps1 +140 -0
- package/src/platform/win32/autostart.js +54 -0
- package/src/platform/win32/dialog.js +41 -0
- package/src/platform/win32/hotkey.js +77 -0
- package/src/platform/win32/index.js +31 -0
- package/src/platform/win32/keys.js +31 -0
- package/src/platform/win32/layer.js +120 -0
- package/src/platform/win32/overlay.js +265 -0
- package/src/platform/win32/pump.js +37 -0
- package/src/platform/win32/tray-worker.js +172 -0
- package/src/platform/win32/tray.js +103 -0
- package/src/platform/win32/win32.js +577 -0
- 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
|
+
}
|