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
|
# Desktop shell: drawer and module contract
A single quickshell component, `desktop/`, presenting one left-side drawer that
hosts several modules. It absorbs three of the five existing components and
leaves two alone.
This is the first of several projects. Notifications, do not disturb,
breaktimer, wifi, bluetooth and kdeconnect are named here only where they
constrain the design; none of them is built now.
## Why
Five components, five processes, five autostart lines, five blur rules, and
functionality scattered across surfaces that have nothing in common. Mail lives
behind a waybar click, VMs behind `SUPER+v`, appearance behind `SUPER+Return`,
and nothing is discoverable without remembering it exists.
The goal is one place, reached one way, where the things a desktop lets you
adjust are visible together. The model is the Windows 11 quick settings panel:
click the bar, a panel appears, everything is in it.
## Scope
Merged into the new shell:
- `volume-osd` becomes the `sound` module; its OSD surface survives unchanged.
- `mail-overview` becomes the `mail` module; its drawer becomes a page.
- `vm-manager` becomes the `vm` module; its drawer becomes a page.
Left alone, still separate processes:
- `window-switcher`, whose ALT+TAB fullscreen grid has no place in a drawer.
- `appearance`, whose wallpaper picker needs more room than a 600px panel; it
gains a tile in the drawer that fires its existing IPC.
Process count goes from five to three.
Not in this project: the notification engine, do not disturb, breaktimer, wifi,
bluetooth, kdeconnect, and any absorption of waybar's own duties.
## Layout
One `PanelWindow` on `DP-1`, left-anchored, `ExclusionMode.Normal` so it starts
below waybar and runs to the bottom of the screen. 600px wide. Namespace
`quickshell-desktop`, with a matching `blur-desktop` layer rule.
Left rather than right because conky occupies the right side of `DP-1`. Below
the bar rather than over it because the drawer is reached from the bar, which
should stay visible and clickable while it is open.
Top to bottom:
**Notification area.** Takes all remaining vertical space. Empty in this
project, reserved for the notification engine. It is an empty `Item` that
claims the space, not a placeholder graphic: the grid must sit at the bottom
exactly where it will sit once notifications arrive, or the layout is tuned
against a position that does not survive. Scrollable once it has content, since
a notification list is the one part of this design with unbounded content.
**Tile grid.** Fixed, never scrolled. A `Flow` with a minimum tile width of
about 180px, which yields three columns at 600px. Tiles wrap and add rows. The
ceiling is 3x3, nine tiles, which is the module count currently foreseen.
The notification area is intended to keep at least 70% of the drawer height.
This holds until the grid exceeds two rows; a full 3x3 grid takes roughly 46%
of a 1038px drawer and the notification area keeps the rest. The 70% figure is
a target that yields to a full grid, not a hard constraint, and tiles can be
reshaped later if more modules arrive.
**Pages.** Opening a page replaces the whole drawer content, grid included, so
a page gets full height. Mail and VM both need it. A header row carries a back
arrow and the page title. One page at a time, no nesting. The page enters from
the right on a slide; this is the only motion in the design, and it is what
makes the drawer read as one surface rather than a window swapping contents.
The drawer resets to the grid when closed. Reopening lands on the grid unless
the trigger deep-links to a page.
Dismissal is Escape or a click outside. Escape requires
`WlrLayershell.keyboardFocus` and a focused item to receive the key, since
`Keys.onEscapePressed` on a `PanelWindow` never fires. Click-outside needs a
click-catching region outside the drawer, which at `ExclusionMode.Normal`
covers only part of the screen; whether that is a second transparent window or
an enlarged mask is an implementation detail.
The 1x1 transparent keepalive `PanelWindow` with `mask: Region {}` is required,
as in every component here. The OSD does not substitute for it, being itself
shown lazily.
## Module contract
```qml
// modules/<name>/Module.qml
Module {
name: "vm"
icon: "..."
alwaysActive: false
tile: Component { ... }
page: Component { ... }
function activate() { ... }
}
```
A module provides a tile, a page, both, or neither. A tile with no page calls
`activate()` when clicked. A module with neither is a pure background service.
A module may also own windows outside the drawer entirely, as `sound` does with
its OSD.
The registry is a list in `shell.qml`. `Module.qml` is a thin base type whose
value is being the one file to read to learn what a module is; it does not
enforce, and nothing stops a module doing something unusual.
### alwaysActive
Whether a module's background service runs while the drawer is closed. The
drawer is closed most of the time, and three modules have background work, so
this is the property that decides whether the shell is cheap to run all
session.
- `sound`: true. The OSD must react to a volume keypress with no drawer open.
- `mail`: true. Account state and the unread count outlive the drawer.
- `vm`: false. Polling virsh exists only to paint a page nobody is looking at.
Pages are lazily loaded regardless. `alwaysActive` governs the service only.
## Modules
### sound (alwaysActive: true)
`VolumeOsd.qml` currently does three jobs in 302 lines, and splits into:
- `Service.qml`, the PipeWire bindings and `PwObjectTracker`. Nodes report
their initial volume before `ready` goes true, so the `ready` check stands
alone with no extra guard on top of it. `PwObjectTracker` is mandatory, or
node properties go stale.
- `Osd.qml`, today's transient OSD, unchanged, keeping its own window,
its `quickshell-volume-osd` namespace and its existing blur rule.
- `Tile.qml`, output volume, mute state, current player at a glance.
- `Page.qml`, output and input device lists with per-device volume, plus the
MPRIS player and transport. playerctld publishes a duplicate of every player
under its own bus name, so filtering stays by `dbusName`.
### mail (alwaysActive: true)
`MailPanel.qml`'s body becomes `Page.qml`; `Accounts.qml` becomes the service.
The heartbeat and watcher-dot logic move intact, including the deliberate
absence of a `watch` on the heartbeat file, which is written by atomic replace
and would kill a watch held on the old inode.
`mail-notify.sh`, `waybar-mail.sh` and `test-mail-notify.sh` move to
`modules/mail/`. The waybar mail module stays on the bar for the unread count
it displays, which nothing else shows; its click target becomes the drawer's
mail page.
### vm (alwaysActive: false)
`Virsh.qml` becomes the service, `VmPanel.qml` the page. Managed-save detection
stays `virsh dominfo` grepped for `Managed save: yes`, polled per VM, since
`virsh list --name` does not report it. Memory and disk figures stay
guest-agent-only, showing a dash rather than substituting libvirt's
host-side numbers, which measure something else.
The tile shows a status dot. Since the service sleeps while the page is closed,
the tile needs some state to show, so the drawer opening triggers one `virsh
list` rather than waiting for the page. A tile that cannot say anything until
its page has been visited is a poor tile, and one list call is cheap; the
per-VM polling for stats and managed save still waits for the page.
`vms_dots.sh` stays on waybar, unchanged and outside the shell, so nothing
loses glanceable VM state when the in-shell service sleeps.
The existing `notify-send` on action failure stays as it is.
### appearance (tile only)
`activate()` calls the external appearance shell's existing IPC. No page, no
service. The module that demonstrates a tile needs neither.
## Triggers
The drawer opens from a new waybar custom module at the left end of the bar,
shaped like a Slackware icon: a static button with `on-click` and no `exec`. It
cannot show open/closed state, which would require the shell to feed waybar,
and that is deliberately not built.
One IPC function, `open(page)`, with the page name optional:
| trigger | destination |
|---|---|
| waybar launcher | grid |
| waybar mail click | mail page |
| `SUPER+v` | vm page |
| `SUPER+Return` | appearance, unchanged, external |
| ALT+TAB | window switcher, unchanged, external |
Deep-linking preserves the one-keypress paths that exist today rather than
taxing them with an extra click. The back arrow then has an obvious meaning:
back to the grid, from wherever you landed.
The drawer may gain a keyboard shortcut of its own later.
## Files
```
desktop/
shell.qml ShellRoot: keepalive window, IPC, module registry
Drawer.qml panel window, notification area, grid, page stack
Tile.qml
Page.qml header, back arrow, content slot
Module.qml the contract
Theme.qml symlink to ../shared/Theme.qml
Button.qml the file duplicated in mail-overview and vm-manager
modules/
sound/ Module.qml Service.qml Osd.qml Tile.qml Page.qml
mail/ Module.qml Service.qml Tile.qml Page.qml + the three scripts
vm/ Module.qml Service.qml Tile.qml Page.qml
appearance/ Module.qml
```
`Button.qml` is currently byte-identical in `mail-overview` and `vm-manager`;
the merge collapses it to one file. `Theme.qml` remains a symlink to
`shared/Theme.qml`, per the existing convention, and the count of links drops
from five to three.
The three absorbed directories are deleted in the same commit that adds them
under `desktop/`, so history follows the move and nothing is duplicated on
disk.
## Changes outside this repo
Six edits to the live Hyprland and waybar configuration, none of them in this
repository, all applied by the user:
| file | change |
|---|---|
| `autostart.lua` | five `qs` lines to three; `mail-notify.sh` path |
| `keybindings.lua` | `SUPER+v` to the drawer deep-link |
| `decorations.lua` | add `blur-desktop`; drop `blur-mail` and `blur-vm-manager`; keep `blur-volume-osd` |
| `waybar/config.jsonc` | add the launcher at the left end |
| `modules/custom/mail.jsonc` | `exec` path and `on-click` destination |
| `modules/custom/launcher.jsonc` | new static button |
`hyprctl reload` picks up binds and layer rules. `hl.exec_cmd` is exec-once, so
the process changes need a logout or a manual start.
## Failure and risk
Each module's failures stay its own: vm surfaces libvirt failures as a
notification, mail renders the watcher dot red and treats an unparseable
`notmuch` count as an error rather than an empty inbox.
The new risk is the merge itself. One process means a QML error in any module
can take down all four. The drawer and each page are lazily loaded, so a broken
page fails when it is opened rather than at startup, which leaves the
`alwaysActive` services as the only code that must be sound at launch.
## Verification
Per AGENTS.md, and the traps there are about measurement rather than code:
- A detached `qs` does not survive an agent's tool call. Start it so the
harness owns the process and confirm from the log, not a later `pgrep`.
- The process is `qs`, not `quickshell`. Use `pkill -x qs` and `pgrep -cx qs`,
and check the count afterwards.
- `pkill -f` matches the agent's own shell and kills the caller.
- A config with no visible window exits silently, reporting no error.
`test-mail-notify.sh` moves with the mail module and must still pass; its
sixteen tests are the only automated oracle in this project.
Anything visual is for the user to look at. A screenshot of a transient OSD is
a race.
|