Skip to content

Commit 18d892a

Browse files
authored
feat: anchor to bars from other shells and keep Gitify above other windows (#27)
1 parent 1410dc6 commit 18d892a

3 files changed

Lines changed: 276 additions & 8 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ A KWin script that places Gitify beside the panel on native Wayland.
66

77
When you click Gitify's tray icon, the popup opens next to the click position. Keyboard opens reuse the last panel anchor. Before the first tray click, it opens at the end of a panel on the pointer's display, or in the top-right corner when no panel is available.
88

9-
The script supports horizontal and vertical panels, floating and auto-hide panels, and multiple displays. It keeps the popup inside the usable display area and repositions it when Gitify changes size. Dialogs and other applications are left alone.
9+
The script supports horizontal and vertical panels, floating and auto-hide panels, and multiple displays. It also works with bars from other shells, such as Caelestia, including bars drawn inside a surface that covers the whole display; there it places the popup beside the clicked tray icon. It keeps the popup inside the usable display area, keeps it above other windows, and repositions it when Gitify changes size. Dialogs and other applications are left alone.
1010

1111
KWin does not expose individual system-tray icon rectangles to scripts. The script therefore uses the pointer position at the panel when Gitify opens, rather than reading private Plasma widget internals. Icons opened from the tray overflow and startup opens use the remembered position or panel fallback.
1212

‎gitify/contents/code/main.js‎

Lines changed: 118 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
const GAP = 8;
2+
// How far from a display edge a tray click can be when no panel is found there.
3+
const EDGE_REACH = 64;
24
const windows = new Map();
35
let lastAnchor = null;
46

@@ -64,13 +66,92 @@ function placement(size, area, anchor) {
6466
};
6567
}
6668

