Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ A KWin script that places Gitify beside the panel on native Wayland.

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.

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.
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.

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.

Expand Down
125 changes: 118 additions & 7 deletions gitify/contents/code/main.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
const GAP = 8;
// How far from a display edge a tray click can be when no panel is found there.
const EDGE_REACH = 64;
const windows = new Map();
let lastAnchor = null;

Expand Down Expand Up @@ -64,13 +66,92 @@
};
}

// Shells such as Caelestia draw their bar inside one dock surface that covers
// the whole display, which gives no panel edge to anchor to.
function isFullDisplayDock(window) {
const rect = window.frameGeometry;
const screen = window.output && window.output.geometry;

Check warning on line 73 in gitify/contents/code/main.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=gitify-app_kde&issues=AaGZ3MjNS7pM3HqF_W7K&open=AaGZ3MjNS7pM3HqF_W7K&pullRequest=27
return Boolean(
window.dock && screen && rect.width > screen.width / 2 && rect.height > screen.height / 2,
);
}

function isPanel(window) {
const rect = window.frameGeometry;
return window.dock && rect.width > 0 && rect.height > 0 && !isFullDisplayDock(window);
}

function panelWindows() {
return workspace.windowList().filter((window) =>
window.dock && String(window.resourceClass) === 'plasmashell' &&
window.frameGeometry.width > 0 && window.frameGeometry.height > 0,
return workspace.windowList().filter(isPanel);
}

function unmeasuredDockAt(point) {
return workspace.windowList().some((window) =>
isFullDisplayDock(window) && contains(window.frameGeometry, point),
);
}

// Edge anchors only stand in for a bar KWin cannot measure, so they lapse
// once that output gains a measurable panel or loses its full-display dock.
function edgeAnchorUsable(output, panels, removed) {
return workspace.screens.includes(output) &&
!panels.some((panel) => panel.output === output && panel !== removed) &&
workspace.windowList().some((window) =>
window !== removed && isFullDisplayDock(window) && window.output === output);
}

// A bar beside a display edge that KWin cannot measure, rebuilt from the
// display's current geometry so it follows resolution and scale changes.
// `along` is the click's position along the edge and `distance` its distance
// from the edge.
function edgeAnchor(output, edge, along, distance) {
const screen = output.geometry;
const horizontal = edge === 'top' || edge === 'bottom';
const start = horizontal ? screen.x : screen.y;
const length = horizontal ? screen.width : screen.height;
if (along < start || along >= start + length) {
return null;
}
const thickness = Math.max(1, Math.round(distance * 2));
const frameGeometry = {
top: { x: screen.x, y: screen.y, width: screen.width, height: thickness },
bottom: { x: screen.x, y: screen.y + screen.height - thickness, width: screen.width, height: thickness },
left: { x: screen.x, y: screen.y, width: thickness, height: screen.height },
right: { x: screen.x + screen.width - thickness, y: screen.y, width: thickness, height: screen.height },
}[edge];
const point = {
top: { x: along, y: screen.y + distance },
bottom: { x: along, y: screen.y + screen.height - distance },
left: { x: screen.x + distance, y: along },
right: { x: screen.x + screen.width - distance, y: along },
}[edge];
return { panel: { frameGeometry, output }, edge, point, pointer: { output, edge, along, distance } };
}

// A tray click on a display with no measurable panel, such as a bar drawn
// inside a full-display surface. The pointer sits on the icon, so the bar is
// assumed to span from the display edge to as far past the pointer as the
// edge is before it.
function pointerAnchor(cursor, panels) {
const output = workspace.screenAt(cursor);
if (!output || panels.some((panel) => panel.output === output) || !unmeasuredDockAt(cursor)) {
return null;
}
const screen = output.geometry;
const distances = {
top: cursor.y - screen.y,
bottom: screen.y + screen.height - cursor.y,
left: cursor.x - screen.x,
right: screen.x + screen.width - cursor.x,
};
const edge = Object.keys(distances).reduce((a, b) => (distances[b] < distances[a] ? b : a));

Check warning on line 147 in gitify/contents/code/main.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add an initial value to this "reduce()" call.

See more on https://sonarcloud.io/project/issues?id=gitify-app_kde&issues=AaGZ3MjNS7pM3HqF_W7L&open=AaGZ3MjNS7pM3HqF_W7L&pullRequest=27
if (distances[edge] > EDGE_REACH) {
return null;
}
const along = edge === 'top' || edge === 'bottom' ? cursor.x : cursor.y;
return edgeAnchor(output, edge, along, distances[edge]);
}

function selectAnchor(window) {
const panels = panelWindows();
const cursor = { x: workspace.cursorPos.x, y: workspace.cursorPos.y };
Expand All @@ -84,6 +165,20 @@
return lastAnchor;
}

const clickedEdge = pointerAnchor(cursor, panels);
if (clickedEdge) {
lastAnchor = clickedEdge;
return lastAnchor;
}

if (lastAnchor && lastAnchor.pointer) {

Check warning on line 174 in gitify/contents/code/main.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=gitify-app_kde&issues=AaGZ3MjNS7pM3HqF_W7M&open=AaGZ3MjNS7pM3HqF_W7M&pullRequest=27
const { output, edge, along, distance } = lastAnchor.pointer;
lastAnchor = edgeAnchorUsable(output, panels) ? edgeAnchor(output, edge, along, distance) : null;
if (lastAnchor) {
return lastAnchor;
}
}

if (lastAnchor && panels.includes(lastAnchor.panel)) {
const panel = lastAnchor.panel;
const edge = edgeOf(panel.frameGeometry, panel.output.geometry);
Expand All @@ -94,7 +189,8 @@
lastAnchor = null;

const output = workspace.screenAt(cursor) || window.output;
const panel = panels.find((candidate) => candidate.output === output);
const onOutput = panels.filter((candidate) => candidate.output === output);
const panel = onOutput.find((candidate) => String(candidate.resourceClass) === 'plasmashell') || onOutput[0];
if (!panel) {
return null;
}
Expand Down Expand Up @@ -135,6 +231,9 @@
}
const entry = { window, anchor: null, moving: false };
windows.set(window, entry);
// Electron cannot keep a window above others on Wayland, so a popup opened
// from the tray would otherwise open behind the window that has focus.
window.keepAbove = true;
const onShow = () => {
entry.anchor = selectAnchor(window);
place(entry);
Expand All @@ -157,13 +256,25 @@
}

workspace.windowAdded.connect(track);
// KWin may still list a window while announcing its removal, so it is
// excluded explicitly when checking whether an edge anchor still has a dock.
function anchoredTo(anchor, window) {
if (!anchor) {
return false;
}
if (anchor.pointer) {
return Boolean(window.dock) && !edgeAnchorUsable(anchor.pointer.output, panelWindows(), window);
}
return anchor.panel === window;
}

workspace.windowRemoved.connect((window) => {
if (lastAnchor && lastAnchor.panel === window) {
if (anchoredTo(lastAnchor, window)) {
lastAnchor = null;
}
for (const entry of windows.values()) {
if (entry.anchor && entry.anchor.panel === window) {
entry.anchor = selectAnchor(entry.window);
if (anchoredTo(entry.anchor, window)) {
entry.anchor = entry.anchor.pointer ? null : selectAnchor(entry.window);
place(entry);
}
}
Expand Down
157 changes: 157 additions & 0 deletions tests/positioning.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,160 @@ test('registers each window once and releases it on close', () => {
f.window.closed.emit();
assert.equal(f.windows.size, 0);
});

test('anchors a tray click in a bar drawn inside a full-display dock surface', () => {
const f = fixture();
f.panel.resourceClass = 'quickshell';
f.panel.frameGeometry = { x: 0, y: 0, width: 1919, height: 1080 };
f.add();
assert.deepEqual([f.window.frameGeometry.x, f.window.frameGeometry.y], [1250, 632]);
});

test('opens toward the display interior from a full-display bar on any edge', () => {
for (const [cursor, expected] of [
[{ x: 1000, y: 15 }, [750, 38]],
[{ x: 15, y: 500 }, [38, 300]],
[{ x: 1905, y: 500 }, [1382, 300]],
]) {
const f = fixture();
f.panel.frameGeometry = { x: 0, y: 0, width: 1920, height: 1080 };
f.workspace.cursorPos = cursor;
f.add();
assert.deepEqual([f.window.frameGeometry.x, f.window.frameGeometry.y], expected);
}
});

test('remembers a full-display bar anchor across a keyboard reopen', () => {
const f = fixture();
f.panel.frameGeometry = { x: 0, y: 0, width: 1920, height: 1080 };
f.add();
f.window.hidden = true;
f.workspace.cursorPos = { x: 300, y: 300 };
f.window.hidden = false;
f.window.frameGeometry = { x: 710, y: 340, width: 500, height: 400 };
f.window.windowShown.emit();
assert.deepEqual([f.window.frameGeometry.x, f.window.frameGeometry.y], [1250, 632]);
});

test('uses the corner when a full-display bar is opened away from the display edge', () => {
const f = fixture();
f.panel.frameGeometry = { x: 0, y: 0, width: 1920, height: 1080 };
f.workspace.cursorPos = { x: 900, y: 400 };
f.add();
assert.deepEqual([f.window.frameGeometry.x, f.window.frameGeometry.y], [1412, 8]);
});

test('anchors to docks from other shells', () => {
const f = fixture();
f.panel.resourceClass = 'waybar';
f.panel.frameGeometry = { x: 0, y: 0, width: 1920, height: 30 };
f.workspace.cursorPos = { x: 1000, y: 15 };
f.add();
assert.deepEqual([f.window.frameGeometry.x, f.window.frameGeometry.y], [750, 38]);
});

test('keeps the panel anchor when a keyboard open happens near another display edge', () => {
const f = fixture();
f.add();
f.window.hidden = true;
f.workspace.cursorPos = { x: 300, y: 10 };
f.window.hidden = false;
f.window.frameGeometry = { x: 710, y: 340, width: 500, height: 400 };
f.window.windowShown.emit();
assert.deepEqual([f.window.frameGeometry.x, f.window.frameGeometry.y], [1250, 632]);
});

test('keeps Gitify above other windows', () => {
const f = fixture();
f.add();
assert.equal(f.window.keepAbove, true);
});

test('rebuilds a full-display bar anchor when the display changes size', () => {
const f = fixture();
f.panel.frameGeometry = { x: 0, y: 0, width: 1920, height: 1080 };
f.add();
f.output.geometry = { x: 0, y: 0, width: 2560, height: 1440 };
f.window.hidden = true;
f.workspace.cursorPos = { x: 300, y: 300 };
f.window.hidden = false;
f.window.frameGeometry = { x: 710, y: 340, width: 500, height: 400 };
f.window.windowShown.emit();
assert.deepEqual([f.window.frameGeometry.x, f.window.frameGeometry.y], [1250, 992]);
});

test('prefers the Plasma panel over other docks before the first tray click', () => {
const f = fixture();
const dock = { dock: true, resourceClass: 'latte-dock', output: f.output, frameGeometry: { x: 0, y: 0, width: 48, height: 1080 } };
f.allWindows.unshift(dock);
f.workspace.cursorPos = { x: 900, y: 400 };
f.add();
assert.deepEqual([f.window.frameGeometry.x, f.window.frameGeometry.y], [1412, 632]);
});

test('drops a full-display bar anchor once the display gains a measurable panel', () => {
const f = fixture();
f.panel.frameGeometry = { x: 0, y: 0, width: 1920, height: 1080 };
f.add();
f.panel.frameGeometry = { x: 0, y: 0, width: 1920, height: 30 };
f.window.hidden = true;
f.workspace.cursorPos = { x: 300, y: 300 };
f.window.hidden = false;
f.window.frameGeometry = { x: 710, y: 340, width: 500, height: 400 };
f.window.windowShown.emit();
assert.deepEqual([f.window.frameGeometry.x, f.window.frameGeometry.y], [1412, 38]);
});

test('drops a full-display bar anchor once its dock is gone', () => {
const f = fixture();
f.panel.frameGeometry = { x: 0, y: 0, width: 1920, height: 1080 };
f.add();
f.allWindows.splice(f.allWindows.indexOf(f.panel), 1);
f.window.hidden = true;
f.workspace.cursorPos = { x: 300, y: 300 };
f.window.hidden = false;
f.window.frameGeometry = { x: 710, y: 340, width: 500, height: 400 };
f.window.windowShown.emit();
assert.deepEqual([f.window.frameGeometry.x, f.window.frameGeometry.y], [1412, 8]);
});

test('moves an open popup to the corner when its full-display bar is removed', () => {
const f = fixture();
f.panel.frameGeometry = { x: 0, y: 0, width: 1920, height: 1080 };
f.add();
f.workspace.windowRemoved.emit(f.panel);
assert.deepEqual([f.window.frameGeometry.x, f.window.frameGeometry.y], [1412, 8]);
});

test('drops a full-display bar anchor once its dock shrinks to nothing', () => {
const f = fixture();
f.panel.frameGeometry = { x: 0, y: 0, width: 1920, height: 1080 };
f.add();
f.panel.frameGeometry = { x: 0, y: 0, width: 0, height: 0 };
f.window.hidden = true;
f.workspace.cursorPos = { x: 300, y: 300 };
f.window.hidden = false;
f.window.frameGeometry = { x: 710, y: 340, width: 500, height: 400 };
f.window.windowShown.emit();
assert.deepEqual([f.window.frameGeometry.x, f.window.frameGeometry.y], [1412, 8]);
});

test('keeps a full-display bar anchor while another full-display dock remains', () => {
const f = fixture();
f.panel.frameGeometry = { x: 0, y: 0, width: 1920, height: 1080 };
const other = { dock: true, resourceClass: 'quickshell', output: f.output, frameGeometry: { x: 0, y: 0, width: 1920, height: 1080 } };
f.allWindows.push(other);
f.add();
f.workspace.windowRemoved.emit(other);
f.window.frameGeometry = { ...f.window.frameGeometry, height: 500 };
assert.deepEqual([f.window.frameGeometry.x, f.window.frameGeometry.y], [1250, 532]);
});

test('moves an open popup to the corner when its shrunken full-display bar is removed', () => {
const f = fixture();
f.panel.frameGeometry = { x: 0, y: 0, width: 1920, height: 1080 };
f.add();
f.panel.frameGeometry = { x: 0, y: 0, width: 0, height: 0 };
f.workspace.windowRemoved.emit(f.panel);
assert.deepEqual([f.window.frameGeometry.x, f.window.frameGeometry.y], [1412, 8]);
});
Loading