// 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 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: "quickshell-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 { id: keyHandler anchors.fill: parent focus: true // The LazyLoader destroys the overlay when it closes and // builds a new one on the next opening, verified by a build // counter: two openings, two constructions. So this starts at // 0 every time on its own, and there is nothing to reset. A // Component.onCompleted assigning 0 here would be a no-op. property int selectedIndex: 0 function commit() { const list = Windows.windows; if (selectedIndex >= 0 && selectedIndex < list.length) root.focusAfterClose(list[selectedIndex]); } function move(delta) { const n = Windows.windows.length; if (n === 0) return; selectedIndex = (selectedIndex + delta + n) % n; } Keys.onEscapePressed: root.close() Keys.onReturnPressed: commit() Keys.onEnterPressed: commit() Keys.onLeftPressed: move(-1) Keys.onRightPressed: move(1) Keys.onUpPressed: move(-grid.columns) Keys.onDownPressed: move(grid.columns) Keys.onTabPressed: move(1) Keys.onBacktabPressed: move(-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 } } Grid { id: grid anchors.centerIn: parent visible: Windows.windows.length > 0 readonly property int count: Windows.windows.length readonly property int gap: 30 readonly property int maxCardWidth: 640 // Columns are the window count capped at six, so one // card sits dead centre and two straddle the middle. columns: Math.max(1, Math.min(count, 6)) // Cards grow to fill their row, up to a ceiling. // Without the ceiling a lone preview becomes a // full-screen mirror of the window it stands for. readonly property int rows: Math.ceil(count / columns) // The three text lines and the spacing above them, // which the box height has to leave room for. readonly property int textHeight: 54 // Clamped by the height as well as the width. Without // the second term a busy desktop pushes rows off the // top and bottom, and a centred Grid has no way to // scroll to them, so the cards shrink instead. readonly property int cardWidth: Math.min( maxCardWidth, Math.floor((parent.width * 0.92 - (columns - 1) * gap) / columns), Math.floor(((parent.height * 0.92 - (rows - 1) * gap) / rows - textHeight) * 16 / 10)) spacing: gap Repeater { model: Windows.windows WindowCard { required property int index required property var modelData toplevel: modelData cardWidth: grid.cardWidth selected: index === keyHandler.selectedIndex onActivated: { keyHandler.selectedIndex = index; keyHandler.commit(); } // 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) } } } } } } } }