69+
// Shells such as Caelestia draw their bar inside one dock surface that covers
70+
// the whole display, which gives no panel edge to anchor to.
71+
function isFullDisplayDock(window) {
72+
const rect = window.frameGeometry;
73+
const screen = window.output && window.output.geometry;
74+
return Boolean(
75+
window.dock && screen && rect.width > screen.width / 2 && rect.height > screen.height / 2,
76+
);
77+
}
78+
79+
function isPanel(window) {
80+
const rect = window.frameGeometry;
81+
return window.dock && rect.width > 0 && rect.height > 0 && !isFullDisplayDock(window);
82+
}
83+
6784
function panelWindows() {
68-
return workspace.windowList().filter((window) =>
69-
window.dock && String(window.resourceClass) === 'plasmashell' &&
70-
window.frameGeometry.width > 0 && window.frameGeometry.height > 0,
85+
return workspace.windowList().filter(isPanel);
86+
}
87+
88+
function unmeasuredDockAt(point) {
89+
return workspace.windowList().some((window) =>
90+
isFullDisplayDock(window) && contains(window.frameGeometry, point),
7191
);
7292
}
7393

94+
// Edge anchors only stand in for a bar KWin cannot measure, so they lapse
95+
// once that output gains a measurable panel or loses its full-display dock.
96+
function edgeAnchorUsable(output, panels, removed) {
97+
return workspace.screens.includes(output) &&
98+
!panels.some((panel) => panel.output === output && panel !== removed) &&
99+
workspace.windowList().some((window) =>
100+
window !== removed && isFullDisplayDock(window) && window.output === output);
101+
}
102+
103+
// A bar beside a display edge that KWin cannot measure, rebuilt from the
104+
// display's current geometry so it follows resolution and scale changes.
105+
// `along` is the click's position along the edge and `distance` its distance
106+
// from the edge.
107+
function edgeAnchor(output, edge, along, distance) {
108+
const screen = output.geometry;
109+
const horizontal = edge === 'top' || edge === 'bottom';
110+
const start = horizontal ? screen.x : screen.y;
111+
const length = horizontal ? screen.width : screen.height;
112+
if (along < start || along >= start + length) {
113+
return null;
114+
}
115+
const thickness = Math.max(1, Math.round(distance * 2));
116+
const frameGeometry = {
117+
top: { x: screen.x, y: screen.y, width: screen.width, height: thickness },
118+
bottom: { x: screen.x, y: screen.y + screen.height - thickness, width: screen.width, height: thickness },
119+
left: { x: screen.x, y: screen.y, width: thickness, height: screen.height },
120+
right: { x: screen.x + screen.width - thickness, y: screen.y, width: thickness, height: screen.height },
121+
}[edge];
122+
const point = {
123+
top: { x: along, y: screen.y + distance },
124+
bottom: { x: along, y: screen.y + screen.height - distance },
125+
left: { x: screen.x + distance, y: along },
126+
right: { x: screen.x + screen.width - distance, y: along },
127+
}[edge];
128+
return { panel: { frameGeometry, output }, edge, point, pointer: { output, edge, along, distance } };
129+
}
130+
131+
// A tray click on a display with no measurable panel, such as a bar drawn
132+
// inside a full-display surface. The pointer sits on the icon, so the bar is
133+
// assumed to span from the display edge to as far past the pointer as the
134+
// edge is before it.
135+
function pointerAnchor(cursor, panels) {
136+
const output = workspace.screenAt(cursor);
137+
if (!output || panels.some((panel) => panel.output === output) || !unmeasuredDockAt(cursor)) {
138+
return null;
139+
}
140+
const screen = output.geometry;
141+
const distances = {
142+
top: cursor.y - screen.y,
143+
bottom: screen.y + screen.height - cursor.y,
144+
left: cursor.x - screen.x,
145+
right: screen.x + screen.width - cursor.x,
146+
};
147+
const edge = Object.keys(distances).reduce((a, b) => (distances[b] < distances[a] ? b : a));
148+
if (distances[edge] > EDGE_REACH) {
149+
return null;
150+
}
151+
const along = edge === 'top' || edge === 'bottom' ? cursor.x : cursor.y;
152+
return edgeAnchor(output, edge, along, distances[edge]);
153+
}
154+
74155
function selectAnchor(window) {
75156
const panels = panelWindows();
76157
const cursor = { x: workspace.cursorPos.x, y: workspace.cursorPos.y };
@@ -84,6 +165,20 @@ function selectAnchor(window) {
84165
return lastAnchor;
85166
}
86167

168+
const clickedEdge = pointerAnchor(cursor, panels);
169+
if (clickedEdge) {
170+
lastAnchor = clickedEdge;
171+
return lastAnchor;
172+
}
173+
174+
if (lastAnchor && lastAnchor.pointer) {
175+
const { output, edge, along, distance } = lastAnchor.pointer;
176+
lastAnchor = edgeAnchorUsable(output, panels) ? edgeAnchor(output, edge, along, distance) : null;
177+
if (lastAnchor) {
178+
return lastAnchor;
179+
}
180+
}
181+
87182
if (lastAnchor && panels.includes(lastAnchor.panel)) {
88183
const panel = lastAnchor.panel;
89184
const edge = edgeOf(panel.frameGeometry, panel.output.geometry);
@@ -94,7 +189,8 @@ function selectAnchor(window) {
94189
lastAnchor = null;
95190

96191
const output = workspace.screenAt(cursor) || window.output;
97-
const panel = panels.find((candidate) => candidate.output === output);
192+
const onOutput = panels.filter((candidate) => candidate.output === output);
193+
const panel = onOutput.find((candidate) => String(candidate.resourceClass) === 'plasmashell') || onOutput[0];
98194
if (!panel) {
99195
return null;
100196
}
@@ -135,6 +231,9 @@ function track(window) {
135231
}
136232
const entry = { window, anchor: null, moving: false };
137233
windows.set(window, entry);
234+
// Electron cannot keep a window above others on Wayland, so a popup opened
235+
// from the tray would otherwise open behind the window that has focus.
236+
window.keepAbove = true;
138237
const onShow = () => {
139238
entry.anchor = selectAnchor(window);
140239
place(entry);
@@ -157,13 +256,25 @@ function track(window) {
157256
}
158257

159258
workspace.windowAdded.connect(track);
259+
// KWin may still list a window while announcing its removal, so it is
260+
// excluded explicitly when checking whether an edge anchor still has a dock.
261+
function anchoredTo(anchor, window) {
262+
if (!anchor) {
263+
return false;
264+
}
265+
if (anchor.pointer) {
266+
return Boolean(window.dock) && !edgeAnchorUsable(anchor.pointer.output, panelWindows(), window);
267+
}
268+
return anchor.panel === window;
269+
}
270+
160271
workspace.windowRemoved.connect((window) => {
161-
if (lastAnchor && lastAnchor.panel === window) {
272+
if (anchoredTo(lastAnchor, window)) {
162273
lastAnchor = null;
163274
}
164275
for (const entry of windows.values()) {
165-
if (entry.anchor && entry.anchor.panel === window) {
166-
entry.anchor = selectAnchor(entry.window);
276+
if (anchoredTo(entry.anchor, window)) {
277+
entry.anchor = entry.anchor.pointer ? null : selectAnchor(entry.window);
167278
place(entry);
168279
}
169280
}

‎tests/positioning.test.js‎

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,160 @@ test('registers each window once and releases it on close', () => {
156156
f.window.closed.emit();
157157
assert.equal(f.windows.size, 0);
158158
});
159+
160+
test('anchors a tray click in a bar drawn inside a full-display dock surface', () => {
161+
const f = fixture();
162+
f.panel.resourceClass = 'quickshell';
163+
f.panel.frameGeometry = { x: 0, y: 0, width: 1919, height: 1080 };
164+
f.add();
165+
assert.deepEqual([f.window.frameGeometry.x, f.window.frameGeometry.y], [1250, 632]);
166+
});
167+
168+
test('opens toward the display interior from a full-display bar on any edge', () => {
169+
for (const [cursor, expected] of [
170+
[{ x: 1000, y: 15 }, [750, 38]],
171+
[{ x: 15, y: 500 }, [38, 300]],
172+
[{ x: 1905, y: 500 }, [1382, 300]],
173+
]) {
174+
const f = fixture();
175+
f.panel.frameGeometry = { x: 0, y: 0, width: 1920, height: 1080 };
176+
f.workspace.cursorPos = cursor;
177+
f.add();
178+
assert.deepEqual([f.window.frameGeometry.x, f.window.frameGeometry.y], expected);
179+
}
180+
});
181+
182+
test('remembers a full-display bar anchor across a keyboard reopen', () => {
183+
const f = fixture();
184+
f.panel.frameGeometry = { x: 0, y: 0, width: 1920, height: 1080 };
185+
f.add();
186+
f.window.hidden = true;
187+
f.workspace.cursorPos = { x: 300, y: 300 };
188+
f.window.hidden = false;
189+
f.window.frameGeometry = { x: 710, y: 340, width: 500, height: 400 };
190+
f.window.windowShown.emit();
191+
assert.deepEqual([f.window.frameGeometry.x, f.window.frameGeometry.y], [1250, 632]);
192+
});
193+
194+
test('uses the corner when a full-display bar is opened away from the display edge', () => {
195+
const f = fixture();
196+
f.panel.frameGeometry = { x: 0, y: 0, width: 1920, height: 1080 };
197+
f.workspace.cursorPos = { x: 900, y: 400 };
198+
f.add();
199+
assert.deepEqual([f.window.frameGeometry.x, f.window.frameGeometry.y], [1412, 8]);
200+
});
201+
202+
test('anchors to docks from other shells', () => {
203+
const f = fixture();
204+
f.panel.resourceClass = 'waybar';
205+
f.panel.frameGeometry = { x: 0, y: 0, width: 1920, height: 30 };
206+
f.workspace.cursorPos = { x: 1000, y: 15 };
207+
f.add();
208+
assert.deepEqual([f.window.frameGeometry.x, f.window.frameGeometry.y], [750, 38]);
209+
});
210+
211+
test('keeps the panel anchor when a keyboard open happens near another display edge', () => {
212+
const f = fixture();
213+
f.add();
214+
f.window.hidden = true;
215+
f.workspace.cursorPos = { x: 300, y: 10 };
216+
f.window.hidden = false;
217+
f.window.frameGeometry = { x: 710, y: 340, width: 500, height: 400 };
218+
f.window.windowShown.emit();
219+
assert.deepEqual([f.window.frameGeometry.x, f.window.frameGeometry.y], [1250, 632]);
220+
});
221+
222+
test('keeps Gitify above other windows', () => {
223+
const f = fixture();
224+
f.add();
225+
assert.equal(f.window.keepAbove, true);
226+
});
227+
228+
test('rebuilds a full-display bar anchor when the display changes size', () => {
229+
const f = fixture();
230+
f.panel.frameGeometry = { x: 0, y: 0, width: 1920, height: 1080 };
231+
f.add();
232+
f.output.geometry = { x: 0, y: 0, width: 2560, height: 1440 };
233+
f.window.hidden = true;
234+
f.workspace.cursorPos = { x: 300, y: 300 };
235+
f.window.hidden = false;
236+
f.window.frameGeometry = { x: 710, y: 340, width: 500, height: 400 };
237+
f.window.windowShown.emit();
238+
assert.deepEqual([f.window.frameGeometry.x, f.window.frameGeometry.y], [1250, 992]);
239+
});
240+
241+
test('prefers the Plasma panel over other docks before the first tray click', () => {
242+
const f = fixture();
243+
const dock = { dock: true, resourceClass: 'latte-dock', output: f.output, frameGeometry: { x: 0, y: 0, width: 48, height: 1080 } };
244+
f.allWindows.unshift(dock);
245+
f.workspace.cursorPos = { x: 900, y: 400 };
246+
f.add();
247+
assert.deepEqual([f.window.frameGeometry.x, f.window.frameGeometry.y], [1412, 632]);
248+
});
249+
250+
test('drops a full-display bar anchor once the display gains a measurable panel', () => {
251+
const f = fixture();
252+
f.panel.frameGeometry = { x: 0, y: 0, width: 1920, height: 1080 };
253+
f.add();
254+
f.panel.frameGeometry = { x: 0, y: 0, width: 1920, height: 30 };
255+
f.window.hidden = true;
256+
f.workspace.cursorPos = { x: 300, y: 300 };
257+
f.window.hidden = false;
258+
f.window.frameGeometry = { x: 710, y: 340, width: 500, height: 400 };
259+
f.window.windowShown.emit();
260+
assert.deepEqual([f.window.frameGeometry.x, f.window.frameGeometry.y], [1412, 38]);
261+
});
262+
263+
test('drops a full-display bar anchor once its dock is gone', () => {
264+
const f = fixture();
265+
f.panel.frameGeometry = { x: 0, y: 0, width: 1920, height: 1080 };
266+
f.add();
267+
f.allWindows.splice(f.allWindows.indexOf(f.panel), 1);
268+
f.window.hidden = true;
269+
f.workspace.cursorPos = { x: 300, y: 300 };
270+
f.window.hidden = false;
271+
f.window.frameGeometry = { x: 710, y: 340, width: 500, height: 400 };
272+
f.window.windowShown.emit();
273+
assert.deepEqual([f.window.frameGeometry.x, f.window.frameGeometry.y], [1412, 8]);
274+
});
275+
276+
test('moves an open popup to the corner when its full-display bar is removed', () => {
277+
const f = fixture();
278+
f.panel.frameGeometry = { x: 0, y: 0, width: 1920, height: 1080 };
279+
f.add();
280+
f.workspace.windowRemoved.emit(f.panel);
281+
assert.deepEqual([f.window.frameGeometry.x, f.window.frameGeometry.y], [1412, 8]);
282+
});
283+
284+
test('drops a full-display bar anchor once its dock shrinks to nothing', () => {
285+
const f = fixture();
286+
f.panel.frameGeometry = { x: 0, y: 0, width: 1920, height: 1080 };
287+
f.add();
288+
f.panel.frameGeometry = { x: 0, y: 0, width: 0, height: 0 };
289+
f.window.hidden = true;
290+
f.workspace.cursorPos = { x: 300, y: 300 };
291+
f.window.hidden = false;
292+
f.window.frameGeometry = { x: 710, y: 340, width: 500, height: 400 };
293+
f.window.windowShown.emit();
294+
assert.deepEqual([f.window.frameGeometry.x, f.window.frameGeometry.y], [1412, 8]);
295+
});
296+
297+
test('keeps a full-display bar anchor while another full-display dock remains', () => {
298+
const f = fixture();
299+
f.panel.frameGeometry = { x: 0, y: 0, width: 1920, height: 1080 };
300+
const other = { dock: true, resourceClass: 'quickshell', output: f.output, frameGeometry: { x: 0, y: 0, width: 1920, height: 1080 } };
301+
f.allWindows.push(other);
302+
f.add();
303+
f.workspace.windowRemoved.emit(other);
304+
f.window.frameGeometry = { ...f.window.frameGeometry, height: 500 };
305+
assert.deepEqual([f.window.frameGeometry.x, f.window.frameGeometry.y], [1250, 532]);
306+
});
307+
308+
test('moves an open popup to the corner when its shrunken full-display bar is removed', () => {
309+
const f = fixture();
310+
f.panel.frameGeometry = { x: 0, y: 0, width: 1920, height: 1080 };
311+
f.add();
312+
f.panel.frameGeometry = { x: 0, y: 0, width: 0, height: 0 };
313+
f.workspace.windowRemoved.emit(f.panel);
314+
assert.deepEqual([f.window.frameGeometry.x, f.window.frameGeometry.y], [1412, 8]);
315+
});

0 commit comments

Comments
 (0)