@tryinget/pi-activity-strip 0.2.0 → 0.4.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.
@@ -0,0 +1,178 @@
1
+ // ---
2
+ // summary: "owns Niri native strip remapping, floating conversion, and exact top-edge alignment"
3
+ // read_when:
4
+ // - "changing compositor-native activity-strip placement or animation detection"
5
+ // ---
6
+
7
+ import { hasNiriFloatingPosition, isNiriWindowAligned } from "../common/alignment-controller.mjs";
8
+ import { resolveActivityStripWindow } from "../common/niri-focus.mjs";
9
+
10
+ const defaultWait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
11
+
12
+ function formatDelta(value) {
13
+ const rounded = Math.round(value);
14
+ return rounded >= 0 ? `+${rounded}` : String(rounded);
15
+ }
16
+
17
+ function readPosition(window) {
18
+ const position = window?.layout?.tile_pos_in_workspace_view;
19
+ if (!Array.isArray(position) || position.length < 2) return null;
20
+ return {
21
+ x: Number(position[0] ?? 0),
22
+ y: Number(position[1] ?? 0),
23
+ };
24
+ }
25
+
26
+ /**
27
+ * @param {{
28
+ * execFileAsync: (file: string, args: string[], options: object) => Promise<unknown>;
29
+ * env: NodeJS.ProcessEnv;
30
+ * timeoutMs: number;
31
+ * processId: number;
32
+ * readWindows: () => Promise<Array<Record<string, unknown>>>;
33
+ * getBrowserWindow: () => import("electron").BrowserWindow | null;
34
+ * getBounds: () => {x: number; y: number; width: number; height: number};
35
+ * isNiriSession: () => boolean;
36
+ * wait?: (milliseconds: number) => Promise<unknown>;
37
+ * }} options
38
+ */
39
+ export function createNiriNativeWindowRuntime(options) {
40
+ const wait = options.wait ?? defaultWait;
41
+
42
+ function resolveProcessWindow(windows) {
43
+ return resolveActivityStripWindow(
44
+ windows.filter((window) => window?.pid === options.processId),
45
+ );
46
+ }
47
+
48
+ async function getProcessWindow() {
49
+ return resolveProcessWindow(await options.readWindows());
50
+ }
51
+
52
+ async function moveWindowToWorkspace(stripWindow, workspace) {
53
+ const reference = workspace.name || workspace.idx;
54
+ if (reference == null) return false;
55
+ try {
56
+ await options.execFileAsync(
57
+ "niri",
58
+ [
59
+ "msg",
60
+ "action",
61
+ "move-window-to-workspace",
62
+ "--window-id",
63
+ String(stripWindow.id),
64
+ "--focus",
65
+ "false",
66
+ String(reference),
67
+ ],
68
+ { env: options.env, timeout: options.timeoutMs },
69
+ );
70
+ return true;
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ async function moveWindowToTop(isCurrent) {
77
+ let animated = false;
78
+ const browserWindow = options.getBrowserWindow();
79
+ if (!isCurrent() || !browserWindow || browserWindow.isDestroyed() || !options.isNiriSession()) {
80
+ return { ok: false, animated };
81
+ }
82
+
83
+ let niriWindow = await getProcessWindow();
84
+ if (!isCurrent() || !niriWindow?.id) return { ok: false, animated };
85
+ if (niriWindow.is_floating !== true) {
86
+ try {
87
+ await options.execFileAsync(
88
+ "niri",
89
+ ["msg", "action", "move-window-to-floating", "--id", String(niriWindow.id)],
90
+ { env: options.env },
91
+ );
92
+ animated = true;
93
+ } catch {
94
+ return { ok: false, animated };
95
+ }
96
+ }
97
+
98
+ for (let attempt = 0; attempt < 8; attempt += 1) {
99
+ if (!isCurrent()) return { ok: false, animated };
100
+ const position = readPosition(niriWindow);
101
+ if (hasNiriFloatingPosition(niriWindow) && position) {
102
+ const target = options.getBounds();
103
+ if (isNiriWindowAligned(niriWindow, target)) return { ok: true, animated };
104
+ const deltaX = target.x - position.x;
105
+ const deltaY = target.y - position.y;
106
+ if (Math.abs(deltaX) >= 1 || Math.abs(deltaY) >= 1) {
107
+ try {
108
+ await options.execFileAsync(
109
+ "niri",
110
+ [
111
+ "msg",
112
+ "action",
113
+ "move-floating-window",
114
+ "--id",
115
+ String(niriWindow.id),
116
+ "-x",
117
+ formatDelta(deltaX),
118
+ "-y",
119
+ formatDelta(deltaY),
120
+ ],
121
+ { env: options.env },
122
+ );
123
+ animated = true;
124
+ } catch {
125
+ return { ok: false, animated };
126
+ }
127
+ }
128
+ }
129
+ await wait(60);
130
+ if (!isCurrent()) return { ok: false, animated };
131
+ niriWindow = await getProcessWindow();
132
+ if (!niriWindow?.id) return { ok: false, animated };
133
+ }
134
+ return { ok: false, animated };
135
+ }
136
+
137
+ async function alignWindowToTop(isCurrent) {
138
+ const browserWindow = options.getBrowserWindow();
139
+ if (!isCurrent() || !browserWindow || browserWindow.isDestroyed()) {
140
+ return { ok: false, animated: false };
141
+ }
142
+ if (!options.isNiriSession()) {
143
+ browserWindow.setBounds(options.getBounds(), false);
144
+ return { ok: true, animated: false };
145
+ }
146
+
147
+ const niriWindow = await getProcessWindow();
148
+ if (!isCurrent()) return { ok: false, animated: false };
149
+ const target = options.getBounds();
150
+ const currentSize = niriWindow?.layout?.window_size;
151
+ const widthMatches = Array.isArray(currentSize) && Number(currentSize[0]) === target.width;
152
+ const heightMatches = Array.isArray(currentSize) && Number(currentSize[1]) === target.height;
153
+ let animated = false;
154
+
155
+ if (!widthMatches || !heightMatches) {
156
+ browserWindow.setSize(target.width, target.height, false);
157
+ animated = true;
158
+ await wait(80);
159
+ if (!isCurrent()) return { ok: false, animated };
160
+ }
161
+
162
+ for (let attempt = 0; attempt < 8; attempt += 1) {
163
+ const result = await moveWindowToTop(isCurrent);
164
+ animated ||= result.animated;
165
+ if (result.ok) return { ok: true, animated };
166
+ if (!isCurrent()) return { ok: false, animated };
167
+ await wait(60);
168
+ }
169
+ return { ok: false, animated };
170
+ }
171
+
172
+ return {
173
+ alignWindowToTop,
174
+ isWindowAligned: (window) => isNiriWindowAligned(window, options.getBounds()),
175
+ moveWindowToWorkspace,
176
+ resolveProcessWindow,
177
+ };
178
+ }
@@ -0,0 +1,81 @@
1
+ // ---
2
+ // summary: "watches focused Niri workspace events with a bounded polling fallback"
3
+ // read_when:
4
+ // - "changing activity-strip workspace switch latency or Niri event-stream handling"
5
+ // ---
6
+
7
+ /**
8
+ * Trigger focused-workspace reconciliation immediately from Niri's event
9
+ * stream while retaining the slower poll as a fail-closed fallback.
10
+ * @param {{
11
+ * spawn: Function;
12
+ * env: NodeJS.ProcessEnv;
13
+ * onFocusedWorkspace: (workspaceId: number) => void;
14
+ * onFallback: () => void;
15
+ * fallbackMs: number;
16
+ * setIntervalFn?: typeof setInterval;
17
+ * clearIntervalFn?: typeof clearInterval;
18
+ * }} options
19
+ */
20
+ export function createNiriWorkspaceEventWatcher(options) {
21
+ const setIntervalFn = options.setIntervalFn ?? setInterval;
22
+ const clearIntervalFn = options.clearIntervalFn ?? clearInterval;
23
+ let buffer = "";
24
+ let stopped = false;
25
+ let child = null;
26
+ let lastFocusedWorkspaceId = null;
27
+
28
+ const fallbackTimer = setIntervalFn(() => {
29
+ if (!stopped) options.onFallback();
30
+ }, options.fallbackMs);
31
+ fallbackTimer.unref?.();
32
+
33
+ function consume(chunk) {
34
+ if (stopped) return;
35
+ buffer += String(chunk ?? "");
36
+ let newlineIndex = buffer.indexOf("\n");
37
+ while (newlineIndex >= 0) {
38
+ const line = buffer.slice(0, newlineIndex).trim();
39
+ buffer = buffer.slice(newlineIndex + 1);
40
+ if (line) {
41
+ try {
42
+ const event = JSON.parse(line);
43
+ const activation = event?.WorkspaceActivated;
44
+ if (
45
+ activation?.focused === true &&
46
+ Number.isInteger(activation.id) &&
47
+ activation.id !== lastFocusedWorkspaceId
48
+ ) {
49
+ lastFocusedWorkspaceId = activation.id;
50
+ options.onFocusedWorkspace(activation.id);
51
+ }
52
+ } catch {
53
+ // A malformed event is ignored; the fallback poll remains authoritative.
54
+ }
55
+ }
56
+ newlineIndex = buffer.indexOf("\n");
57
+ }
58
+ }
59
+
60
+ try {
61
+ child = options.spawn("niri", ["msg", "-j", "event-stream"], {
62
+ env: options.env,
63
+ stdio: ["ignore", "pipe", "ignore"],
64
+ });
65
+ child.stdout?.on?.("data", consume);
66
+ child.on?.("error", () => {});
67
+ } catch {
68
+ child = null;
69
+ }
70
+
71
+ function stop() {
72
+ if (stopped) return;
73
+ stopped = true;
74
+ clearIntervalFn(fallbackTimer);
75
+ child?.stdout?.removeListener?.("data", consume);
76
+ child?.kill?.();
77
+ child = null;
78
+ }
79
+
80
+ return { stop };
81
+ }
@@ -1,6 +1,46 @@
1
+ // ---
2
+ // summary: "exposes a sandboxed renderer subscription API for activity-strip snapshot IPC messages"
3
+ // read_when:
4
+ // - "changing the preload bridge or renderer snapshot subscription contract"
5
+ // ---
6
+
1
7
  const { contextBridge, ipcRenderer } = require("electron");
2
8
 
3
9
  contextBridge.exposeInMainWorld("activityStrip", {
10
+ activate(sessionId) {
11
+ return ipcRenderer.invoke("pi-activity-strip:focus", String(sessionId ?? ""));
12
+ },
13
+ setExpanded(expanded) {
14
+ return ipcRenderer.invoke("pi-activity-strip:set-expanded", Boolean(expanded));
15
+ },
16
+ onCollapse(handler) {
17
+ if (typeof handler !== "function") return () => {};
18
+ const listener = () => handler();
19
+ ipcRenderer.on("pi-activity-strip:collapse", listener);
20
+ return () => ipcRenderer.removeListener("pi-activity-strip:collapse", listener);
21
+ },
22
+ onVisibility(handler) {
23
+ if (typeof handler !== "function") return () => {};
24
+ let latestRequestId = 0;
25
+ const listener = (_event, visible, requestId) => {
26
+ if (!Number.isInteger(requestId) || requestId <= 0) return;
27
+ latestRequestId = Math.max(latestRequestId, requestId);
28
+ const isCurrent = () => requestId === latestRequestId;
29
+ Promise.resolve()
30
+ .then(() => handler(Boolean(visible), isCurrent))
31
+ .then((applied) =>
32
+ ipcRenderer.send(
33
+ "pi-activity-strip:visibility-applied",
34
+ requestId,
35
+ Boolean(visible),
36
+ applied !== false,
37
+ ),
38
+ )
39
+ .catch(() => {});
40
+ };
41
+ ipcRenderer.on("pi-activity-strip:visibility", listener);
42
+ return () => ipcRenderer.removeListener("pi-activity-strip:visibility", listener);
43
+ },
4
44
  subscribe(handler) {
5
45
  if (typeof handler !== "function") return () => {};
6
46
  const listener = (_event, snapshot) => handler(snapshot);
@@ -0,0 +1,107 @@
1
+ // ---
2
+ // summary: "acknowledges renderer conceal and reveal frames before native Niri lifecycle effects"
3
+ // read_when:
4
+ // - "changing renderer visibility handshakes or wrong-workspace flash prevention"
5
+ // ---
6
+
7
+ export const VISIBILITY_CHANNEL = "pi-activity-strip:visibility";
8
+ export const VISIBILITY_APPLIED_CHANNEL = "pi-activity-strip:visibility-applied";
9
+
10
+ /**
11
+ * Coordinate one bounded main-to-renderer visibility request. The renderer
12
+ * acknowledges only after its CSS visibility change crosses two animation
13
+ * frames, so callers can keep stale cards concealed during native remaps.
14
+ * @param {{
15
+ * getWebContents: () => {send: (channel: string, ...args: unknown[]) => void} | null;
16
+ * setInputEnabled: (enabled: boolean) => void;
17
+ * timeoutMs: number;
18
+ * setTimer?: typeof setTimeout;
19
+ * clearTimer?: typeof clearTimeout;
20
+ * }} options
21
+ */
22
+ export function createRendererVisibilityRuntime(options) {
23
+ const setTimer = options.setTimer ?? setTimeout;
24
+ const clearTimer = options.clearTimer ?? clearTimeout;
25
+ let nextRequestId = 0;
26
+ let latestRequestId = 0;
27
+ const pending = new Map();
28
+
29
+ function settle(requestId, applied) {
30
+ const request = pending.get(requestId);
31
+ if (!request) return false;
32
+ pending.delete(requestId);
33
+ clearTimer(request.timer);
34
+ request.resolve(applied);
35
+ return true;
36
+ }
37
+
38
+ async function apply(visible, isCurrent = () => true) {
39
+ const webContents = options.getWebContents();
40
+ if (!webContents) return false;
41
+ if (!visible) options.setInputEnabled(false);
42
+
43
+ nextRequestId += 1;
44
+ const requestId = nextRequestId;
45
+ latestRequestId = requestId;
46
+ const applied = new Promise((resolve) => {
47
+ const timer = setTimer(() => settle(requestId, false), options.timeoutMs);
48
+ pending.set(requestId, { resolve, timer, webContents, visible: Boolean(visible) });
49
+ });
50
+ try {
51
+ webContents.send(VISIBILITY_CHANNEL, Boolean(visible), requestId);
52
+ } catch {
53
+ settle(requestId, false);
54
+ }
55
+
56
+ const succeeded = await applied;
57
+ const appliedToCurrentRenderer =
58
+ succeeded &&
59
+ requestId === latestRequestId &&
60
+ isCurrent() &&
61
+ options.getWebContents() === webContents;
62
+ if (appliedToCurrentRenderer && visible) options.setInputEnabled(true);
63
+ return appliedToCurrentRenderer;
64
+ }
65
+
66
+ function acknowledge(event, requestId, visible, applied = true) {
67
+ const request = pending.get(requestId);
68
+ if (!request || event?.sender !== request.webContents || Boolean(visible) !== request.visible) {
69
+ return false;
70
+ }
71
+ return settle(requestId, Boolean(applied));
72
+ }
73
+
74
+ function dispose() {
75
+ nextRequestId += 1;
76
+ latestRequestId = nextRequestId;
77
+ for (const requestId of [...pending.keys()]) settle(requestId, false);
78
+ options.setInputEnabled(false);
79
+ }
80
+
81
+ return {
82
+ acknowledge,
83
+ appliedChannel: VISIBILITY_APPLIED_CHANNEL,
84
+ apply,
85
+ dispose,
86
+ };
87
+ }
88
+
89
+ /**
90
+ * @param {() => import("electron").BrowserWindow | null} getWindow
91
+ * @param {boolean} interactive
92
+ */
93
+ export function createBrowserWindowVisibilityRuntime(getWindow, interactive) {
94
+ return createRendererVisibilityRuntime({
95
+ getWebContents: () => {
96
+ const window = getWindow();
97
+ return window && !window.isDestroyed() ? window.webContents : null;
98
+ },
99
+ setInputEnabled: (enabled) => {
100
+ const window = getWindow();
101
+ if (!window || window.isDestroyed()) return;
102
+ window.setFocusable(enabled && interactive);
103
+ window.setIgnoreMouseEvents(!enabled || !interactive, { forward: true });
104
+ },
105
+ timeoutMs: 750,
106
+ });
107
+ }