// Copyright (C) 2026 Danilo M. // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License version 2 as // published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. import Quickshell import Quickshell.Wayland import QtQuick import "Expose.js" as Expose Scope { id: root property bool open: false property string monitor: "DP-1" readonly property var screenObj: Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0] function toggle() { root.open = !root.open; } function close() { root.open = false; } // Hyprland.toplevels reads empty until it is asked, and a window opened // since the last showing would otherwise be missing from the grid. onOpenChanged: if (open) Windows.refresh(); // A focus dispatched while the overlay is up is accepted and then ignored: // the layer surface holds keyboard focus exclusively, and the compositor // will not move window focus out from under that grab. Closing in the same // turn does not help either, because close() only clears a property and the // surface survives until the frame after. So the target is remembered and // focused once the grab is really gone. property var pendingFocus: null function focusAfterClose(toplevel) { root.pendingFocus = toplevel; root.close(); focusTimer.restart(); } Timer { id: focusTimer interval: 60 onTriggered: { if (root.pendingFocus) Windows.focus(root.pendingFocus); root.pendingFocus = null; } } // A config with no visible window exits, reporting nothing. This 1x1 // click-through window is what holds the shell open while the overlay // is hidden, which is almost always. PanelWindow { anchors { top: true; left: true } implicitWidth: 1 implicitHeight: 1 color: "transparent" exclusionMode: ExclusionMode.Ignore mask: Region {} WlrLayershell.keyboardFocus: WlrKeyboardFocus.None } LazyLoader { active: root.open PanelWindow { id: win screen: root.screenObj anchors { top: true; left: true; right: true; bottom: true } color: "transparent" exclusionMode: ExclusionMode.Ignore WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.namespace: "keystone-window-switcher" WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive // Keys reach a focused item, not a window: Keys.onEscapePressed on // the PanelWindow above never fires. This item is what listens. Item { anchors.fill: parent focus: true Keys.onEscapePressed: root.close() Keys.onReturnPressed: if (scatter.selected) root.focusAfterClose(scatter.selected) Keys.onEnterPressed: if (scatter.selected) root.focusAfterClose(scatter.selected) Keys.onLeftPressed: scatter.move(-1, 0) Keys.onRightPressed: scatter.move(1, 0) Keys.onUpPressed: scatter.move(0, -1) Keys.onDownPressed: scatter.move(0, 1) Rectangle { anchors.fill: parent color: Qt.rgba(Theme.base.r, Theme.base.g, Theme.base.b, 0.82) MouseArea { anchors.fill: parent onClicked: root.close() } // Nothing to switch to. A full-screen dim with nothing in // it reads as a hang, so it says so instead. Column { anchors.centerIn: parent spacing: 14 visible: Windows.windows.length === 0 Text { anchors.horizontalCenter: parent.horizontalCenter text: "Nothing here but hopes and dreams..." color: Theme.text font.family: Theme.fontFamily font.pixelSize: 26 } Text { anchors.horizontalCenter: parent.horizontalCenter text: "go create something beautiful!" color: Theme.overlay font.family: Theme.fontFamily font.pixelSize: 32 } } // The scatter. Recomputed whenever the window list or a // window's size changes, so closing one moves the rest. Item { id: scatter anchors.fill: parent // Real sizes come from the raw hyprctl object. A window // that has none yet stands in at 1280x800 until it does. // The bottom 28px are kept free for the lowest card's // app name, which hangs below its preview. readonly property var rects: Expose.layout( Windows.windows.map(t => { const o = t.lastIpcObject; const s = o && o.size; return s ? { w: s[0], h: s[1] } : { w: 1280, h: 800 }; }), width, height - 28) // ponytail: a fade, not a flight from each window's real // position; add the flight if the fade feels abrupt. opacity: 0 Component.onCompleted: opacity = 1 Behavior on opacity { NumberAnimation { duration: 150 } } // The keyboard selection, held as the window rather // than an index, so a close that shifts the list // does not move it onto a neighbour. Hovering a card // selects it too, so pointer and keys never show two. property var selected: null readonly property int selectedIndex: Windows.windows.indexOf(selected) function move(dx, dy) { const i = Expose.step(rects, selectedIndex, dx, dy); if (i >= 0) selected = Windows.windows[i]; } // Cards glide to their new rects when the scatter // changes, but not while it fades in, or every card // would fly in from the corner on open. property bool settled: false onOpacityChanged: if (opacity === 1) settled = true // A plain array model would rebuild every card on any // change, leaving nothing to animate. ScriptModel diffs // by identity, so a closed window removes one delegate // and the rest keep theirs. Repeater { model: ScriptModel { values: Windows.windows } WindowCard { required property int index required property var modelData readonly property var rect: scatter.rects[index] ?? { x: 0, y: 0, w: 0, h: 0 } toplevel: modelData x: rect.x y: rect.y width: rect.w height: rect.h Behavior on x { enabled: scatter.settled; NumberAnimation { duration: 250; easing.type: Easing.OutCubic } } Behavior on y { enabled: scatter.settled; NumberAnimation { duration: 250; easing.type: Easing.OutCubic } } Behavior on width { enabled: scatter.settled; NumberAnimation { duration: 250; easing.type: Easing.OutCubic } } Behavior on height { enabled: scatter.settled; NumberAnimation { duration: 250; easing.type: Easing.OutCubic } } selected: scatter.selected === modelData onHoveredChanged: if (hovered) scatter.selected = modelData onActivated: root.focusAfterClose(modelData) // Closing is a side errand, so the overlay // stays open: a dismissal would mean reopening // to close a second window. The card goes when // Hyprland says it went. onCloseRequested: Windows.closeWindow(modelData) } } } } } } } }