1import * as Effect from "effect/Effect";
2import type * as Fiber from "effect/Fiber";
3
4import * as Electron from "electron";
5
6const MIN_DURATION_MS = 280;
7const MAX_DURATION_MS = 680;
8const DURATION_DISTANCE_SCALE = 2_000;
9const EASING = "cubic-bezier(.2,.8,.2,1)";
10const TIMEOUT_MS = 6_000;
11
12type SnapShotAnimationDetails = {
13 readonly appName: string;
14 readonly windowTitle: string;
15 readonly appIconDataUrl?: string | undefined;
16};
17
18export type SnapShotAnimationDestination = {
19 readonly frame: Electron.Rectangle;
20 /** Unit coordinates in T3's content area; GNOME supplies the real compositor origin. */
21 readonly relativeFrame?: Electron.Rectangle | undefined;
22 readonly backgroundColor: string;
23 readonly borderColor: string;
24 readonly borderWidth: number;
25 readonly cornerRadius: number;
26 readonly scaleFactor: number;
27 readonly details?: SnapShotAnimationDetails | undefined;
28};
29
30export function snapShotAnimationDurationMs(
31 source: Electron.Rectangle,
32 target: Electron.Rectangle,
33): number {
34 const distance = Math.hypot(
35 source.x + source.width / 2 - target.x - target.width / 2,
36 source.y + source.height / 2 - target.y - target.height / 2,
37 );
38 return (
39 MAX_DURATION_MS -
40 (MAX_DURATION_MS - MIN_DURATION_MS) * Math.exp(-distance / DURATION_DISTANCE_SCALE)
41 );
42}
43
44type ActiveTransition = {
45 readonly id: string;
46 readonly source: Electron.Rectangle;
47 readonly snapshotDataUrl: string;
48 readonly overlays: Array<{
49 readonly window: Electron.BrowserWindow;
50 readonly requestedBounds: Electron.Rectangle;
51 bounds: Electron.Rectangle;
52 }>;
53 details?: SnapShotAnimationDetails | undefined;
54 timer?: Fiber.Fiber<void> | undefined;
55 flight?: Promise<void> | undefined;
56};
57
58type SnapShotTransitionOptions = {
59 readonly showWindow?: ((window: Electron.BaseWindow) => void) | undefined;
60 readonly boundOverlayToCaptureDisplays?: boolean | undefined;
61 readonly waitForCompositorFrame?: boolean | undefined;
62 readonly alwaysOnTopLevel?:
63 | NonNullable<Parameters<Electron.BrowserWindow["setAlwaysOnTop"]>[1]>
64 | undefined;
65};
66
67export function snapShotAnimationOverlayBounds(
68 displays: ReadonlyArray<Pick<Electron.Display, "bounds">>,
69): Electron.Rectangle {
70 const firstDisplay = displays[0];
71 const first = firstDisplay?.bounds ?? { x: 0, y: 0, width: 1, height: 1 };
72 let left = first.x;
73 let top = first.y;
74 let right = first.x + first.width;
75 let bottom = first.y + first.height;
76 for (const display of displays.slice(1)) {
77 const bounds = display.bounds;
78 left = Math.min(left, bounds.x);
79 top = Math.min(top, bounds.y);
80 right = Math.max(right, bounds.x + bounds.width);
81 bottom = Math.max(bottom, bounds.y + bounds.height);
82 }
83 return { x: left, y: top, width: right - left, height: bottom - top };
84}
85
86export function snapShotAnimationDisplayBounds(
87 displays: ReadonlyArray<Pick<Electron.Display, "bounds">>,
88 source: Electron.Rectangle,
89 destination: Electron.Rectangle,
90): Array<Electron.Rectangle> {
91 const flight = snapShotAnimationOverlayBounds([{ bounds: source }, { bounds: destination }]);
92 return displays
93 .map((display) => display.bounds)
94 .filter(
95 (bounds) =>
96 bounds.x < flight.x + flight.width &&
97 bounds.x + bounds.width > flight.x &&
98 bounds.y < flight.y + flight.height &&
99 bounds.y + bounds.height > flight.y,
100 );
101}
102
103function createWindow(
104 bounds: Electron.Rectangle,
105 alwaysOnTopLevel: SnapShotTransitionOptions["alwaysOnTopLevel"],
106): Electron.BrowserWindow {
107 const window = new Electron.BrowserWindow({
108 ...bounds,
109 alwaysOnTop: true,
110 backgroundColor: "#00000000",
111 focusable: false,
112 frame: false,
113 hasShadow: false,
114 resizable: false,
115 show: false,
116 skipTaskbar: true,
117 title: "The Knife Snapshot Animation",
118 transparent: true,
119 webPreferences: {
120 backgroundThrottling: false,
121 contextIsolation: true,
122 nodeIntegration: false,
123 sandbox: true,
124 },
125 });
126 if (alwaysOnTopLevel) window.setAlwaysOnTop(true, alwaysOnTopLevel);
127 window.setIgnoreMouseEvents(true);
128 return window;
129}
130
131function transitionHtml(
132 sourceBounds: Electron.Rectangle,
133 overlayBounds: Electron.Rectangle,
134 flash: boolean,
135): string {
136 const source = {
137 x: sourceBounds.x - overlayBounds.x,
138 y: sourceBounds.y - overlayBounds.y,
139 width: sourceBounds.width,
140 height: sourceBounds.height,
141 };
142 return `<!doctype html><style>
143html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent}
144#card{position:absolute;left:${source.x}px;top:${source.y}px;width:${source.width}px;height:${source.height}px;contain:strict;overflow:hidden;border-radius:12px;background:#fff;box-shadow:0 24px 70px rgba(0,0,0,.24);transform-origin:center;will-change:transform}
145#content{position:absolute;inset:0;overflow:hidden;border-radius:inherit;transform-origin:center;will-change:transform}
146#snapshot{position:absolute;inset:0;width:100%;height:100%;object-fit:fill;transform-origin:center;will-change:transform}
147#flash{position:absolute;inset:0;background:#fff;opacity:0;${flash ? "" : "display:none"}}
148#details{--scale:1;position:absolute;inset-inline:0;bottom:0;display:flex;align-items:center;gap:calc(6px * var(--scale));padding:calc(24px * var(--scale)) calc(10px * var(--scale)) calc(8px * var(--scale));background:linear-gradient(to top,rgba(0,0,0,.85),rgba(0,0,0,.55),transparent);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;opacity:0}
149#details:not([data-ready]){visibility:hidden}
150#icon,#fallback{width:calc(28px * var(--scale));height:calc(28px * var(--scale));flex:none;border-radius:calc(6px * var(--scale))}
151#icon{display:none;object-fit:cover}
152#fallback{display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,.2);color:#fff;font-size:calc(10px * var(--scale));font-weight:500;text-transform:uppercase}
153#copy{min-width:0;flex:1}#app,#title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:calc(14px * var(--scale))}
154#app{color:#fff;font-size:calc(11px * var(--scale));font-weight:500}#title{color:rgba(255,255,255,.7);font-size:calc(9px * var(--scale))}
155#border{position:absolute;z-index:2;inset:0;box-sizing:border-box;border:0 solid transparent;border-radius:inherit;opacity:0}
156</style><div id="card"><div id="content"><img id="snapshot"><div id="details"><img id="icon"><div id="fallback"></div><div id="copy"><div id="app"></div><div id="title"></div></div></div></div><div id="border"></div><div id="flash"></div></div><script>
157const source=${JSON.stringify(source)},card=document.getElementById("card"),content=document.getElementById("content"),snapshot=document.getElementById("snapshot"),details=document.getElementById("details"),border=document.getElementById("border");
158const afterPaint=()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
159window.setCaptureSnapshot=async src=>{snapshot.src=src;await snapshot.decode();await afterPaint()};
160window.rebaseCaptureSource=next=>{Object.assign(source,next);Object.assign(card.style,{left:source.x+"px",top:source.y+"px",width:source.width+"px",height:source.height+"px"})};
161window.startCaptureFlash=()=>document.getElementById("flash").animate([{opacity:.08},{offset:.38,opacity:.08},{offset:.68,opacity:.02},{opacity:0}],{duration:300,fill:"forwards",easing:"${EASING}"});
162const applyDetails=value=>{
163 if(!value)return;
164 details.dataset.ready="";
165 document.getElementById("app").textContent=value.appName;
166 document.getElementById("title").textContent=value.windowTitle||"Captured window";
167 document.getElementById("fallback").textContent=value.appName.slice(0,1);
168 if(value.appIconDataUrl){
169 const icon=document.getElementById("icon");icon.src=value.appIconDataUrl;icon.style.display="block";document.getElementById("fallback").style.display="none";
170 }
171};
172window.updateCaptureDetails=applyDetails;
173window.prepareCaptureTransition=async destination=>{
174 const target=destination.frame,borderWidth=Math.min(Math.max(0,destination.borderWidth),Math.max(0,(Math.min(target.width,target.height)-1)/2));
175 const inner={width:Math.max(1,target.width-borderWidth*2),height:Math.max(1,target.height-borderWidth*2)};
176 const sourceCenter={x:source.x+source.width/2,y:source.y+source.height/2},targetCenter={x:target.x+target.width/2,y:target.y+target.height/2};
177 const initial="translate3d("+(sourceCenter.x-targetCenter.x)+"px,"+(sourceCenter.y-targetCenter.y)+"px,0) scale("+(source.width/target.width)+","+(source.height/target.height)+")";
178 const imageWidth=Math.max(1,snapshot.naturalWidth||source.width),imageHeight=Math.max(1,snapshot.naturalHeight||source.height),cover=Math.max(inner.width/imageWidth,inner.height/imageHeight);
179 Object.assign(card.style,{left:target.x+"px",top:target.y+"px",width:target.width+"px",height:target.height+"px",borderRadius:destination.cornerRadius+"px",backgroundColor:destination.backgroundColor,boxShadow:"none"});
180 Object.assign(content.style,{inset:borderWidth+"px",borderRadius:Math.max(0,destination.cornerRadius-borderWidth)+"px"});
181 Object.assign(border.style,{borderWidth:borderWidth+"px",borderColor:destination.borderColor});
182 details.style.setProperty("--scale",String(destination.scaleFactor));
183 applyDetails(destination.details);
184 const options={duration:destination.durationMs,fill:"forwards",easing:"${EASING}"};
185 const animations=[
186 card.animate([{transform:initial},{transform:"translate3d(0,0,0) scale(1)"}],options),
187 content.animate([{transform:"scale("+(target.width/inner.width)+","+(target.height/inner.height)+")"},{transform:"scale(1)"}],options),
188 snapshot.animate([{transform:"scale(1)"},{transform:"scale("+((imageWidth*cover)/inner.width)+","+((imageHeight*cover)/inner.height)+")"}],options),
189 border.animate([{opacity:0},{opacity:1}],options),
190 details.animate([{opacity:0},{opacity:1}],{delay:Math.max(0,destination.durationMs-125),duration:125,fill:"both",easing:"ease-in"})
191 ];
192 for(const animation of animations){animation.pause();animation.currentTime=0}
193 window.playCaptureTransition=async()=>{
194 for(const animation of animations)animation.play();
195 await Promise.all(animations.map(animation=>animation.finished.catch(()=>undefined)));
196 await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
197 };
198 await afterPaint();
199};
200</script>`;
201}
202
203export class SnapShotTransition {
204 private active: ActiveTransition | undefined;
205 private readonly boundOverlayToCaptureDisplays: boolean;
206 private readonly waitForCompositorFrame: boolean;
207 private readonly alwaysOnTopLevel: SnapShotTransitionOptions["alwaysOnTopLevel"];
208 private readonly showWindow: (window: Electron.BaseWindow) => void;
209
210 constructor(options: SnapShotTransitionOptions = {}) {
211 this.boundOverlayToCaptureDisplays = options.boundOverlayToCaptureDisplays ?? false;
212 this.waitForCompositorFrame = options.waitForCompositorFrame ?? false;
213 this.alwaysOnTopLevel = options.alwaysOnTopLevel;
214 this.showWindow = options.showWindow ?? ((window) => window.showInactive());
215 }
216
217 async begin(
218 id: string,
219 source: Electron.Rectangle,
220 snapshotDataUrl: string,
221 flash: boolean,
222 destinationWindowBounds?: Electron.Rectangle,
223 ): Promise<void> {
224 this.dispose();
225 const sourceDisplay = Electron.screen.getDisplayMatching(source);
226 const requestedBounds = this.boundOverlayToCaptureDisplays
227 ? snapShotAnimationDisplayBounds(
228 [
229 sourceDisplay,
230 ...Electron.screen.getAllDisplays().filter((d) => d.id !== sourceDisplay.id),
231 ],
232 source,
233 destinationWindowBounds ?? source,
234 )
235 : [snapShotAnimationOverlayBounds(Electron.screen.getAllDisplays())];
236 const active: ActiveTransition = {
237 id,
238 source,
239 snapshotDataUrl,
240 overlays: [],
241 };
242 active.timer = Effect.runFork(
243 Effect.sleep(TIMEOUT_MS).pipe(
244 Effect.andThen(
245 Effect.sync(() => {
246 if (this.active === active) this.dispose();
247 }),
248 ),
249 ),
250 );
251 this.active = active;
252 try {
253 await this.showOverlays(active, requestedBounds, flash);
254 } catch (error) {
255 if (this.active === active) this.dispose();
256 throw error;
257 }
258 }
259
260 private async showOverlays(
261 active: ActiveTransition,
262 requestedBounds: ReadonlyArray<Electron.Rectangle>,
263 flash: boolean,
264 ): Promise<void> {
265 const overlays: ActiveTransition["overlays"] = [];
266 try {
267 for (const bounds of requestedBounds) {
268 const window = createWindow(bounds, this.alwaysOnTopLevel);
269 const overlay = { window, requestedBounds: bounds, bounds: window.getBounds() };
270 active.overlays.push(overlay);
271 overlays.push(overlay);
272 }
273 await Promise.all(
274 overlays.map(async (overlay) => {
275 await overlay.window.loadURL(
276 "data:text/html;charset=utf-8," +
277 encodeURIComponent(transitionHtml(active.source, overlay.bounds, flash)),
278 );
279 if (this.active === active && !overlay.window.isDestroyed()) {
280 await overlay.window.webContents.executeJavaScript(
281 `window.setCaptureSnapshot(${JSON.stringify(active.snapshotDataUrl)})`,
282 );
283 }
284 }),
285 );
286 for (const overlay of overlays) {
287 if (this.active !== active) return;
288 if (overlay.window.isDestroyed()) continue;
289 this.showWindow(overlay.window);
290 const bounds = overlay.window.getBounds();
291 if (
292 bounds.x !== overlay.bounds.x ||
293 bounds.y !== overlay.bounds.y ||
294 bounds.width !== overlay.bounds.width ||
295 bounds.height !== overlay.bounds.height
296 ) {
297 overlay.bounds = bounds;
298 await overlay.window.webContents.executeJavaScript(
299 `window.rebaseCaptureSource(${JSON.stringify({
300 x: active.source.x - bounds.x,
301 y: active.source.y - bounds.y,
302 width: active.source.width,
303 height: active.source.height,
304 })})`,
305 );
306 }
307 }
308 if (this.waitForCompositorFrame) {
309 // Cover the source with a composited snapshot before the caller reveals T3.
310 // Decoding and renderer animation frames alone can leave a transparent gap.
311 await Promise.all(
312 overlays.map(async (overlay) => {
313 if (this.active !== active || overlay.window.isDestroyed()) return;
314 await overlay.window.webContents.capturePage({ x: 0, y: 0, width: 1, height: 1 });
315 }),
316 );
317 }
318 if (this.active !== active) return;
319 if (flash) {
320 await Promise.all(
321 overlays.map(async (overlay) => {
322 if (overlay.window.isDestroyed()) return;
323 await overlay.window.webContents
324 .executeJavaScript("window.startCaptureFlash()")
325 .catch(() => undefined);
326 }),
327 );
328 }
329 } catch (error) {
330 for (const overlay of overlays) {
331 if (!overlay.window.isDestroyed()) overlay.window.destroy();
332 }
333 throw error;
334 }
335 }
336
337 animateTo(id: string, destination: SnapShotAnimationDestination): void {
338 const active = this.active;
339 if (!active || active.id !== id) return;
340 if (destination.details) active.details = destination.details;
341 if (active.flight) {
342 if (destination.details) {
343 for (const overlay of active.overlays) {
344 if (overlay.window.isDestroyed()) continue;
345 void overlay.window.webContents
346 .executeJavaScript(
347 "window.updateCaptureDetails(" + JSON.stringify(destination.details) + ")",
348 )
349 .catch(() => undefined);
350 }
351 }
352 return;
353 }
354 active.flight = this.runFlight(active, destination);
355 }
356
357 private async runFlight(
358 active: ActiveTransition,
359 destination: SnapShotAnimationDestination,
360 ): Promise<void> {
361 if (this.active !== active) return;
362 if (this.boundOverlayToCaptureDisplays) {
363 const missingBounds = snapShotAnimationDisplayBounds(
364 Electron.screen.getAllDisplays(),
365 active.source,
366 destination.frame,
367 ).filter(
368 (bounds) =>
369 !active.overlays.some(
370 (overlay) =>
371 !overlay.window.isDestroyed() &&
372 overlay.requestedBounds.x === bounds.x &&
373 overlay.requestedBounds.y === bounds.y &&
374 overlay.requestedBounds.width === bounds.width &&
375 overlay.requestedBounds.height === bounds.height,
376 ),
377 );
378 if (missingBounds.length > 0) {
379 await this.showOverlays(active, missingBounds, false).catch(() => undefined);
380 }
381 }
382 if (this.active !== active) return;
383 const durationMs = snapShotAnimationDurationMs(active.source, destination.frame);
384 const prepared = await Promise.allSettled(
385 active.overlays.map(async (overlay) => {
386 if (overlay.window.isDestroyed()) return;
387 await overlay.window.webContents.executeJavaScript(
388 `window.prepareCaptureTransition(${JSON.stringify({
389 ...destination,
390 frame: {
391 x: destination.frame.x - overlay.bounds.x,
392 y: destination.frame.y - overlay.bounds.y,
393 width: destination.frame.width,
394 height: destination.frame.height,
395 },
396 borderWidth: Math.max(0, destination.borderWidth),
397 cornerRadius: Math.max(0, destination.cornerRadius),
398 scaleFactor: Math.max(0.1, destination.scaleFactor),
399 details: active.details,
400 durationMs,
401 })})`,
402 );
403 if (this.active !== active || overlay.window.isDestroyed()) return;
404 if (this.waitForCompositorFrame) {
405 // Read back one pixel after preparation to flush queued compositor work
406 // before the animation clock starts on any of the display surfaces.
407 await overlay.window.webContents.capturePage({ x: 0, y: 0, width: 1, height: 1 });
408 }
409 return overlay;
410 }),
411 );
412 if (this.active !== active) return;
413 // One display failing must not cut the flight short on the others.
414 await Promise.allSettled(
415 prepared.map((result) => {
416 if (result.status !== "fulfilled" || !result.value || result.value.window.isDestroyed()) {
417 return;
418 }
419 return result.value.window.webContents.executeJavaScript("window.playCaptureTransition()");
420 }),
421 );
422 }
423
424 async waitForLanding(id: string): Promise<void> {
425 const active = this.active;
426 if (!active || active.id !== id) return;
427 await active.flight?.catch(() => undefined);
428 }
429
430 async complete(id: string): Promise<void> {
431 const active = this.active;
432 if (!active || active.id !== id) return;
433 await this.waitForLanding(id);
434 if (this.active === active) this.dispose();
435 }
436
437 dismiss(id: string): void {
438 if (this.active?.id === id) this.dispose();
439 }
440
441 dispose(): void {
442 const active = this.active;
443 this.active = undefined;
444 if (!active) return;
445 active.timer?.interruptUnsafe();
446 for (const overlay of active.overlays) {
447 if (!overlay.window.isDestroyed()) overlay.window.destroy();
448 }
449 }
450}