From 601245d71df68aecbfd2526fd5f68423fc0fc155 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Mon, 2 Nov 2026 01:53:26 +0100 Subject: [PATCH] feat: anchor to bars from other shells and keep Gitify above other windows --- README.md | 2 +- gitify/contents/code/main.js | 125 ++++++++++++++++++++++++++-- tests/positioning.test.js | 157 +++++++++++++++++++++++++++++++++++ 3 files changed, 276 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 4420806..001e293 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/gitify/contents/code/main.js b/gitify/contents/code/main.js index d359dbe..0669388 100644 --- a/gitify/contents/code/main.js +++ b/gitify/contents/code/main.js @@ -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; @@ -64,13 +66,92 @@ function placement(size, area, anchor) { }; } +// 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; + 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)); + 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 }; @@ -84,6 +165,20 @@ function selectAnchor(window) { return lastAnchor; } + const clickedEdge = pointerAnchor(cursor, panels); + if (clickedEdge) { + lastAnchor = clickedEdge; + return lastAnchor; + } + + if (lastAnchor && lastAnchor.pointer) { + 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); @@ -94,7 +189,8 @@ function selectAnchor(window) { 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; } @@ -135,6 +231,9 @@ function track(window) { } 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); @@ -157,13 +256,25 @@ function track(window) { } 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); } } diff --git a/tests/positioning.test.js b/tests/positioning.test.js index 7f4785b..128faa8 100644 --- a/tests/positioning.test.js +++ b/tests/positioning.test.js @@ -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]); +});