From 39e66ee4b9d001520b53baf31d13269faaf3f263 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 14 Sep 2026 10:37:46 +0200 Subject: docs: implementation plan for the desktop shell Ten tasks: skeleton, contract, chrome, drawer, then the four modules in increasing order of risk, then documentation, then the six config edits outside this repo. Appearance goes first among the modules despite being last in the grid, because it is nine lines of substance and puts something in the grid before the three migrations start. Sound, mail and vm follow in that order: sound splits one 302-line file into three, mail is the gentlest move and carries the only automated oracle in the project, and vm is the largest and the one whose polling behaviour actually changes. Each task leaves the repo working, because several delete a component and a half-finished deletion is painful to unpick. Paths in the config task are written with a tilde: the gitleaks hook rejected the absolute form, correctly, since this file is committed and the live Hyprland config it describes is not. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01We9zcHEH8ZWHPcQwMaJRNv --- docs/superpowers/plans/2026-09-14-desktop-shell.md | 2817 ++++++++++++++++++++ 1 file changed, 2817 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-14-desktop-shell.md (limited to 'docs/superpowers/plans') 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//` 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. +// +// 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. +// +// 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 `. + 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. +// +// 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. +// +// 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. +// +// 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 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` 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. +// +// 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 ipc call drawer open -> the grid + // qs -p 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. +// +// 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. +// +// 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. +// +// 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. +// +// 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. +// +// 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. +// +// 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. +// +// 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. +// +// 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. +// +// 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. +// +// 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. +// +// 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. +// +// 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//` 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": "", + "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. -- cgit v1.2.3