# 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//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.