1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
|
// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
//
// 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)
}
}
}
}
}
}
}
}
|