1import Clutter from "gi://Clutter";
2import Gio from "gi://Gio";
3import GLib from "gi://GLib";
4import Meta from "gi://Meta";
5import St from "gi://St";
6import * as Main from "resource:///org/gnome/shell/ui/main.js";
7import { captureDestinationFrame, findCaptureDestination } from "./feedbackGeometry.js";
8
9/** A single, bounded, sender-owned flight. No live window clone or per-frame JS work. */
10export class CaptureFeedback {
11 begin(sender, pid, snapshot, options) {
12 this.dispose();
13 const session = { sender, pid };
14 this._session = session;
15 this._watch = Gio.bus_watch_name_on_connection(
16 Gio.DBus.session,
17 sender,
18 Gio.BusNameWatcherFlags.NONE,
19 null,
20 () => {
21 if (this._session === session) this.dispose();
22 },
23 );
24 this._timeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 6000, () => {
25 this._timeout = 0;
26 this.dispose();
27 return GLib.SOURCE_REMOVE;
28 });
29 try {
30 if (options.animate && snapshot.content && St.Settings.get().enable_animations) {
31 this._actor = new Clutter.Actor({
32 ...snapshot.bufferBounds,
33 content: snapshot.content,
34 reactive: false,
35 });
36 Main.uiGroup.add_child(this._actor);
37 }
38 if (options.flash) {
39 this._flash = new St.Widget({
40 ...snapshot.bounds,
41 reactive: false,
42 style: "background-color: white;",
43 opacity: 24,
44 });
45 Main.uiGroup.add_child(this._flash);
46 this._flash.ease({
47 opacity: 0,
48 duration: 180,
49 mode: Clutter.AnimationMode.EASE_OUT_QUAD,
50 onComplete: () => {
51 this._flash?.destroy();
52 this._flash = null;
53 },
54 });
55 }
56 } catch (error) {
57 // Optional decoration must not prevent attaching a successfully captured image.
58 this._clearActors();
59 console.warn(`T3 capture effects unavailable: ${error.message}`);
60 }
61 return Boolean(this._actor);
62 }
63
64 _owned(sender) {
65 if (
66 !this._session ||
67 this._session.sender !== sender ||
68 Main.sessionMode.isLocked ||
69 Main.sessionMode.isGreeter
70 ) {
71 throw new Error("No active capture belongs to this caller.");
72 }
73 return this._session;
74 }
75
76 async activate(sender, title) {
77 const session = this._owned(sender);
78 if (session.target) return;
79 const find = () =>
80 findCaptureDestination(
81 global
82 .get_window_actors()
83 .map((actor) => actor.meta_window)
84 .filter((window) => window.get_window_type() === Meta.WindowType.NORMAL),
85 session.pid,
86 title,
87 );
88 // A command-palette capture temporarily unmaps T3. Wait for its new surface, not a sleep.
89 const target =
90 find() ??
91 (await new Promise((resolve) => {
92 const check = () => {
93 const window = find();
94 if (window) finish(window);
95 };
96 let timeout;
97 const mapped = global.window_manager.connect("map", check);
98 const finish = (window) => {
99 global.window_manager.disconnect(mapped);
100 if (timeout) GLib.source_remove(timeout);
101 this._cancelActivation = null;
102 resolve(window);
103 };
104 this._cancelActivation = () => finish(undefined);
105 timeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 1500, () => {
106 timeout = 0;
107 finish(undefined);
108 return GLib.SOURCE_REMOVE;
109 });
110 check();
111 }));
112 if (this._owned(sender) !== session || !target)
113 throw new Error("The Knife's window is not available for activation.");
114 session.target = target;
115 Main.activateWindow(target, global.get_current_time());
116 }
117
118 async animate(sender, relative) {
119 const session = this._owned(sender);
120 if (this._flight) return this._flight;
121 const actor = this._actor;
122 if (!actor || !session.target || !St.Settings.get().enable_animations) {
123 this._clearActors();
124 return;
125 }
126 const window = session.target;
127 const bounds = window.frame_rect_to_client_rect(window.get_frame_rect());
128 const frame = captureDestinationFrame(relative, bounds);
129 const distance = Math.hypot(frame.x - actor.x, frame.y - actor.y);
130 this._flight = new Promise((resolve) => {
131 this._land = resolve;
132 actor.ease({
133 x: frame.x,
134 y: frame.y,
135 scale_x: frame.width / actor.width,
136 scale_y: frame.height / actor.height,
137 duration: Math.round(680 - 400 * Math.exp(-distance / 2000)),
138 mode: Clutter.AnimationMode.EASE_OUT_CUBIC,
139 onStopped: () => {
140 this._land?.();
141 this._land = null;
142 },
143 });
144 });
145 await this._flight;
146 // Keep the landed image until the renderer acknowledges the attachment (connection closes).
147 }
148
149 _clearActors() {
150 this._actor?.destroy();
151 this._flash?.destroy();
152 this._actor = null;
153 this._flash = null;
154 this._land?.();
155 this._land = null;
156 }
157
158 dispose() {
159 this._session = null;
160 this._cancelActivation?.();
161 this._clearActors();
162 this._flight = null;
163 if (this._watch) Gio.bus_unwatch_name(this._watch);
164 if (this._timeout) GLib.source_remove(this._timeout);
165 this._watch = 0;
166 this._timeout = 0;
167 }
168}