diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/superpowers/plans/2026-09-14-desktop-shell.md | 2817 |
1 files changed, 2817 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-09-14-desktop-shell.md b/docs/superpowers/plans/2026-09-14-desktop-shell.md new file mode 100644 index 0000000..b767919 --- /dev/null +++ b/docs/superpowers/plans/2026-09-14-desktop-shell.md @@ -0,0 +1,2817 @@ +# Desktop shell implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** One quickshell component, `desktop/`, presenting a left-side drawer that hosts sound, mail, vm and appearance as modules, absorbing three existing components. + +**Architecture:** A `ShellRoot` holds a keepalive window, an `IpcHandler` and a registry list of modules. Each module is a directory under `desktop/modules/` exposing a `Module.qml` that declares a tile, a page, both or neither, plus `alwaysActive` governing whether its background service runs while the drawer is closed. The drawer is a single `PanelWindow` whose content is either the grid or one full-height page. + +**Tech Stack:** Quickshell 0.3.1, Qt 6 QML. `QtQuick.Controls` for `ScrollView`, plain `QtQuick` `Flow` for the tile grid. `Quickshell.Services.Pipewire`, `Quickshell.Services.Mpris`, `Quickshell.Io` for `Process`/`FileView`/`IpcHandler`, `Quickshell.Wayland` for layershell properties. + +**Spec:** `docs/superpowers/specs/2026-09-14-desktop-shell-design.md` + +--- + +## Before you start + +Read `AGENTS.md` at the repo root. Four things there will cost you hours if you +skip them: + +- **A quickshell config with no visible window exits.** No error, no message, it + just quits. Every task that runs the shell depends on the keepalive + `PanelWindow` from Task 2 existing. +- **A detached `qs` does not survive a tool call.** Starting one with `&`, + `nohup` or `setsid -f` and checking `pgrep` later always reports it dead, + whether or not the config is sound. Start it so the harness owns the process, + read the log, and do not conclude anything from a later `pgrep`. +- **The process is `qs`, not `quickshell`.** `pkill -x quickshell` matches + nothing and exits successfully, so every "stopped" is a lie and restarts + stack. Use `pkill -x qs`, then `pgrep -cx qs` and check the number. +- **`pkill -f` kills the caller**, because the agent's own working directory is + in its command line. Always `-x`. + +Verification in this plan is therefore: start the shell in the foreground with a +timeout, read what it printed, and kill by exact name. Anything visual is for +the user to look at, not for a screenshot. + +## File structure + +``` +desktop/ + shell.qml ShellRoot: keepalive, IpcHandler, module registry + Drawer.qml the PanelWindow: notification area, grid, page stack + Module.qml the contract: name, icon, alwaysActive, tile, page, activate() + Tile.qml one grid tile: icon, label, state line, click + Page.qml page chrome: header, back arrow, content slot + Button.qml moved from mail-overview (byte-identical in vm-manager) + Theme.qml symlink -> ../shared/Theme.qml + README.md + modules/ + sound/ + SoundModule.qml + Service.qml PipeWire bindings, PwObjectTracker, show() logic + Player.qml moved from volume-osd, singleton, unchanged + Osd.qml the transient OSD window, keeps its own namespace + TransportButton.qml moved from volume-osd, unchanged + SoundTile.qml + SoundPage.qml + mail/ + MailModule.qml + Accounts.qml moved from mail-overview, singleton, unchanged + MailTile.qml + MailPage.qml MailPanel's content, rehomed + mail-notify.sh moved + waybar-mail.sh moved + test-mail-notify.sh moved + vm/ + VmModule.qml + Virsh.qml moved from vm-manager, singleton, unchanged + Stat.qml moved from vm-manager, unchanged + VmTile.qml + VmPage.qml VmPanel's content, rehomed + appearance/ + AppearanceModule.qml tile only, activate() calls the external shell + qmldir singleton registrations +``` + +Deleted when their contents have moved: `volume-osd/`, `mail-overview/`, +`vm-manager/`. + +### A note on singletons + +`Player.qml`, `Accounts.qml`, `Virsh.qml` and `Theme.qml` are all +`pragma Singleton`. In the existing components they work because each is in the +component's root directory, which quickshell scans. Moving them into +`modules/<name>/` subdirectories means they are no longer in the root, so they +need a `qmldir` registering them. Task 1 creates it, and every later task that +moves a singleton adds its line. + +--- + +## Task 1: Skeleton that runs + +**Files:** +- Create: `desktop/shell.qml` +- Create: `desktop/qmldir` +- Create: `desktop/Theme.qml` (symlink) + +- [ ] **Step 1: Create the directory and the Theme symlink** + +The symlink, not a copy. `shared/Theme.qml` is the one real file and the other +components link to it; a copy here would be the drift the shared file exists to +prevent. + +```bash +mkdir -p desktop/modules +ln -s ../shared/Theme.qml desktop/Theme.qml +ls -l desktop/Theme.qml +``` + +Expected: `desktop/Theme.qml -> ../shared/Theme.qml` + +- [ ] **Step 2: Write the qmldir** + +`desktop/qmldir`: + +``` +singleton Theme 1.0 Theme.qml +``` + +- [ ] **Step 3: Write a shell that only holds itself open** + +`desktop/shell.qml`: + +```qml +// 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 + +ShellRoot { + // Quickshell exits once no window is visible, and this shell's drawer is + // closed most of the time. A 1x1 transparent window with an empty mask + // holds the process open without drawing anything or catching a click. + // See AGENTS.md: without it the shell loads, reports no error, and quits. + PanelWindow { + visible: true + implicitWidth: 1 + implicitHeight: 1 + color: "transparent" + exclusionMode: ExclusionMode.Ignore + mask: Region {} + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + } +} +``` + +- [ ] **Step 4: Verify it loads and stays up** + +Run it in the foreground under a timeout, so the harness owns the process: + +```bash +timeout 5 qs -p desktop 2>&1 | head -20 +``` + +Expected: a line containing `Configuration Loaded`, no `QML` errors, and the +command ending only when the timeout fires (exit 124). If it returns +immediately with no error, the keepalive window is missing or malformed. + +- [ ] **Step 5: Commit** + +```bash +git add desktop/ +git commit -m "feat(desktop): skeleton shell with the keepalive window + +The window draws nothing and catches nothing; it exists because a +quickshell config with no visible window exits silently, and this +shell's drawer is closed most of the time." +``` + +--- + +## Task 2: The module contract + +**Files:** +- Create: `desktop/Module.qml` + +- [ ] **Step 1: Write Module.qml** + +Deliberately thin. Its value is being the one file to read to learn what a +module is, not enforcement. + +`desktop/Module.qml`: + +```qml +// 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 QtQuick + +// What a module declares to the drawer. +// +// 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. +// +// Nothing here enforces anything: a module is free to do something unusual. +// This file is documentation with defaults. +QtObject { + // Identifies the module to IPC: `ipc call drawer open <name>`. + required property string name + + // Shown on the tile. A Nerd Font glyph. + property string icon: "" + + // Label under the icon. Defaults to the name, capitalised. + property string label: name.charAt(0).toUpperCase() + name.slice(1) + + // Whether this module's background service runs while the drawer is + // closed. The drawer is closed most of the time, so this is what decides + // whether the shell is cheap to run all session. It governs the service + // only: pages are lazily loaded either way. + property bool alwaysActive: false + + // Rendered inside the tile, below the icon: a short state line. Null for + // a tile that says nothing beyond its label. + property Component tileContent: null + + // The full-height page behind the tile. Null means the tile is + // fire-and-forget and activate() is called instead. + property Component page: null + + // What a tile with no page does when clicked. + function activate() {} +} +``` + +- [ ] **Step 2: Verify it parses** + +`Module.qml` is not instantiated yet, so loading the shell will not touch it. +Check it compiles on its own. + +Use the Qt 6 binary by its full path: bare `qmllint` on this machine resolves +to `/usr/lib64/qt5/bin/qmllint`, which rejects Qt 6 syntax and reports errors +that have nothing to do with the file. + +```bash +/usr/lib64/qt6/bin/qmllint desktop/Module.qml 2>&1 | head -20 +``` + +Expected: no output, or warnings only about the unresolved `Theme` import, +which qmllint cannot see without the config's import path. Errors naming a +syntax problem are real failures. + +- [ ] **Step 3: Commit** + +```bash +git add desktop/Module.qml +git commit -m "feat(desktop): the module contract + +A module provides a tile, a page, both or neither, plus alwaysActive, +which governs the background service rather than the page: the drawer is +closed most of the time and three of four modules have background work." +``` + +--- + +## Task 3: Tile and Page chrome + +**Files:** +- Create: `desktop/Tile.qml` +- Create: `desktop/Page.qml` + +- [ ] **Step 1: Write Tile.qml** + +Sized by the `Flow` that holds it, so the width comes from outside. The minimum +tile width of 180px lives in `Drawer.qml`, not here. + +`desktop/Tile.qml`: + +```qml +// 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 QtQuick + +Rectangle { + id: tile + + property string icon: "" + property string label: "" + property Component content: null + property bool active: false + signal clicked + + implicitHeight: 96 + radius: 12 + color: area.containsMouse + ? Qt.alpha(Theme.accent, 0.22) + : Qt.alpha(Theme.surface, active ? 0.7 : 0.35) + border.width: 1 + border.color: active ? Qt.alpha(Theme.accent, 0.5) : Qt.alpha(Theme.text, 0.08) + + Behavior on color { ColorAnimation { duration: 120 } } + + Column { + anchors { + left: parent.left; right: parent.right + verticalCenter: parent.verticalCenter + leftMargin: 14; rightMargin: 14 + } + spacing: 6 + + Text { + text: tile.icon + font { family: Theme.fontFamily; pixelSize: 22 } + color: tile.active ? Theme.accent : Theme.text + } + + Text { + width: parent.width + elide: Text.ElideRight + text: tile.label + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + color: Theme.text + } + + // The state line. A module with nothing to say leaves this null and + // the tile is icon and label only. + Loader { + width: parent.width + active: tile.content !== null + sourceComponent: tile.content + } + } + + MouseArea { + id: area + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: tile.clicked() + } +} +``` + +- [ ] **Step 2: Write Page.qml** + +The header and back arrow, with the module's own content below. The content +scrolls: mail with several accounts and vm with several VMs both exceed the +drawer height. + +`desktop/Page.qml`: + +```qml +// 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 QtQuick +import QtQuick.Controls + +Item { + id: page + + property string title: "" + default property alias content: holder.data + signal back + + Item { + id: header + anchors { top: parent.top; left: parent.left; right: parent.right } + height: 44 + + Rectangle { + id: backBtn + anchors { left: parent.left; verticalCenter: parent.verticalCenter } + width: 32; height: 32; radius: 16 + color: backArea.containsMouse ? Qt.alpha(Theme.accent, 0.22) : "transparent" + + Text { + anchors.centerIn: parent + text: "" + font { family: Theme.fontFamily; pixelSize: 14 } + color: Theme.text + } + + MouseArea { + id: backArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: page.back() + } + } + + Text { + anchors { left: backBtn.right; leftMargin: 10; verticalCenter: parent.verticalCenter } + text: page.title + font { family: Theme.fontFamily; pixelSize: Theme.fontSize + 2; bold: true } + color: Theme.text + } + } + + Rectangle { + id: rule + anchors { top: header.bottom; left: parent.left; right: parent.right } + height: 1 + color: Qt.alpha(Theme.text, 0.12) + } + + ScrollView { + anchors { top: rule.bottom; left: parent.left; right: parent.right; bottom: parent.bottom } + anchors.topMargin: 12 + clip: true + contentWidth: availableWidth + + Item { + id: holder + width: parent.width + implicitHeight: childrenRect.height + } + } +} +``` + +- [ ] **Step 3: Verify both parse by instantiating them** + +Temporarily add to `desktop/shell.qml`, inside `ShellRoot`, after the keepalive +window: + +```qml + // scratch: remove before committing + property Component _t: Tile { icon: "x"; label: "Test" } + property Component _p: Page { title: "Test" } +``` + +Then: + +```bash +timeout 5 qs -p desktop 2>&1 | head -20 +``` + +Expected: `Configuration Loaded`, no errors naming `Tile.qml` or `Page.qml`. +Remove the two scratch lines afterwards. + +- [ ] **Step 4: Commit** + +```bash +git add desktop/Tile.qml desktop/Page.qml +git commit -m "feat(desktop): tile and page chrome + +The page body scrolls because mail with several accounts and vm with +several VMs both exceed the drawer height; the tile grid deliberately +does not, being fixed at a 3x3 ceiling." +``` + +--- + +## Task 4: The drawer + +**Files:** +- Create: `desktop/Drawer.qml` +- Modify: `desktop/shell.qml` + +- [ ] **Step 1: Write Drawer.qml** + +The layout decisions from the spec are all here: left-anchored on DP-1, 600px, +`ExclusionMode.Normal`, reserved top, `Flow` grid with a 180px minimum tile +width, page replacing the whole content. + +`desktop/Drawer.qml`: + +```qml +// 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 + +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: "" + + 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) { + root.page = 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 = ""; + } + + 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. + Item { + anchors.fill: parent + focus: true + Keys.onEscapePressed: { + if (root.page) root.page = ""; + else root.close(); + } + } + + 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) + + // 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 === "" + + // Reserved for the notification engine. An empty Item that + // claims the space rather than a placeholder graphic: the + // grid has to sit where it will sit once notifications + // arrive, or the layout is tuned against a position that + // does not survive. + Item { + id: notifications + anchors { top: parent.top; left: parent.left; right: parent.right } + anchors.bottom: grid.top + anchors.bottomMargin: 16 + } + + // 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 + content: modelData.tileContent + onClicked: root.activate(modelData) + } + } + } + } + + // --- page view --- + + Loader { + anchors.fill: parent + anchors.margins: 16 + active: root.current !== null + sourceComponent: root.current?.page ?? 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. + opacity: active ? 1 : 0 + x: active ? 16 : 60 + Behavior on x { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } } + Behavior on opacity { NumberAnimation { duration: 160 } } + } + } + } + } +} +``` + +- [ ] **Step 2: Wire it into the shell with no modules yet** + +Replace `desktop/shell.qml` with: + +```qml +// 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 + +ShellRoot { + // Quickshell exits once no window is visible, and the drawer is closed + // most of the time. See AGENTS.md. + PanelWindow { + visible: true + implicitWidth: 1 + implicitHeight: 1 + color: "transparent" + exclusionMode: ExclusionMode.Ignore + mask: Region {} + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + } + + Drawer { + id: drawer + modules: [] + } + + // The waybar launcher and the deep-link keybinds all reach this: + // qs -p <this dir> ipc call drawer open -> the grid + // qs -p <this dir> ipc call drawer open mail -> the mail page + IpcHandler { + target: "drawer" + function open(page: string) { drawer.show(page); } + function toggle(page: string) { drawer.toggle(page); } + function close() { drawer.close(); } + } +} +``` + +- [ ] **Step 3: Verify the drawer opens** + +```bash +timeout 8 qs -p desktop 2>&1 | head -20 & +sleep 3 +qs -p desktop ipc call drawer open +sleep 1 +qs -p desktop ipc call drawer close +wait +``` + +Expected: `Configuration Loaded`, both `ipc call` commands exiting 0, and no +QML errors. An empty 600px panel appearing on the left of DP-1 for one second +is the visible result; ask the user to confirm it rather than screenshotting. + +- [ ] **Step 4: Commit** + +```bash +git add desktop/Drawer.qml desktop/shell.qml +git commit -m "feat(desktop): the drawer, with the top reserved + +Left of DP-1 because conky holds the right; ExclusionMode.Normal so the +bar the drawer is reached from stays visible and clickable. The top is an +empty Item claiming the space the notification engine will fill, so the +grid already sits where it will sit once that lands." +``` + +--- + +## Task 5: The appearance module + +The simplest module, and the one that proves a tile needs no page. Done first +so the grid has something in it before the harder migrations. + +**Files:** +- Create: `desktop/modules/appearance/AppearanceModule.qml` +- Modify: `desktop/shell.qml` + +- [ ] **Step 1: Write the module** + +`desktop/modules/appearance/AppearanceModule.qml`: + +```qml +// 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 QtQuick + +// Fire and forget. The appearance shell stays a separate process: a wallpaper +// picker needs more room than a 600px drawer, so this tile only opens it. +Module { + id: mod + + name: "appearance" + icon: "" + label: "Appearance" + + function activate() { + proc.running = false; + proc.running = true; + } + + property Process proc: Process { + command: ["qs", "-p", `${Quickshell.env("HOME")}/Programming/GIT/quickshell/appearance`, + "ipc", "call", "appearance", "wallpaper"] + } +} +``` + +- [ ] **Step 2: Register it in the shell** + +A QML type is named by its file, so each module's file carries its own name +rather than all four being `Module.qml`: four files of the same name in four +directories would collide the moment two are imported together. Write the file +from Step 1 as `desktop/modules/appearance/AppearanceModule.qml`, and the type +is `AppearanceModule`. + +In `desktop/shell.qml`, add the directory import near the top, after the other +imports: + +```qml +import "modules/appearance" +``` + +and replace the `Drawer` block with: + +```qml + Drawer { + id: drawer + modules: [ + AppearanceModule {}, + ] + } +``` + +- [ ] **Step 3: Verify the tile appears and fires** + +```bash +timeout 10 qs -p desktop 2>&1 | head -20 & +sleep 3 +qs -p desktop ipc call drawer open +sleep 5 +wait +pgrep -cx qs +``` + +Expected: one tile labelled "Appearance" in the grid. Ask the user to click it +and confirm the wallpaper picker opens and the drawer closes. `pgrep -cx qs` +should report the number of shells actually running, which during development +is the existing five plus this one. + +- [ ] **Step 4: Commit** + +```bash +git add desktop/modules/appearance/ desktop/shell.qml +git commit -m "feat(desktop): the appearance tile + +A tile with no page: appearance stays its own process because a wallpaper +picker needs more room than a 600px drawer, so the tile only fires its +existing IPC. This is the case the contract's activate() exists for." +``` + +--- + +## Task 6: Move the sound module + +Three jobs currently live in `VolumeOsd.qml`: PipeWire tracking, the OSD +surface, and the player transport. They split into `Service.qml`, `Osd.qml` and +the page. + +**Files:** +- Create: `desktop/modules/sound/SoundModule.qml` +- Create: `desktop/modules/sound/Service.qml` +- Create: `desktop/modules/sound/Osd.qml` +- Create: `desktop/modules/sound/SoundTile.qml` +- Create: `desktop/modules/sound/SoundPage.qml` +- Move: `volume-osd/Player.qml` -> `desktop/modules/sound/Player.qml` +- Move: `volume-osd/TransportButton.qml` -> `desktop/modules/sound/TransportButton.qml` +- Modify: `desktop/qmldir` + +- [ ] **Step 1: Move the two files that need no change** + +```bash +git mv volume-osd/Player.qml desktop/modules/sound/Player.qml +git mv volume-osd/TransportButton.qml desktop/modules/sound/TransportButton.qml +``` + +- [ ] **Step 2: Register Player as a singleton** + +`Player.qml` is `pragma Singleton` and is no longer in the config root, so it +needs a qmldir entry. Append to `desktop/qmldir`: + +``` +singleton Player 1.0 modules/sound/Player.qml +``` + +- [ ] **Step 3: Write the service** + +The PipeWire half of the old `VolumeOsd.qml`, with the two traps preserved +verbatim in comment and code. + +`desktop/modules/sound/Service.qml`: + +```qml +// 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.Services.Pipewire +import QtQuick + +// The PipeWire half of what used to be VolumeOsd.qml. Always active: the OSD +// has to react to a volume keypress with no drawer open. +Scope { + id: root + + readonly property PwNode sink: Pipewire.defaultAudioSink + readonly property PwNode source: Pipewire.defaultAudioSource + + readonly property real volume: sink?.audio?.volume ?? 0 + readonly property bool muted: sink?.audio?.muted ?? false + + // Which node changed last, and whether it was the input. The OSD draws + // this one; null means nothing to show. + property PwNode active: null + property bool isInput: false + + // Keeping the nodes bound is what makes volume/muted actually update. + // Without the tracker the value reads once and goes stale. + PwObjectTracker { objects: [root.sink, root.source].filter(n => n !== null) } + + signal changed() + + // A node reports its initial volume while binding, before `ready` goes + // true, so the `ready` check alone suppresses the startup values. Nothing + // else may be swallowed: the next signal after that is the user's first + // keypress, and eating it costs the OSD its first appearance. + function show(node, input) { + if (!node?.ready || !node.audio) return; + root.active = node; + root.isInput = input; + root.changed(); + } + + function showTrack() { + if (!Player.active) return; + root.active = root.sink; + root.isInput = false; + root.changed(); + } + + Connections { + target: root.sink?.audio ?? null + function onVolumeChanged() { root.show(root.sink, false); } + function onMutedChanged() { root.show(root.sink, false); } + } + + Connections { + target: root.source?.audio ?? null + function onVolumeChanged() { root.show(root.source, true); } + function onMutedChanged() { root.show(root.source, true); } + } + + // A track change shows the OSD as well, so the row is not something you + // only see when you happen to touch the volume. + Connections { + target: Player.current ?? null + function onTrackTitleChanged() { if (Player.title) root.showTrack(); } + function onPlaybackStateChanged() { root.showTrack(); } + } +} +``` + +- [ ] **Step 4: Write the OSD** + +The window half, keeping its namespace so the existing Hyprland blur rule needs +no edit. The body is the old `VolumeOsd.qml` from its first `PanelWindow` +onward, with `root.` reads redirected to the injected service. + +`desktop/modules/sound/Osd.qml`: + +```qml +// 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 Quickshell.Services.Pipewire +import QtQuick + +// The transient on-screen display. Unchanged in behaviour from volume-osd, +// including its namespace, so the existing Hyprland blur rule still matches. +Scope { + id: root + + required property var service + + // Milliseconds the OSD stays up after the last change. + property int timeout: 1500 + + property bool visibleNow: false + + // Hovering freezes the countdown so the transport buttons can be clicked; + // leaving starts it again. + property bool hovered: false + + Connections { + target: root.service + function onChanged() { + root.visibleNow = true; + hideTimer.restart(); + } + } + + Timer { + id: hideTimer + running: root.visibleNow && !root.hovered + interval: root.timeout + onTriggered: root.visibleNow = false + } + + PanelWindow { + id: win + + visible: root.visibleNow + + readonly property PwNode node: root.service.active + readonly property real volume: node?.audio?.volume ?? 0 + readonly property bool muted: node?.audio?.muted ?? false + readonly property bool isInput: root.service.isInput + + // Bottom centre. Move the anchor to relocate. + anchors.bottom: true + margins.bottom: 120 + + // Grows to fit the track row; the volume-only size is unchanged. + implicitWidth: 360 + implicitHeight: Player.active ? 150 : 72 + color: "transparent" + + exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: "quickshell-volume-osd" + // Still no keyboard focus: the transport buttons are pointer targets, + // and the OSD must never take keys from the window being typed in. + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + Rectangle { + anchors.fill: parent + radius: 12 + // Translucent so the compositor's blur shows through. The frosting + // itself is Hyprland's, applied by layerrule to this window's + // namespace: see the README. + color: Qt.alpha(Theme.base, 0.65) + border.width: 1 + border.color: Qt.alpha(Theme.text, 0.12) + + HoverHandler { + onHoveredChanged: root.hovered = hovered + } + + Column { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + Loader { + active: Player.active + width: parent.width + sourceComponent: trackRow + } + + Rectangle { + visible: Player.active + width: parent.width + height: 1 + color: Qt.alpha(Theme.text, 0.1) + } + + Row { + width: parent.width + spacing: 14 + + Text { + anchors.verticalCenter: parent.verticalCenter + width: 30 + horizontalAlignment: Text.AlignHCenter + font.family: Theme.fontFamily + font.pixelSize: 24 + color: win.muted ? Theme.red : Theme.accent + text: { + if (win.isInput) return win.muted ? "" : ""; + if (win.muted || win.volume <= 0) return ""; + return win.volume < 0.5 ? "" : ""; + } + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - 30 - parent.spacing + spacing: 8 + + Item { + width: parent.width + height: label.implicitHeight + + Text { + id: label + anchors.left: parent.left + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + color: Theme.subtext + text: win.isInput ? "Input" : "Output" + } + + Text { + anchors.right: parent.right + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + color: Theme.text + text: win.muted ? "muted" : Math.round(win.volume * 100) + "%" + } + } + + Rectangle { + width: parent.width + height: 6 + radius: 3 + color: Theme.surface + + Rectangle { + height: parent.height + radius: parent.radius + // Volume can exceed 1.0; the bar stops at full. + width: parent.width * Math.min(win.volume, 1) + color: win.muted ? Theme.red : Theme.accent + opacity: win.muted ? 0.5 : 1 + Behavior on width { NumberAnimation { duration: 100 } } + } + } + } + } + } + } + } + + Component { + id: trackRow + + Row { + id: trackLine + // A Row sizes to its children, so the panel width has to be + // pushed in: the text column below subtracts from it. + width: parent ? parent.width : 0 + spacing: 12 + + // Players that extract embedded art reuse one temp path, so the + // source carries a per-track suffix and caching is off. + Rectangle { + width: 46; height: 46; radius: 6 + color: Qt.alpha(Theme.surface, 0.8) + clip: true + + Image { + anchors.fill: parent + source: Player.artUrl + cache: false + asynchronous: true + fillMode: Image.PreserveAspectCrop + visible: status === Image.Ready + } + Text { + anchors.centerIn: parent + visible: Player.artUrl === "" || parent.children[0].status !== Image.Ready + text: "" + font { family: Theme.fontFamily; pixelSize: 20 } + color: Theme.overlay + } + } + + Column { + anchors.verticalCenter: parent.verticalCenter + // Whatever the art and transport buttons leave: a fixed width + // here overflowed the panel and pushed `next` past its edge. + width: trackLine.width - 46 - transport.width - 2 * trackLine.spacing + spacing: 3 + + Text { + width: parent.width + elide: Text.ElideRight + text: Player.title || "Nothing playing" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; bold: true } + color: Theme.text + } + Text { + width: parent.width + elide: Text.ElideRight + visible: Player.artist !== "" + text: Player.artist + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + color: Theme.subtext + } + } + + Row { + id: transport + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + TransportButton { + glyph: "" + enabled: Player.current?.canGoPrevious ?? false + onClicked: Player.current?.previous() + } + TransportButton { + glyph: Player.playing ? "" : "" + enabled: Player.current?.canTogglePlaying ?? false + onClicked: Player.current?.togglePlaying() + } + TransportButton { + glyph: "" + enabled: Player.current?.canGoNext ?? false + onClicked: Player.current?.next() + } + } + } + } +} +``` + +Note: the glyphs above are written as escapes because the originals are Nerd +Font private-use characters that do not survive copying through a plan +document. When moving the file, take the glyph bytes from the original +`volume-osd/VolumeOsd.qml` rather than retyping them, and check +`git diff` shows no change to those literals. + +- [ ] **Step 5: Write the tile content and the page** + +`desktop/modules/sound/SoundTile.qml`: + +```qml +// 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 QtQuick + +// The tile's state line: volume, or what is playing. +Text { + required property var service + + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.subtext + text: { + if (service.muted) return "muted"; + const pct = Math.round(service.volume * 100) + "%"; + return Player.active && Player.title ? `${pct} · ${Player.title}` : pct; + } +} +``` + +`desktop/modules/sound/SoundPage.qml`: + +```qml +// 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.Services.Pipewire +import QtQuick + +Column { + id: page + + required property var service + signal back + + spacing: 16 + + // Output and input, each with its own slider. + Repeater { + model: [ + { label: "Output", node: page.service.sink }, + { label: "Input", node: page.service.source }, + ] + + Column { + required property var modelData + readonly property var audio: modelData.node?.audio ?? null + + width: page.width + spacing: 6 + + Item { + width: parent.width + implicitHeight: name.implicitHeight + + Text { + id: name + anchors.left: parent.left + text: modelData.label + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; bold: true } + color: Theme.text + } + + Text { + anchors.right: parent.right + text: !audio ? "—" : audio.muted ? "muted" : Math.round(audio.volume * 100) + "%" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1 } + color: audio?.muted ? Theme.red : Theme.subtext + } + } + + Text { + width: parent.width + elide: Text.ElideRight + text: modelData.node?.description ?? modelData.node?.name ?? "" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.overlay + } + + // Click or drag anywhere on the bar to set the level. + Rectangle { + width: parent.width + height: 8 + radius: 4 + color: Theme.surface + + Rectangle { + height: parent.height + radius: parent.radius + width: parent.width * Math.min(audio?.volume ?? 0, 1) + color: audio?.muted ? Theme.red : Theme.accent + opacity: audio?.muted ? 0.5 : 1 + } + + MouseArea { + anchors.fill: parent + enabled: audio !== null + onPositionChanged: mouse => set(mouse.x) + onPressed: mouse => set(mouse.x) + function set(x) { + if (audio) audio.volume = Math.max(0, Math.min(1, x / width)); + } + } + } + } + } + + Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.12) } + + // What is playing, with transport. Same Player singleton the OSD uses, + // so playerctld's duplicate is already filtered out by dbusName. + Column { + width: parent.width + spacing: 8 + visible: Player.active + + Text { + width: parent.width + elide: Text.ElideRight + text: Player.title || "Nothing playing" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; bold: true } + color: Theme.text + } + + Text { + width: parent.width + elide: Text.ElideRight + visible: Player.artist !== "" + text: Player.artist + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + color: Theme.subtext + } + + Row { + spacing: 4 + TransportButton { + glyph: "" + enabled: Player.current?.canGoPrevious ?? false + onClicked: Player.current?.previous() + } + TransportButton { + glyph: Player.playing ? "" : "" + enabled: Player.current?.canTogglePlaying ?? false + onClicked: Player.current?.togglePlaying() + } + TransportButton { + glyph: "" + enabled: Player.current?.canGoNext ?? false + onClicked: Player.current?.next() + } + } + } +} +``` + +- [ ] **Step 6: Write the module** + +`desktop/modules/sound/SoundModule.qml`: + +```qml +// 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 QtQuick + +// Always active: the OSD must react to a volume keypress with no drawer open, +// which is the whole reason this module's service cannot be lazy. +Module { + id: mod + + name: "sound" + icon: "" + label: "Sound" + alwaysActive: true + + readonly property Service service: Service {} + + // The OSD is this module's own window, outside the drawer entirely. + readonly property Osd osd: Osd { service: mod.service } + + tileContent: Component { + SoundTile { service: mod.service } + } + + page: Component { + Page { + title: "Sound" + SoundPage { width: parent.width; service: mod.service } + } + } +} +``` + +- [ ] **Step 7: Register it and delete the old component** + +In `desktop/shell.qml`, add `import "modules/sound"` and put `SoundModule {}` +first in the `modules` list, before `AppearanceModule {}`. + +Then remove what is now duplicated: + +```bash +git rm volume-osd/VolumeOsd.qml volume-osd/shell.qml volume-osd/Theme.qml +git mv volume-osd/README.md desktop/modules/sound/README.md +rmdir volume-osd +``` + +- [ ] **Step 8: Verify the OSD still works and the page renders** + +```bash +pkill -x qs +pgrep -cx qs +``` + +Expected: `0`. If it is not zero, something is still running and later readings +will be wrong. + +Then start only the new shell: + +```bash +timeout 20 qs -p desktop 2>&1 | head -30 +``` + +While it runs, ask the user to: +1. Press a volume key and confirm the OSD appears bottom-centre as before. +2. Run `qs -p desktop ipc call drawer open sound` and confirm the page shows + output and input with working sliders. + +Afterwards restart the other components the user still needs: + +```bash +qs -p vm-manager & +qs -p mail-overview & +qs -p appearance & +qs -p window-switcher & +``` + +Note these are detached and will not survive the tool call; they are for the +user's session, so have the user start them, or leave them for the next login. + +- [ ] **Step 9: Commit** + +```bash +git add -A desktop/modules/sound volume-osd desktop/shell.qml desktop/qmldir +git commit -m "feat(desktop): move volume-osd in as the sound module + +VolumeOsd.qml did three jobs in one file: PipeWire tracking, the OSD +surface and the player transport. They become Service, Osd and the page. +The OSD keeps its namespace so the existing Hyprland blur rule still +matches, and the service stays always-active because the OSD has to +answer a keypress with no drawer open." +``` + +--- + +## Task 7: Move the mail module + +The gentlest move: `Accounts.qml` is unchanged, `MailPanel.qml`'s body becomes +the page, and the three scripts move with it. + +**Files:** +- Move: `mail-overview/Accounts.qml` -> `desktop/modules/mail/Accounts.qml` +- Move: `mail-overview/Button.qml` -> `desktop/Button.qml` +- Move: the three scripts -> `desktop/modules/mail/` +- Create: `desktop/modules/mail/MailModule.qml` +- Create: `desktop/modules/mail/MailTile.qml` +- Create: `desktop/modules/mail/MailPage.qml` +- Modify: `desktop/qmldir` + +- [ ] **Step 1: Move the files that need no change** + +`Button.qml` is byte-identical in `mail-overview` and `vm-manager`; one copy +moves to the shell root and the other is deleted in Task 8. + +```bash +git mv mail-overview/Accounts.qml desktop/modules/mail/Accounts.qml +git mv mail-overview/Button.qml desktop/Button.qml +git mv mail-overview/mail-notify.sh desktop/modules/mail/mail-notify.sh +git mv mail-overview/waybar-mail.sh desktop/modules/mail/waybar-mail.sh +git mv mail-overview/test-mail-notify.sh desktop/modules/mail/test-mail-notify.sh +``` + +- [ ] **Step 2: Register Accounts as a singleton** + +Append to `desktop/qmldir`: + +``` +singleton Accounts 1.0 modules/mail/Accounts.qml +``` + +- [ ] **Step 3: Check the scripts for self-referential paths** + +The scripts may locate siblings relative to their own directory. Check before +assuming the move is transparent: + +```bash +grep -n 'dirname\|BASH_SOURCE\|\$0\|mail-overview' desktop/modules/mail/*.sh +``` + +If any line hardcodes `mail-overview`, update it to the new path. If they use +`$(dirname "$0")` they are already correct. + +- [ ] **Step 4: Verify the test suite still passes** + +This is the only automated oracle in the whole project. + +```bash +./desktop/modules/mail/test-mail-notify.sh +``` + +Expected: `16 passed, 0 failed`. If the count differs, the move broke +something; fix before continuing. + +- [ ] **Step 5: Write the page** + +`MailPanel.qml`'s content, with the window chrome dropped and `root.` reads +pointing at the page. The heartbeat `FileView` moves in unchanged, including +its deliberate lack of `watchChanges`. + +`desktop/modules/mail/MailPage.qml`: + +```qml +// 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 QtQuick + +Column { + id: page + + signal close + + // mail-watcher's heartbeat, written every 60s. Read once per open: the + // page is behind a Loader, so opening it rebuilds the FileView and reads + // the current file. Watch is deliberately not used, because the heartbeat + // is written by atomic replace (tmpfile + rename) and an inotify watch + // held on the old inode dies with it. + property bool watcherAlive: false + property int watcherDead: 0 + property int watcherExpected: 0 + + // The same staleness rule as mail-watcher's heartbeat_is_healthy: dead + // threads or a heartbeat older than 300s mean the watcher needs a look. + // Backoff is healthy, so it never turns the dot. + function readHeartbeat(payload) { + page.watcherAlive = false; + page.watcherDead = 0; + page.watcherExpected = 0; + + let data = null; + try { data = JSON.parse(payload); } catch (e) { return; } + if (!data || typeof data.ts !== "string") return; + + const ts = Date.parse(data.ts); + if (isNaN(ts) || (Date.now() - ts) / 1000 > 300) return; + + page.watcherAlive = true; + page.watcherDead = Number(data.dead) || 0; + page.watcherExpected = Number(data.expected) || 0; + } + + readonly property color watcherColor: + !watcherAlive ? Theme.red + : watcherDead > 0 ? Theme.yellow + : Theme.green + + readonly property string watcherText: + !watcherAlive ? "watcher not running" + : watcherDead > 0 ? `${watcherDead} folder(s) dead, check the log` + : `watcher ok · ${watcherExpected} folders` + + spacing: 10 + + FileView { + id: heartbeat + path: `${Quickshell.env("HOME")}/.local/state/mail-watcher.heartbeat` + onLoaded: page.readHeartbeat(text()) + onLoadFailed: page.readHeartbeat("") + } + + Item { + width: parent.width + implicitHeight: totalText.implicitHeight + + Text { + id: totalText + anchors.right: parent.right + // A dash rather than a possibly-wrong number while any account is + // still unknown. + text: Accounts.anyUnknown ? "—" : `${Accounts.total} unread` + color: Theme.subtext + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + } + + Text { + visible: Accounts.error !== "" + width: parent.width + text: Accounts.error + color: Theme.red + wrapMode: Text.WordWrap + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize - 2 + } + + Repeater { + model: Accounts.accounts + + Column { + required property var modelData + width: page.width + spacing: 4 + + Item { + width: parent.width + implicitHeight: 26 + + Rectangle { + id: dot + anchors.verticalCenter: parent.verticalCenter + width: 8; height: 8; radius: 4 + // The account's own colour from the config. Per-account + // identity, not a palette. + color: modelData.color || Theme.accent + } + + Text { + anchors { left: dot.right; leftMargin: 10; verticalCenter: parent.verticalCenter } + text: modelData.label + color: Theme.text + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + + Text { + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + text: modelData.count < 0 ? "—" : String(modelData.count) + color: modelData.count > 0 ? Theme.text : Theme.subtext + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.bold: modelData.count > 0 + } + } + + // The newest three unread threads. Read-only: qtmaildir takes no + // arguments, so there is no way to ask it for a particular thread. + Repeater { + model: modelData.threads + + Column { + required property var modelData + width: page.width - 18 + x: 18 + spacing: 1 + bottomPadding: 4 + + Item { + width: parent.width + implicitHeight: who.implicitHeight + + Text { + id: who + anchors.left: parent.left + width: parent.width - when.implicitWidth - 10 + text: modelData.authors + elide: Text.ElideRight + color: Theme.subtext + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize - 3 + } + + Text { + id: when + anchors.right: parent.right + text: modelData.date + color: Theme.overlay + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize - 3 + } + } + + Text { + width: parent.width + text: modelData.subject + elide: Text.ElideRight + color: Theme.text + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize - 2 + } + } + } + } + } + + Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.12) } + + // Watcher health. Green when idling, yellow when a folder gave up, red + // when there is no fresh heartbeat at all. + Item { + width: parent.width + implicitHeight: 22 + + Rectangle { + id: watcherDot + anchors.verticalCenter: parent.verticalCenter + width: 8; height: 8; radius: 4 + color: page.watcherColor + } + + Text { + anchors { left: watcherDot.right; leftMargin: 10; verticalCenter: parent.verticalCenter } + text: page.watcherText + color: Theme.subtext + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize - 2 + } + } + + Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.12) } + + Row { + anchors.right: parent.right + spacing: 10 + + Button { + text: "Open qtmaildir" + onClicked: { Accounts.openClient(); page.close(); } + } + } +} +``` + +- [ ] **Step 6: Write the tile and the module** + +`desktop/modules/mail/MailTile.qml`: + +```qml +// 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 QtQuick + +Text { + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Accounts.total > 0 ? Theme.text : Theme.subtext + // A dash rather than a possibly-wrong number while any account is unknown, + // the same rule the page header uses. + text: Accounts.anyUnknown ? "—" + : Accounts.total === 0 ? "no unread" + : `${Accounts.total} unread` +} +``` + +`desktop/modules/mail/MailModule.qml`: + +```qml +// 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 QtQuick + +// Always active: the unread count outlives the drawer, and Accounts watches +// qtmaildir.conf so a new account appears without a restart. +Module { + id: mod + + name: "mail" + icon: "" + label: "Mail" + alwaysActive: true + + tileContent: Component { MailTile {} } + + page: Component { + Page { + title: "Mail" + // Mail has almost certainly arrived since this was last opened, + // and for an autostarted shell that is the whole session. + Component.onCompleted: Accounts.refresh() + MailPage { width: parent.width } + } + } +} +``` + +- [ ] **Step 7: Register it and delete the old component** + +Add `import "modules/mail"` to `desktop/shell.qml` and put `MailModule {}` in +the `modules` list, after sound. + +```bash +git rm mail-overview/MailPanel.qml mail-overview/shell.qml mail-overview/Theme.qml +git mv mail-overview/README.md desktop/modules/mail/README.md +rmdir mail-overview +``` + +- [ ] **Step 8: Verify** + +```bash +pkill -x qs +pgrep -cx qs +``` + +Expected: `0`. + +```bash +./desktop/modules/mail/test-mail-notify.sh +timeout 15 qs -p desktop 2>&1 | head -30 +``` + +Expected: the test reporting `16 passed, 0 failed`, then +`Configuration Loaded` with no QML errors. Ask the user to run +`qs -p desktop ipc call drawer open mail` and confirm the account rows, thread +previews and watcher dot all render as they did in the old drawer. + +- [ ] **Step 9: Commit** + +```bash +git add -A desktop mail-overview +git commit -m "feat(desktop): move mail-overview in as the mail module + +Accounts.qml is unchanged and MailPanel's body becomes the page. The +three scripts move with it, which changes the absolute paths in autostart +and in the waybar module; both are outside this repo and listed in the +plan's final task. Button.qml, byte-identical here and in vm-manager, +lands at the shell root as the single copy." +``` + +--- + +## Task 8: Move the vm module + +The largest move. `Virsh.qml` becomes the module's service unchanged; +`VmPanel.qml`'s body becomes the page. + +**Files:** +- Move: `vm-manager/Virsh.qml` -> `desktop/modules/vm/Virsh.qml` +- Move: `vm-manager/Stat.qml` -> `desktop/modules/vm/Stat.qml` +- Create: `desktop/modules/vm/VmModule.qml` +- Create: `desktop/modules/vm/VmTile.qml` +- Create: `desktop/modules/vm/VmPage.qml` +- Modify: `desktop/qmldir`, `desktop/shell.qml` + +- [ ] **Step 1: Move the two files that need no change** + +```bash +git mv vm-manager/Virsh.qml desktop/modules/vm/Virsh.qml +git mv vm-manager/Stat.qml desktop/modules/vm/Stat.qml +git rm vm-manager/Button.qml +``` + +`Button.qml` is deleted rather than moved: it was byte-identical to +`mail-overview`'s, which became `desktop/Button.qml` in Task 7. Confirm before +deleting: + +```bash +git show HEAD~1:vm-manager/Button.qml | diff - desktop/Button.qml && echo IDENTICAL +``` + +- [ ] **Step 2: Register Virsh as a singleton** + +Append to `desktop/qmldir`: + +``` +singleton Virsh 1.0 modules/vm/Virsh.qml +``` + +- [ ] **Step 3: Write the page** + +`VmPanel.qml`'s content with the window chrome dropped. The confirm-step logic, +the per-VM rows and the snapshot list all move unchanged; only the enclosing +`Scope`/`PanelWindow` and the keyboard handling go away, the latter because the +drawer owns Escape now. + +`desktop/modules/vm/VmPage.qml`: + +```qml +// 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 QtQuick + +Column { + id: page + + property string selected: "" + + // A pending destructive action, shown as a confirm step instead of the + // action list: { kind, vm, snap }. Null when nothing is being confirmed. + property var confirming: null + property string typed: "" + + spacing: 16 + + Component.onCompleted: { + // The stats timer only runs while this page is up, so start it here + // and stop it in Component.onDestruction. The lifecycle event stream + // in Virsh keeps running regardless, which is what makes the list + // correct the moment the page appears. + Virsh.sampling = true; + Virsh.refreshList(); + if (!page.selected && Virsh.names.length) page.selected = Virsh.names[0]; + if (page.selected) Virsh.loadSnapshots(page.selected); + } + + Component.onDestruction: Virsh.sampling = false + + onSelectedChanged: if (selected) Virsh.loadSnapshots(selected) + + function fmtBytes(b) { + if (b < 0) return "—"; + const g = b / (1024 * 1024 * 1024); + return g >= 10 ? g.toFixed(0) + " GB" : g.toFixed(1) + " GB"; + } + + function stateColor(s) { + if (s === "running") return Theme.green; + if (s === "paused" || s === "suspended" || s === "shutting down") return Theme.yellow; + if (s === "crashed") return Theme.red; + return Theme.overlay; + } + + // Which verbs make sense in the current state, mirroring the states the + // old rofi script switched on. + function actionsFor(s, saved) { + if (s === "running") + return [["shutdown", "Shutdown"], ["reboot", "Reboot"], ["suspend", "Suspend"], + ["reset", "Reset"], ["destroy", "Force stop"]]; + if (s === "paused" || s === "suspended") + return [["resume", "Resume"], ["shutdown", "Shutdown"], ["destroy", "Force stop"]]; + // Only worth offering when a saved image actually exists: without one + // managedsave-remove fails, and the button would be noise on every + // other VM. + if (saved) + return [["start", "Start"], ["discardsave", "Discard saved state"]]; + return [["start", "Start"]]; + } + + function isDestructive(a) { return a === "reset" || a === "destroy" || a === "discardsave"; } + + function run(vm, action) { + if (isDestructive(action)) page.confirming = { kind: action, vm: vm, snap: "" }; + else Virsh.act(vm, action); + } + + // One row per VM, so several VMs stay readable at a glance. + Repeater { + model: Virsh.names + + Rectangle { + required property string modelData + readonly property var vm: Virsh.vms[modelData] ?? ({}) + readonly property bool isSel: page.selected === modelData + + width: page.width + implicitHeight: vmCol.implicitHeight + 24 + radius: 10 + color: isSel ? Qt.alpha(Theme.surface, 0.7) : Qt.alpha(Theme.surface, 0.35) + border.width: 1 + border.color: isSel ? Qt.alpha(Theme.accent, 0.5) : "transparent" + + MouseArea { + anchors.fill: parent + onClicked: page.selected = modelData + } + + Column { + id: vmCol + anchors { left: parent.left; right: parent.right; top: parent.top; margins: 12 } + spacing: 10 + + Row { + spacing: 10 + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: 9; height: 9; radius: 5 + color: page.stateColor(vm.state ?? "") + } + Text { + text: modelData + font { family: Theme.fontFamily; pixelSize: Theme.fontSize; bold: true } + color: Theme.text + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: vm.state ?? "" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: Theme.subtext + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: (vm.vcpus ?? 0) + " vCPU" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: Theme.overlay + } + Text { + anchors.verticalCenter: parent.verticalCenter + visible: vm.state === "running" && !(vm.agent ?? false) + text: "agent starting" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + color: Theme.overlay + } + } + + // Stats only mean anything while the VM runs. The figures are + // guest-agent only: libvirt's balloon.current reads full + // forever and block.allocation is host-side qcow2 growth, so + // a dash is correct where the agent is silent. + Flow { + visible: vm.state === "running" + width: parent.width + spacing: 20 + + Stat { + label: "CPU" + value: (vm.cpu ?? -1) < 0 ? "—" : (vm.cpu).toFixed(0) + "%" + fraction: (vm.cpu ?? 0) / 100 + } + Stat { + label: "RAM" + value: (vm.memUsed ?? -1) < 0 ? "—" + : page.fmtBytes(vm.memUsed) + " / " + page.fmtBytes(vm.memTotal) + fraction: (vm.memUsed ?? -1) < 0 ? -1 : vm.memUsed / vm.memTotal + } + Stat { + label: "Disk" + value: (vm.fsUsed ?? -1) < 0 ? "—" + : page.fmtBytes(vm.fsUsed) + " / " + page.fmtBytes(vm.fsTotal) + fraction: (vm.fsUsed ?? -1) < 0 ? -1 : vm.fsUsed / vm.fsTotal + } + Stat { + label: "Address" + value: (vm.ip ?? "") === "" ? "—" : vm.ip + fraction: -1 + } + } + + // Actions and snapshots, for the selected VM only. + Loader { + active: isSel + width: parent.width + sourceComponent: detail + property string vmName: modelData + property string vmState: vm.state ?? "" + property bool vmSaved: vm.saved ?? false + } + } + } + } + + Component { + id: detail + + Column { + spacing: 12 + + readonly property string vmName: parent.vmName + readonly property string vmState: parent.vmState + readonly property bool vmSaved: parent.vmSaved + + Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.08) } + + // Confirm step replaces the buttons, so the action cannot be + // clicked again while it is being confirmed. + Loader { + active: page.confirming !== null && page.confirming.vm === vmName + width: parent.width + sourceComponent: confirmUi + } + + Flow { + visible: !(page.confirming !== null && page.confirming.vm === vmName) + width: parent.width + spacing: 8 + + Repeater { + model: page.actionsFor(vmState, vmSaved) + Button { + required property var modelData + text: modelData[1] + danger: page.isDestructive(modelData[0]) + onClicked: page.run(vmName, modelData[0]) + } + } + Button { + text: "Snapshot" + onClicked: Virsh.snapshotCreate(vmName) + } + Button { + text: "Delete VM" + danger: true + onClicked: page.confirming = { kind: "delete", vm: vmName, snap: "" } + } + } + + Text { + visible: (Virsh.snapshots[vmName] ?? []).length > 0 + text: "Snapshots" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + color: Theme.subtext + } + + Repeater { + model: Virsh.snapshots[vmName] ?? [] + + Column { + required property var modelData + width: parent.width + spacing: 4 + + Text { + width: parent.width + elide: Text.ElideRight + text: modelData.name + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: Theme.text + } + + Row { + width: parent.width + spacing: 10 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: modelData.created + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + color: Theme.overlay + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: modelData.state + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + color: Theme.overlay + } + Button { + text: "Revert" + danger: true + onClicked: page.confirming = { kind: "revert", vm: vmName, snap: modelData.name } + } + Button { + text: "Delete" + danger: true + onClicked: page.confirming = { kind: "snapdelete", vm: vmName, snap: modelData.name } + } + } + } + } + } + } + + Component { + id: confirmUi + + Column { + spacing: 10 + readonly property var c: page.confirming + // Deleting a VM erases its disk image, so that one asks for the + // name to be typed. The rest are recoverable enough for a click. + readonly property bool needsTyping: c && c.kind === "delete" + + Text { + width: parent.width + wrapMode: Text.Wrap + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1 } + color: Theme.red + text: { + if (!c) return ""; + if (c.kind === "delete") return `Delete ${c.vm}? This erases its disk image and cannot be undone.`; + if (c.kind === "revert") return `Revert ${c.vm} to "${c.snap}"? Changes since that snapshot are lost.`; + if (c.kind === "snapdelete") return `Delete snapshot "${c.snap}"?`; + if (c.kind === "destroy") return `Force stop ${c.vm}? This is a power cut, not a shutdown.`; + if (c.kind === "discardsave") return `Discard the saved state of ${c.vm}? Its memory image is deleted and the next start boots cold. The disk is untouched.`; + if (c.kind === "reset") return `Reset ${c.vm}? This is a hard reset, not a reboot.`; + return ""; + } + } + + TextInput { + id: nameField + visible: needsTyping + width: 260 + text: page.typed + onTextChanged: page.typed = text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1 } + color: Theme.text + focus: needsTyping + Component.onCompleted: if (needsTyping) forceActiveFocus() + + Rectangle { + anchors.fill: parent + anchors.margins: -6 + z: -1 + radius: 6 + color: Qt.alpha(Theme.surface, 0.8) + border.width: 1 + border.color: Qt.alpha(Theme.text, 0.15) + } + Text { + visible: !nameField.text + text: "type the VM name" + font: nameField.font + color: Theme.overlay + } + } + + Row { + spacing: 8 + Button { + text: "Confirm" + danger: true + enabled: !needsTyping || page.typed === c.vm + onClicked: { + if (c.kind === "delete") Virsh.deleteVm(c.vm); + else if (c.kind === "revert") Virsh.snapshotRevert(c.vm, c.snap); + else if (c.kind === "snapdelete") Virsh.snapshotDelete(c.vm, c.snap); + else Virsh.act(c.vm, c.kind); + page.confirming = null; + page.typed = ""; + } + } + Button { + text: "Cancel" + onClicked: { page.confirming = null; page.typed = ""; } + } + } + } + } +} +``` + +Note the stat row changed from `Row` to `Flow`: four stats at 150px bars do not +fit in a 600px drawer as one row, where they did in the old full-width panel. + +- [ ] **Step 4: Write the tile and the module** + +The tile shows one dot per VM. `Virsh` runs `virsh event --all --loop` +unconditionally and refreshes the list on every lifecycle event, so +`Virsh.names` and each VM's state are current even while `sampling` is false; +what `sampling` gates is only the 2s stats poll. + +`desktop/modules/vm/VmTile.qml`: + +```qml +// 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 QtQuick + +// One dot per VM, green for running. The state behind these comes from the +// lifecycle event stream, which runs whether or not the page is open, so the +// dots are current without the stats poll. +Row { + spacing: 4 + + Repeater { + model: Virsh.names + + Rectangle { + required property string modelData + readonly property string state: Virsh.vms[modelData]?.state ?? "" + + anchors.verticalCenter: parent.verticalCenter + width: 7; height: 7; radius: 4 + color: state === "running" ? Theme.green + : state === "paused" || state === "suspended" ? Theme.yellow + : Theme.overlay + } + } + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: Virsh.names.length === 0 + text: "no VMs" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.subtext + } +} +``` + +`desktop/modules/vm/VmModule.qml`: + +```qml +// 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 QtQuick + +// Not always active: the 2s stats poll exists only to paint a page nobody is +// looking at, so the page starts and stops it. The lifecycle event stream in +// Virsh is separate and always runs, which is what keeps the tile's dots and +// the VM list correct without polling. +Module { + id: mod + + name: "vm" + icon: "" + label: "Machines" + alwaysActive: false + + tileContent: Component { VmTile {} } + + page: Component { + Page { + title: "Virtual machines" + VmPage { width: parent.width } + } + } +} +``` + +- [ ] **Step 5: Keep the failure notification** + +`vm-manager/shell.qml` turned `Virsh.actionFailed` into a `notify-send`. That +belongs in the module now. Add to `VmModule.qml`, inside the `Module` block: + +```qml + // An action that fails (libvirt refusing, a disk in use) has to say so: + // the notification is the only feedback, since virsh output goes nowhere. + property Process notifyProc: Process {} + + property Connections conn: Connections { + target: Virsh + function onActionFailed(vm, action, message) { + mod.notifyProc.command = ["notify-send", "--app-name=vm-manager", + "--urgency=critical", "--icon=error", + `${action} failed: ${vm}`, message]; + mod.notifyProc.running = false; + mod.notifyProc.running = true; + } + } +``` + +and add `import Quickshell.Io` at the top of the file. + +- [ ] **Step 6: Register it and delete the old component** + +Add `import "modules/vm"` to `desktop/shell.qml` and put `VmModule {}` in the +`modules` list, after mail. + +```bash +git rm vm-manager/VmPanel.qml vm-manager/shell.qml vm-manager/Theme.qml +git mv vm-manager/README.md desktop/modules/vm/README.md +rmdir vm-manager +``` + +- [ ] **Step 7: Verify** + +```bash +pkill -x qs +pgrep -cx qs +``` + +Expected: `0`. + +```bash +timeout 20 qs -p desktop 2>&1 | head -30 +``` + +Expected: `Configuration Loaded`, no QML errors. Ask the user to: +1. Confirm the grid shows four tiles, with the VM tile showing a dot per VM. +2. Run `qs -p desktop ipc call drawer open vm` and confirm the VM rows, stats + and snapshot list render, and that the back arrow returns to the grid. +3. Confirm the stats update while the page is open and that the tile dots are + still right after closing it. + +`Virsh.qml` moved unchanged, so the two libvirt findings it encodes should +still hold. Confirm they survived the move rather than assuming it: + +```bash +grep -c 'Managed save' desktop/modules/vm/Virsh.qml +grep -c 'balloon\|block.allocation\|guest-get-fsinfo' desktop/modules/vm/Virsh.qml +``` + +Expected: `1` and at least `1`. The first is the managed-save detection, which +`domstats` cannot report and which makes `virsh start` fail every time on an +affected VM; the second is the guest-agent path that exists because libvirt's +own memory and disk figures measure something else. If either reads `0`, the +file was edited when it should have been moved verbatim. + +A VM carrying a managed save shows "Discard saved state" among its actions; if +the user has one, that button appearing is the end-to-end check. + +- [ ] **Step 8: Commit** + +```bash +git add -A desktop vm-manager +git commit -m "feat(desktop): move vm-manager in as the vm module + +Virsh.qml is unchanged; VmPanel's body becomes the page and the stat row +becomes a Flow, because four stats with 150px bars do not fit a 600px +drawer as one row. The page starts and stops Virsh.sampling, so the 2s +poll runs only while something is looking at it; the lifecycle event +stream still runs always, which is what keeps the tile's dots current." +``` + +--- + +## Task 9: Documentation + +**Files:** +- Create: `desktop/README.md` +- Modify: `README.md` +- Modify: `AGENTS.md` + +- [ ] **Step 1: Write the component README** + +`desktop/README.md` covers what the drawer is, how a module is written, and the +outside-repo configuration. The three moved READMEs stay where Tasks 6-8 put +them, under their module directories, and this one links to them. + +Write it with these sections: + +```markdown +# desktop + +One drawer, left of DP-1, hosting the things a desktop lets you adjust. +Reached from a launcher at the left end of waybar. + +## Running it + + qs -p ./desktop + + qs -p ./desktop ipc call drawer open # the grid + qs -p ./desktop ipc call drawer open mail # straight to a page + +## The modules + + modules/sound/ output and input volume, the OSD, the player + modules/mail/ unread per account, threads, the watcher dot + modules/vm/ libvirt state, live stats, snapshots + modules/appearance/ a tile that opens the separate appearance shell + +Each has its own README. + +## Writing a module + +[Describe Module.qml's properties: name, icon, label, alwaysActive, +tileContent, page, activate(). Explain that a module provides a tile, a +page, both or neither, and that alwaysActive governs the service rather +than the page. Show the appearance module as the smallest complete +example, since it is nine lines of substance.] + +## alwaysActive + +[Explain why sound and mail are true and vm is false: the OSD must answer +a keypress with no drawer open, the unread count outlives the drawer, and +vm's stats poll only paints a page nobody is looking at. Note that vm's +lifecycle event stream still runs always, so the property governs the +poll, not everything the module does.] + +## Geometry + +[600px, left of DP-1 because conky holds the right, ExclusionMode.Normal +so waybar stays visible and clickable, the reserved notification area at +the top, the fixed 3x3 tile grid at the bottom.] + +## Hyprland and waybar + +[The blur rule, the launcher module, the deep-link click targets. Refer +to the config table in the plan's final task.] + +## Theme + +[One sentence: Theme.qml is a symlink to shared/Theme.qml, as in every +component here.] +``` + +Fill each bracketed section with real prose; the brackets are instructions to +you, not content to keep. + +- [ ] **Step 2: Update the top-level README** + +In `README.md`, replace the "Implementations" block with: + +``` + desktop/ the drawer: sound, mail, VMs, appearance + appearance/ wallpaper picker and colour scheme switcher + window-switcher/ open windows as live previews in a grid, on ALT+TAB +``` + +and adjust the paragraph above it, which currently says the components are +"separate shells, not modules of a single bar". That is still true of the three +directories, but `desktop/` is itself a host for modules, so say so: three +shells, one of which hosts modules. + +Update the `qs -p ./volume-osd` example to `qs -p ./desktop`. + +- [ ] **Step 3: Update AGENTS.md** + +Two sections are now wrong: + +- The component list at the top still names five directories. +- The "Theme" section says there is one `Theme.qml` "symlinked five ways"; it + is now three. + +Add to the per-component notes, since both cost time to rediscover: + +```markdown +- **A singleton outside the config root needs a `qmldir` entry.** The five + original components each kept their singletons beside `shell.qml`, where + quickshell finds them. Moving one into `modules/<name>/` makes it invisible + until `qmldir` names it. +- **`Virsh.sampling` gates the 2s stats poll, not the whole service.** The + lifecycle event stream runs unconditionally, which is what keeps the VM list + and the tile's dots current while the page is closed. +``` + +- [ ] **Step 4: Verify the docs match the code** + +```bash +ls desktop/modules/ +grep -c 'volume-osd\|mail-overview\|vm-manager' README.md AGENTS.md +``` + +Expected: the four module directories listed, and the grep reporting `0` for +`README.md`. `AGENTS.md` legitimately still mentions the old names in its +historical notes, so read its matches rather than requiring zero. + +- [ ] **Step 5: Commit** + +```bash +git add README.md AGENTS.md desktop/README.md +git commit -m "docs: the desktop shell and what the merge changed + +Five components become three, and the Theme symlink count with them. Two +new notes: a singleton outside the config root is invisible without a +qmldir entry, and Virsh.sampling gates only the stats poll, not the +lifecycle stream that keeps the tile correct while the page is closed." +``` + +--- + +## Task 10: The configuration outside this repo + +These six edits are in the user's live Hyprland and waybar configuration, not +in this repository. **Do not apply them without asking.** Present the list, make +the edits the user approves, and let the user restart the session. + +- [ ] **Step 1: Show the user what needs changing** + +| file | change | +|---|---| +| `~/.config/hypr/sections/autostart.lua` | five `qs` lines to three; `mail-notify.sh` path | +| `~/.config/hypr/sections/keybindings.lua` | `SUPER+v` to the drawer deep-link | +| `~/.config/hypr/sections/decorations.lua` | add `blur-desktop`; drop `blur-mail`, `blur-vm-manager`; keep `blur-volume-osd` | +| `~/.config/waybar/config.jsonc` | add the launcher at the left end | +| `~/.config/waybar/modules/custom/mail.jsonc` | `exec` path and `on-click` | +| `~/.config/waybar/modules/custom/launcher.jsonc` | new file | + +- [ ] **Step 2: autostart.lua** + +Replace the five `qs` lines with: + +```lua + hl.exec_cmd("qs -p ~/Programming/GIT/quickshell/desktop") + hl.exec_cmd("qs -p ~/Programming/GIT/quickshell/appearance") + hl.exec_cmd("qs -p ~/Programming/GIT/quickshell/window-switcher") +``` + +and change the notifier line to its new path: + +```lua + hl.exec_cmd("~/Programming/GIT/quickshell/desktop/modules/mail/mail-notify.sh") +``` + +- [ ] **Step 3: keybindings.lua** + +Replace the `SUPER + v` bind: + +```lua +hl.bind(mainMod .. " + v", hl.dsp.exec_cmd("qs -p ~/Programming/GIT/quickshell/desktop ipc call drawer open vm")) +``` + +ALT+TAB and `SUPER+Return` are unchanged: both still point at shells that still +exist. + +- [ ] **Step 4: decorations.lua** + +Delete the `blur-mail` and `blur-vm-manager` rules, whose namespaces no longer +exist. Keep `blur-volume-osd`, which the OSD still uses. Add: + +```lua +-- Frosted glass for the quickshell desktop drawer. +hl.layer_rule({ + name = "blur-desktop", + match = { namespace = "^(quickshell-desktop)$" }, + blur = true, + xray = false, + ignore_alpha = 0.1, +}) +``` + +- [ ] **Step 5: The waybar launcher** + +Create `~/.config/waybar/modules/custom/launcher.jsonc`: + +```jsonc +{ + // Opens the quickshell desktop drawer. A static button: no "exec", so it + // cannot show whether the drawer is open, which would need the shell to + // feed waybar. + "custom/launcher": { + "format": "<span font='18px'></span>", + "tooltip": false, + "on-click": "qs -p ~/Programming/GIT/quickshell/desktop ipc call drawer toggle" + } +} +``` + +The glyph between the `span` tags must be the Slackware icon from the user's +Nerd Font; ask the user which codepoint they want rather than guessing. + +In `~/.config/waybar/config.jsonc`, add the include and put the module first in +`modules-left`: + +```jsonc + "~/.config/waybar/modules/custom/launcher.jsonc", +``` + +```jsonc + "modules-left": [ + "custom/launcher", + "clock#date", +``` + +- [ ] **Step 6: The mail module's paths** + +In `~/.config/waybar/modules/custom/mail.jsonc`: + +```jsonc + "exec": "~/Programming/GIT/quickshell/desktop/modules/mail/waybar-mail.sh", + "on-click": "qs -p ~/Programming/GIT/quickshell/desktop ipc call drawer open mail", +``` + +- [ ] **Step 7: Apply and restart** + +```bash +hyprctl reload +``` + +That picks up the binds and the layer rules. It does not start processes: +`hl.exec_cmd` is exec-once, so the three-shell autostart takes effect at the +next login. Have the user either log out and back in, or start the shells +manually for this session. + +Restart waybar for the launcher and the mail module's new paths: + +```bash +pkill -x waybar && waybar & +``` + +- [ ] **Step 8: Confirm the end state** + +After the user has logged back in: + +```bash +pgrep -ax qs +``` + +Expected: exactly three, running `desktop`, `appearance` and `window-switcher`. + +Ask the user to confirm: the launcher opens the drawer, `SUPER+v` lands on the +VM page, the waybar mail count still updates and its click opens the mail page, +the volume OSD still appears on a keypress with the drawer closed, and the +drawer is frosted rather than flat. + +--- + +## Notes for whoever executes this + +**Commit after every task.** Each task leaves the repo working; several delete +a component, and a half-finished deletion is painful to unpick. + +**The three deleted components stay in git history.** If a page turns out to +have lost something, `git show HEAD~n:vm-manager/VmPanel.qml` has the original. + +**Glyphs.** Several files carry Nerd Font private-use characters. This plan +writes them as `\uXXXX` escapes because they do not survive copying through a +document. When moving a file, take the real bytes from the original with +`git mv` or `git show`, and check `git diff` reports no change to them. Where a +new file needs a glyph, the escape form is correct and renders identically. + +**What the spec deliberately leaves out.** Notifications, DND, breaktimer, +wifi, bluetooth and kdeconnect are later projects. The reserved area at the top +of the drawer is the only accommodation this project makes for the first of +them. Resist filling it. |
