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
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
|
// 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
import "modules/user"
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;
}
// One step out: history, then a page (to its module's `up`, the grid by
// default), then the drawer itself. Escape, the mouse's back button and
// a page's back arrow.
function back() {
if (root.history) root.history = false;
else if (root.page) root.page = root.current?.up ?? "";
else root.close();
}
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, so a bar with an exclusive zone, if one ever returns,
// pushes the drawer below it without this file knowing its
// height. Today nothing reserves space on DP-1 (the island
// floats, waybar is gone) and the drawer starts at the top edge.
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()
}
// The mouse's back button, anywhere on the surface. On top of
// everything and taking only that button, so left clicks pass
// through it: a back press left to fall through from below never
// arrived, because something on the page took it first.
MouseArea {
anchors.fill: parent
z: 1
acceptedButtons: Qt.BackButton
onClicked: root.back()
}
// 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: root.back()
// 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
UserRow {
id: user
anchors { top: parent.top; left: parent.left; right: parent.right }
onClicked: root.show("user")
}
// 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: user.bottom; topMargin: 12; left: parent.left; right: parent.right }
anchors.bottom: tiles.top
anchors.bottomMargin: 16
onHistory: root.history = true
}
// At most three rows tall; more tiles scroll inside
// this box, so the notification space above never
// shrinks when a module is added.
Flickable {
id: tiles
anchors { left: parent.left; right: parent.right; bottom: parent.bottom }
readonly property int maxRows: 3
height: Math.min(grid.height, maxRows * 96 + (maxRows - 1) * grid.spacing)
contentHeight: grid.height
clip: true
boundsBehavior: Flickable.StopAtBounds
Flow {
id: grid
width: tiles.width
spacing: 10
Repeater {
model: root.modules.filter(m => m.tiled)
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)
}
}
}
}
// A thin bar in the panel margin, there only when a row
// is hidden, so the fourth row is discoverable. Outside
// the Flickable, whose clip would hide it.
Rectangle {
visible: tiles.contentHeight > tiles.height
anchors { left: tiles.right; leftMargin: 6 }
y: tiles.y + tiles.visibleArea.yPosition * tiles.height
width: 3
height: tiles.visibleArea.heightRatio * tiles.height
radius: 1.5
color: Qt.alpha(Theme.accent, 0.6)
}
}
// --- 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 steps out as Escape does. 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.back);
if (item && item.dismiss) item.dismiss.connect(root.close);
if (item && item.go) item.go.connect(root.show);
}
// 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)
}
}
}
}
}
|