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
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
|
// 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.Io
import Quickshell.Wayland
import QtQuick
Scope {
id: root
// The modules the drawer hosts, in grid order. Set from shell.qml.
property list<QtObject> modules
// Waybar runs on this screen and the launcher sits at its left end, so
// the drawer belongs here. Falls back to the first screen when this
// monitor is not connected, so the drawer is never invisible.
property string monitor: "DP-1"
property bool open: false
// Which module's page is showing. Empty means the grid.
property string page: ""
// Set by NotificationList's History button and read by the history view
// loader below; reset when the drawer closes.
property bool history: false
// The balloons read this to stand down while the drawer holds the space.
FileView {
id: drawerFlag
path: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/notifyd/drawer"
atomicWrites: true
printErrors: false
}
onOpenChanged: drawerFlag.setText(root.open ? "1\n" : "0\n")
// onOpenChanged does not fire for the initial value, so a restart while
// the drawer was open would leave the flag stale and suppress every
// balloon. Publish the starting state explicitly.
Component.onCompleted: drawerFlag.setText(root.open ? "1\n" : "0\n")
readonly property var screenObj:
Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0]
// A plain `list<QtObject>` is indexed directly; it is not an
// ObjectModel, so there is no `.values` to go through.
readonly property QtObject current: {
for (let i = 0; i < root.modules.length; i++)
if (root.modules[i].name === root.page) return root.modules[i];
return null;
}
function show(name) {
// Deep-linking to a page must leave the history view, or both loaders
// stay active and the later-declared history loader paints on top.
root.history = false;
root.page = root.modules.some(m => m.name === name) ? name : "";
root.open = true;
}
function close() {
root.open = false;
// Reset to the grid: a panel that reopens somewhere unexpected is
// worse than one extra click.
root.page = "";
root.history = false;
}
function toggle(name) {
if (root.open && (name ?? "") === root.page) root.close();
else root.show(name);
}
// Clicking a tile: a module with a page opens it, one without acts.
function activate(mod) {
if (mod.page) root.page = mod.name;
else { mod.activate(); root.close(); }
}
LazyLoader {
active: root.open
PanelWindow {
id: win
screen: root.screenObj
anchors { top: true; left: true; right: true; bottom: true }
color: "transparent"
// Normal, not Ignore: waybar claims an exclusive zone at the top
// of this screen, so respecting it puts the drawer below the bar
// without this file knowing the bar's height. The drawer is
// reached from the bar, so the bar must stay visible and
// clickable while it is open.
exclusionMode: ExclusionMode.Normal
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "quickshell-desktop"
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
// The click-outside catcher. It covers the whole surface, and the
// drawer sits on top of it swallowing its own clicks.
MouseArea {
anchors.fill: parent
onClicked: root.close()
}
// Keys reach a focused item, never the window: setting
// keyboardFocus above is necessary but not sufficient, and
// Keys.onEscapePressed on a PanelWindow never fires. See AGENTS.md.
Rectangle {
id: panel
anchors { top: parent.top; left: parent.left; bottom: parent.bottom }
width: 600
color: Qt.alpha(Theme.base, 0.72)
topRightRadius: 14
bottomRightRadius: 14
border.width: 1
border.color: Qt.alpha(Theme.text, 0.12)
// The panel is that focused item. As the ancestor of the page
// content, a key pressed in any focused descendant bubbles up
// to it.
focus: true
Keys.onEscapePressed: {
if (root.history) root.history = false;
else if (root.page) root.page = "";
else root.close();
}
// Clicks on the panel must not reach the catcher behind it.
MouseArea { anchors.fill: parent }
// --- grid view ---
Item {
anchors.fill: parent
anchors.margins: 16
visible: root.page === "" && !root.history
// The notification engine's space. The live queue lists
// here while the drawer is open; the balloons stand down
// because the drawer flag above is set.
NotificationList {
id: notifications
anchors { top: parent.top; left: parent.left; right: parent.right }
anchors.bottom: grid.top
anchors.bottomMargin: 16
onHistory: root.history = true
}
// Fixed, never scrolled. Three columns at 600px with a
// 180px minimum; tiles wrap and add rows, ceiling 3x3.
Flow {
id: grid
anchors { left: parent.left; right: parent.right; bottom: parent.bottom }
spacing: 10
Repeater {
model: root.modules
Tile {
required property QtObject modelData
// Three columns, or fewer if the panel is
// narrower than three 180px tiles allow.
width: (grid.width - 2 * grid.spacing) / 3
icon: modelData.icon
label: modelData.label
active: modelData.active
content: modelData.tileContent
onClicked: root.activate(modelData)
}
}
}
}
// --- page view ---
Loader {
id: pageLoader
anchors.fill: parent
anchors.margins: 16
active: root.current !== null
sourceComponent: root.current?.page ?? null
// The Page's back arrow returns to the grid. Without this
// the header button is inert, since the page only emits
// `back` and does not know who hosts it.
onLoaded: if (item && item.back) item.back.connect(() => root.page = "")
// The page header shows the module's tile glyph. A Binding,
// not an assignment in onLoaded, so a module whose icon
// changes (network follows the active link) stays in step.
Binding {
target: pageLoader.item
property: "icon"
value: root.current ? root.current.icon : ""
when: pageLoader.item !== null
}
// The page enters from the right: the one piece of motion
// in the design, and what makes the drawer read as one
// surface rather than a window swapping contents. It is a
// transform, not `x`: the anchors above pin x, and an
// anchored x is silently ignored.
opacity: active ? 1 : 0
transform: Translate {
x: pageLoader.active ? 0 : 44
Behavior on x { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } }
}
Behavior on opacity { NumberAnimation { duration: 160 } }
}
// --- history view ---
Loader {
id: historyLoader
anchors.fill: parent
anchors.margins: 16
active: root.history
sourceComponent: Component {
Page {
title: "History"
NotificationHistory { width: parent.width }
}
}
onLoaded: if (item && item.back) item.back.connect(() => root.history = false)
}
}
}
}
}
|