diff options
| -rw-r--r-- | docs/superpowers/plans/2026-10-13-controls-panel.md | 1063 |
1 files changed, 1063 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-13-controls-panel.md b/docs/superpowers/plans/2026-10-13-controls-panel.md new file mode 100644 index 0000000..6c2e152 --- /dev/null +++ b/docs/superpowers/plans/2026-10-13-controls-panel.md @@ -0,0 +1,1063 @@ +# Controls Panel 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:** A grid of seven toggles in the island's status pill, with an edit mode, backed by a JSON preferences file, opened by a bare SUPER release. + +**Architecture:** Pure helpers in `desktop/island/Controls.js` (tile table, order operations, fill, drop slot) tested under node. `desktop/Prefs.qml` is a singleton over one JSON file. `ControlTile.qml` binds one tile's live state; `ControlsPanel.qml` lays them out and owns edit mode. The status pill hosts the panel in its capsule exactly as it hosts the sound panel, and a new `island` IPC target opens it. + +**Tech Stack:** Quickshell 0.3.1 QML (`Quickshell.Io` FileView, `Quickshell.Networking`, `Quickshell.Bluetooth`, PipeWire via the sound module's Service), node for the pure tests, Hyprland 0.56.2 Lua config. + +**Spec:** `docs/superpowers/specs/2026-10-13-controls-panel-design.md` + +--- + +## Read first + +- `AGENTS.md`, sections "The rule that bites first", "Verifying, and how not to", "Reloading". In short: the process is `qs`, never `quickshell`; never `pkill -f`; never start a detached `qs` from a tool call; hot reload does not pick up a new file until a file importing that directory changes. +- `desktop/island/Island.qml`, `StatusPill.qml`, `SoundPanel.qml`: the panel mechanism this copies. +- Icons are freedesktop names through `Quickshell.iconPath(name, true)`, the island's rule (island spec, "Icons"). Every name used below was checked to resolve in the active theme (candy-icons, inheriting breeze-dark). + +Every new source file starts with the GPLv2 header used in the repo: + +``` +// 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. +``` + +The code blocks below write it as `// <GPLv2 header>`; paste the full header there. + +## Files + +| File | Action | Responsibility | +| --- | --- | --- | +| `docs/superpowers/specs/2026-10-13-controls-panel-design.md` | modify | two corrections found while planning | +| `desktop/island/Controls.js` | create | tile table, icons, order helpers, fill, volume step, drop slot | +| `desktop/island/test_controls.js` | create | node test for Controls.js | +| `desktop/Prefs.qml` | create | singleton over `$XDG_STATE_HOME/quickshell/desktop.json` | +| `desktop/island/ControlTile.qml` | create | one tile: live state, click, right-click, scroll, fill | +| `desktop/island/ControlsPanel.qml` | create | header, grid, edit mode, hidden strip | +| `desktop/island/StatusPill.qml` | modify | opener icon, hosts ControlsPanel | +| `desktop/island/Island.qml` | modify | `"controls"` panel, `toggleControls()`, drawer page link | +| `desktop/shell.qml` | modify | `island` IPC target | +| `~/.config/hypr/sections/keybindings.lua` | modify (live config, not in repo) | SUPER release bind | +| `desktop/README.md`, `AGENTS.md` | modify | document the panel and what the build learned | + +--- + +### Task 1: Spec corrections + +Two things in the spec were settled differently while planning: + +1. Icons: the spec says Nerd Font glyphs; the island's rule is named icons through the icon theme. +2. Drag: the spec says `DragHandler` + `DropArea`; one `MouseArea` per tile plus a pure `slotAt()` is less code and testable. + +**Files:** +- Modify: `docs/superpowers/specs/2026-10-13-controls-panel-design.md` + +- [ ] **Step 1: Replace the glyph line** + +Replace: + +``` +Glyphs must exist in Inconsolata Nerd Font and be checked in its cmap. +``` + +with: + +``` +Icons are freedesktop names through `Quickshell.iconPath(name, true)`, the +island's rule, so an icon theme switch reaches the panel. Every name was +checked to resolve in candy-icons (which inherits breeze-dark): wifi +`network-wireless` / `network-wireless-disconnected`, bluetooth +`preferences-system-bluetooth`, DND `notifications` / +`notifications-disabled`, presentation `video-display`, auto-lock +`system-lock-screen`, output `Logic.volumeIcon`, mic +`audio-input-microphone` / `microphone-sensitivity-muted`, the opener +`preferences-system`, edit `document-edit`, hide `window-close`, show +`list-add`. +``` + +- [ ] **Step 2: Replace the drag bullet** + +Replace: + +``` +- a tile dragged onto another moves to that position (`DragHandler` on the + tile, `DropArea` on each slot); +``` + +with: + +``` +- a tile dragged onto another moves to that position: one `MouseArea` per + tile offsets it while pressed, and on release the slot under its centre + (`slotAt()` in `Controls.js`) is where it lands; +``` + +and add `slotAt(x, y, pitch, cols, count)` and `step(volume, delta, max)` to the list of pure helpers in the same section. + +- [ ] **Step 3: Commit** + +```bash +git add docs/superpowers/specs/2026-10-13-controls-panel-design.md +git commit -m "docs(spec): controls panel uses named icons and a plain drag + +Planning found two places where the spec diverged from the island it +extends. Icons go through the icon theme like every other island icon, +and drag is one MouseArea per tile with a pure drop-slot helper, which +is less code than DragHandler and DropArea and can be tested under node. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 2: Controls.js and its test + +**Files:** +- Create: `desktop/island/Controls.js` +- Create: `desktop/island/test_controls.js` + +- [ ] **Step 1: Write the failing test** + +`desktop/island/test_controls.js`: + +```js +// <GPLv2 header> + +// Run: node desktop/island/test_controls.js +// Controls.js is a QML library, so its .pragma line is stripped before node +// evaluates it, as test_logic.js does for Logic.js. + +const fs = require("fs"); +const assert = require("assert"); + +const src = fs.readFileSync(__dirname + "/Controls.js", "utf8") + .replace(/^\.pragma library$/m, ""); +const C = new Function(src + + "\nreturn { defaults, tile, icon, normalize, hidden, move, hide, show, fill, step, slotAt };")(); + +const ALL = ["wifi", "bluetooth", "dnd", "presentation", "nolock", "output", "mic"]; + +// Defaults: all seven, a fresh copy each time. +assert.deepStrictEqual(C.defaults(), ALL); +const d = C.defaults(); d.pop(); +assert.deepStrictEqual(C.defaults(), ALL); + +// Table: label and drawer page; unknown is null. +assert.strictEqual(C.tile("wifi").page, "network"); +assert.strictEqual(C.tile("nolock").label, "Auto-lock"); +assert.strictEqual(C.tile("mic").page, "sound"); +assert.strictEqual(C.tile("nope"), null); + +// Icons by state. Output is Logic.volumeIcon's job, so "" here. +assert.strictEqual(C.icon("wifi", true), "network-wireless"); +assert.strictEqual(C.icon("wifi", false), "network-wireless-disconnected"); +assert.strictEqual(C.icon("dnd", true), "notifications-disabled"); +assert.strictEqual(C.icon("dnd", false), "notifications"); +assert.strictEqual(C.icon("mic", true), "audio-input-microphone"); +assert.strictEqual(C.icon("mic", false), "microphone-sensitivity-muted"); +assert.strictEqual(C.icon("bluetooth", false), "preferences-system-bluetooth"); +assert.strictEqual(C.icon("output", true), ""); + +// normalize: drops unknown ids and duplicates, keeps order; an empty list +// is valid (everything hidden); a non-array is the default. +assert.deepStrictEqual(C.normalize(["mic", "gone", "wifi", "mic"]), ["mic", "wifi"]); +assert.deepStrictEqual(C.normalize([]), []); +assert.deepStrictEqual(C.normalize("wifi"), ALL); +assert.deepStrictEqual(C.normalize(undefined), ALL); + +// hidden: known ids not in the list, in table order. +assert.deepStrictEqual(C.hidden(["wifi", "mic"]), ["bluetooth", "dnd", "presentation", "nolock", "output"]); +assert.deepStrictEqual(C.hidden(ALL), []); + +// move: from index to index, input untouched. +const src3 = ["a", "b", "c"]; +assert.deepStrictEqual(C.move(src3, 0, 2), ["b", "c", "a"]); +assert.deepStrictEqual(C.move(src3, 2, 0), ["c", "a", "b"]); +assert.deepStrictEqual(C.move(src3, 1, 1), ["a", "b", "c"]); +assert.deepStrictEqual(src3, ["a", "b", "c"]); + +// hide / show. +assert.deepStrictEqual(C.hide(["wifi", "mic"], "wifi"), ["mic"]); +assert.deepStrictEqual(C.show(["mic"], "wifi"), ["mic", "wifi"]); +assert.deepStrictEqual(C.show(["mic"], "mic"), ["mic"]); +assert.deepStrictEqual(C.show(["mic"], "nope"), ["mic"]); + +// fill: volume clamped to 0..1; unpopulated reads as empty, not full. +assert.strictEqual(C.fill(0.4), 0.4); +assert.strictEqual(C.fill(1.5), 1); +assert.strictEqual(C.fill(-1), 0); +assert.strictEqual(C.fill(undefined), 0); +assert.strictEqual(C.fill(NaN), 0); + +// step: 5% steps, rounded to the percent, clamped to 0..max. +assert.strictEqual(C.step(0.5, 0.05, 1.5), 0.55); +assert.strictEqual(C.step(0.02, -0.05, 1.5), 0); +assert.strictEqual(C.step(1.48, 0.05, 1.5), 1.5); +assert.strictEqual(C.step(0.98, 0.05, 1.0), 1); +assert.strictEqual(C.step(0.1 + 0.2, 0.05, 1), 0.35); +assert.strictEqual(C.step(undefined, 0.05, 1), 0.05); + +// slotAt: pitch 70, 4 columns, 7 tiles. +assert.strictEqual(C.slotAt(35, 35, 70, 4, 7), 0); +assert.strictEqual(C.slotAt(175, 35, 70, 4, 7), 2); +assert.strictEqual(C.slotAt(105, 105, 70, 4, 7), 5); +assert.strictEqual(C.slotAt(-50, -50, 70, 4, 7), 0); // off the top left +assert.strictEqual(C.slotAt(500, 35, 70, 4, 7), 3); // past the right edge +assert.strictEqual(C.slotAt(275, 105, 70, 4, 7), 6); // empty 8th slot -> last +assert.strictEqual(C.slotAt(35, 900, 70, 4, 7), 6); // below the grid -> last + +console.log("test_controls: ok"); +``` + +- [ ] **Step 2: Run it to see it fail** + +Run: `node desktop/island/test_controls.js` +Expected: FAIL, `ENOENT: no such file or directory, open '.../Controls.js'` + +- [ ] **Step 3: Write Controls.js** + +`desktop/island/Controls.js`: + +```js +.pragma library + +// <GPLv2 header> + +// The controls panel's tiles and the pure operations on their order. The +// order itself lives in Prefs.qml; everything here takes a list and returns +// a new one, so it runs under node (test_controls.js). + +// Table order is the default order and the order of the hidden strip. +const TILES = { + wifi: { label: "Wi-Fi", page: "network" }, + bluetooth: { label: "Bluetooth", page: "bluetooth" }, + dnd: { label: "DND", page: "status" }, + presentation: { label: "Presentation", page: "status" }, + nolock: { label: "Auto-lock", page: "status" }, + output: { label: "Output", page: "sound" }, + mic: { label: "Mic", page: "sound" }, +}; + +function defaults() { + return Object.keys(TILES); +} + +function tile(id) { + return TILES[id] || null; +} + +// Icon name for a tile's state. `on` is the tile's lit state: for the mic +// that is "not muted". Output is "" because Logic.volumeIcon owns it. +function icon(id, on) { + switch (id) { + case "wifi": return on ? "network-wireless" : "network-wireless-disconnected"; + case "bluetooth": return "preferences-system-bluetooth"; + case "dnd": return on ? "notifications-disabled" : "notifications"; + case "presentation": return "video-display"; + case "nolock": return "system-lock-screen"; + case "mic": return on ? "audio-input-microphone" : "microphone-sensitivity-muted"; + default: return ""; + } +} + +// A stored list made safe: unknown ids (a tile removed from the code) and +// duplicates dropped. An empty list is a valid choice; a non-list is not. +function normalize(list) { + if (!Array.isArray(list)) return defaults(); + const out = []; + for (let i = 0; i < list.length; i++) { + const id = list[i]; + if (TILES[id] && out.indexOf(id) < 0) out.push(id); + } + return out; +} + +function hidden(list) { + return defaults().filter(id => list.indexOf(id) < 0); +} + +function move(list, from, to) { + const out = list.slice(); + const id = out.splice(from, 1)[0]; + out.splice(to, 0, id); + return out; +} + +function hide(list, id) { + return list.filter(x => x !== id); +} + +function show(list, id) { + if (!TILES[id] || list.indexOf(id) >= 0) return list.slice(); + return list.concat([id]); +} + +// Audio tile bar width as a fraction. Output goes to 1.5, the bar stops at +// 1. !(> 0) also catches a volume not populated yet. +function fill(volume) { + if (!(volume > 0)) return 0; + return Math.min(1, volume); +} + +// One scroll step, rounded to the percent so repeated steps do not drift. +function step(volume, delta, max) { + const v = Math.round(((volume > 0 ? volume : 0) + delta) * 100) / 100; + return Math.max(0, Math.min(max, v)); +} + +// The slot a dragged tile lands in, from its centre in grid coordinates. +// Off the grid clamps to the nearest edge; past the last tile is the last. +function slotAt(x, y, pitch, cols, count) { + const col = Math.max(0, Math.min(cols - 1, Math.floor(x / pitch))); + const row = Math.max(0, Math.floor(y / pitch)); + return Math.max(0, Math.min(count - 1, row * cols + col)); +} +``` + +- [ ] **Step 4: Run the test** + +Run: `node desktop/island/test_controls.js` +Expected: `test_controls: ok` + +Also run `node desktop/island/test_logic.js` to confirm nothing else moved. + +- [ ] **Step 5: Commit** + +```bash +git add desktop/island/Controls.js desktop/island/test_controls.js +git commit -m "feat(island): pure tile table and order helpers for controls + +The controls panel's tiles, their icons and drawer pages, and every +operation on the visible list: normalize, hide, show, move, the drop +slot under a dragged tile, the audio fill and the scroll step. All pure, +so the edit mode's logic is tested under node rather than by dragging. + +normalize keeps an empty list: hiding every tile is a choice, and only +a value that is not a list falls back to the default. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 3: Prefs.qml + +**Files:** +- Create: `desktop/Prefs.qml` + +Facts this relies on, checked while planning: `FileView.setText` creates missing parent directories; `Quickshell.stateDir` is a per-shell hashed directory, which is why the path is built from `XDG_STATE_HOME` instead. The FileView options mirror `shared/Status.qml`'s `ModeFile`. + +- [ ] **Step 1: Write the singleton** + +`desktop/Prefs.qml`: + +```qml +// <GPLv2 header> + +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick +import "island/Controls.js" as Controls + +// Preferences that must survive a reboot, in one JSON file under the state +// home. Only the shell writes it, which is why it is not in ~/.config. +// +// Nothing is written until the user changes something. A file that fails to +// parse reads as the defaults and stays on disk as it is, so a hand edit +// with a typo is not replaced by a panel that was merely opened. +Singleton { + id: root + + readonly property string path: + (Quickshell.env("XDG_STATE_HOME") || Quickshell.env("HOME") + "/.local/state") + + "/quickshell/desktop.json" + + // Visible control tiles, in display order. + property var controls: Controls.defaults() + + // The whole parsed object, so a write keeps keys this version does not + // know about. + property var data: ({}) + + function setControls(list) { + root.controls = Controls.normalize(list); + const out = Object.assign({}, root.data, { controls: root.controls }); + root.data = out; + file.setText(JSON.stringify(out, null, 2) + "\n"); + } + + function reparse() { + let parsed = {}; + try { + const v = JSON.parse(file.text()); + if (v && typeof v === "object" && !Array.isArray(v)) parsed = v; + } catch (e) {} + root.data = parsed; + const next = "controls" in parsed ? Controls.normalize(parsed.controls) : Controls.defaults(); + // The watch also fires on our own write; compare so it is a no-op. + if (JSON.stringify(next) !== JSON.stringify(root.controls)) root.controls = next; + } + + FileView { + id: file + path: root.path + atomicWrites: true + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: root.reparse() + // Missing file: the defaults, and nothing written. + onLoadFailed: { root.data = {}; root.controls = Controls.defaults(); } + } +} +``` + +- [ ] **Step 2: Commit** + +Nothing imports it yet, so there is nothing to run; Task 6 exercises it. + +```bash +git add desktop/Prefs.qml +git commit -m "feat(desktop): preferences file under the state home + +The first preference is the controls panel's visible tiles. The file is +written only on a change and keeps keys it does not know, so a broken or +newer file is never clobbered by a shell that only read it. State home +rather than the runtime dir because it must survive a reboot, rather +than ~/.config because the shell writes it. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 4: ControlTile.qml + +**Files:** +- Create: `desktop/island/ControlTile.qml` + +- [ ] **Step 1: Write the tile** + +`desktop/island/ControlTile.qml`: + +```qml +// <GPLv2 header> + +import Quickshell +import Quickshell.Widgets +import Quickshell.Networking +import Quickshell.Bluetooth +import QtQuick +import ".." +import "Controls.js" as Controls +import "Logic.js" as Logic + +// One control: icon over label. Left-click toggles, right-click opens the +// drawer page that holds the rest, scroll changes volume on the two audio +// tiles. Toggle tiles fill when on; audio tiles fill to their volume. +Rectangle { + id: tile + + required property string tileId + // The sound module's Service. + required property QtObject sound + + signal openPage(string page) + + readonly property var info: Controls.tile(tileId) + readonly property bool isAudio: tileId === "output" || tileId === "mic" + readonly property var audio: tileId === "output" ? (sound.sink?.audio ?? null) + : tileId === "mic" ? (sound.source?.audio ?? null) + : null + readonly property var adapter: Bluetooth.defaultAdapter + // Networking starts empty for about two seconds (AGENTS.md). + readonly property bool wifiReady: + (Networking.devices?.values ?? []).some(d => d.type === DeviceType.Wifi) + + readonly property bool ready: isAudio ? audio !== null + : tileId === "bluetooth" ? adapter !== null + : tileId === "wifi" ? wifiReady + : true + + readonly property bool on: { + switch (tileId) { + case "wifi": return Networking.wifiEnabled; + case "bluetooth": return adapter?.enabled ?? false; + case "dnd": return Status.dnd; + // Lit while a game holds it too; a click then clears only the manual + // half (Status.qml), so the tile stays lit until the game ends. + case "presentation": return Status.presentation; + case "nolock": return !Status.nolock; + default: return audio ? !audio.muted : false; + } + } + + function toggle() { + switch (tileId) { + case "wifi": Networking.wifiEnabled = !Networking.wifiEnabled; break; + case "bluetooth": adapter.enabled = !adapter.enabled; break; + case "dnd": + case "presentation": + case "nolock": Status.toggleMode(tileId); break; + default: audio.muted = !audio.muted; + } + } + + radius: 12 + color: Qt.alpha(Theme.surface, 0.5) + border.width: 1 + border.color: Qt.alpha(Theme.text, 0.08) + opacity: ready ? 1 : 0.4 + clip: true + + // Toggle tiles: lit when on. + Rectangle { + anchors.fill: parent + radius: parent.radius + visible: !tile.isAudio && tile.on + color: Qt.alpha(Theme.accent, 0.35) + } + + // Audio tiles: a bar to the volume. Muted keeps the width in a neutral + // colour, so the level stays readable. Not the accent as the neutral: + // udt can snap it onto a palette colour (AGENTS.md). + Rectangle { + visible: tile.isAudio + anchors { left: parent.left; top: parent.top; bottom: parent.bottom } + width: parent.width * Controls.fill(tile.audio?.volume) + radius: parent.radius + color: tile.audio?.muted ? Qt.alpha(Theme.overlay, 0.6) : Qt.alpha(Theme.accent, 0.35) + } + + Column { + anchors.centerIn: parent + width: parent.width - 8 + spacing: 4 + + IconImage { + anchors.horizontalCenter: parent.horizontalCenter + implicitSize: 22 + source: Quickshell.iconPath(tile.tileId === "output" + ? Logic.volumeIcon(tile.audio?.volume ?? 0, tile.audio?.muted ?? false) + : Controls.icon(tile.tileId, tile.on), true) + } + + Text { + width: parent.width + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + text: tile.info?.label ?? tile.tileId + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.text + } + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + cursorShape: Qt.PointingHandCursor + onClicked: mouse => { + if (mouse.button === Qt.RightButton) tile.openPage(tile.info.page); + else if (tile.ready) tile.toggle(); + } + // One step per wheel notch (120), as the layout flag does. A + // MouseArea, not a WheelHandler: the handler never fired in the + // island (AGENTS.md). + property real acc: 0 + onWheel: wheel => { + wheel.accepted = true; + if (!tile.isAudio || !tile.ready) return; + acc += wheel.angleDelta.y; + if (Math.abs(acc) < 120) return; + const max = tile.tileId === "output" ? 1.5 : 1.0; + tile.audio.volume = Controls.step(tile.audio.volume, acc > 0 ? 0.05 : -0.05, max); + acc = 0; + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add desktop/island/ControlTile.qml +git commit -m "feat(island): a control tile bound to its live state + +Wifi and bluetooth through the Quickshell services, DND, presentation and +auto-lock through the Status registry, output and mic through the sound +module's tracked nodes. A tile whose backing object has not arrived yet +is dimmed and ignores input, which covers the two seconds before +Networking and Bluetooth populate. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 5: ControlsPanel.qml + +**Files:** +- Create: `desktop/island/ControlsPanel.qml` + +- [ ] **Step 1: Write the panel** + +`desktop/island/ControlsPanel.qml`: + +```qml +// <GPLv2 header> + +import Quickshell +import Quickshell.Widgets +import QtQuick +import ".." +import "Controls.js" as Controls + +// The grid of controls and its edit mode. In edit mode a tile is dragged to +// a new slot, a × hides it, and hidden tiles wait in a strip below with a +. +// Every change is written to Prefs at once. +Column { + id: panel + + required property QtObject sound + property bool editing: false + + signal openDrawer(string page) + + spacing: 12 + + Item { + width: panel.width + height: 20 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: panel.editing ? "Edit controls" : "Controls" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + color: Theme.text + } + + IconImage { + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + implicitSize: 16 + opacity: panel.editing ? 1 : 0.6 + source: Quickshell.iconPath("document-edit", true) + + MouseArea { + anchors.fill: parent + anchors.margins: -4 + cursorShape: Qt.PointingHandCursor + onClicked: panel.editing = !panel.editing + } + } + } + + Item { + id: grid + + readonly property int cols: 4 + readonly property real gap: 8 + readonly property real cell: (width - gap * (cols - 1)) / cols + readonly property real pitch: cell + gap + readonly property int rows: Math.ceil(Prefs.controls.length / cols) + + width: panel.width + height: Math.max(0, rows * pitch - gap) + + Repeater { + model: Prefs.controls + + delegate: Item { + id: slot + required property string modelData + required property int index + + width: grid.cell + height: grid.cell + x: (index % grid.cols) * grid.pitch + y: Math.floor(index / grid.cols) * grid.pitch + z: mover.pressed ? 1 : 0 + + ControlTile { + width: parent.width + height: parent.height + x: mover.pressed ? mover.dx : 0 + y: mover.pressed ? mover.dy : 0 + tileId: slot.modelData + sound: panel.sound + onOpenPage: page => panel.openDrawer(page) + } + + // Edit mode only, and above the tile so it takes the press + // instead of the toggle. Offsets the tile while held; on + // release the tile lands in the slot under its centre. + MouseArea { + id: mover + anchors.fill: parent + enabled: panel.editing + preventStealing: true + cursorShape: pressed ? Qt.ClosedHandCursor : Qt.OpenHandCursor + property real sx: 0 + property real sy: 0 + property real dx: 0 + property real dy: 0 + onPressed: mouse => { sx = mouse.x; sy = mouse.y; dx = 0; dy = 0; } + onPositionChanged: mouse => { dx = mouse.x - sx; dy = mouse.y - sy; } + onReleased: { + const to = Controls.slotAt(slot.x + dx + width / 2, slot.y + dy + height / 2, + grid.pitch, grid.cols, Prefs.controls.length); + if (to !== slot.index) + Prefs.setControls(Controls.move(Prefs.controls, slot.index, to)); + } + } + + IconImage { + visible: panel.editing && !mover.pressed + anchors { top: parent.top; right: parent.right; margins: 3 } + implicitSize: 14 + source: Quickshell.iconPath("window-close", true) + + MouseArea { + anchors.fill: parent + anchors.margins: -4 + cursorShape: Qt.PointingHandCursor + onClicked: Prefs.setControls(Controls.hide(Prefs.controls, slot.modelData)) + } + } + } + } + } + + Flow { + width: panel.width + spacing: 6 + visible: panel.editing && Controls.hidden(Prefs.controls).length > 0 + + Repeater { + model: Controls.hidden(Prefs.controls) + + delegate: Rectangle { + id: chip + required property string modelData + width: chipRow.implicitWidth + 16 + height: 28 + radius: 14 + color: Qt.alpha(Theme.surface, 0.5) + + Row { + id: chipRow + anchors.centerIn: parent + spacing: 4 + + IconImage { + anchors.verticalCenter: parent.verticalCenter + implicitSize: 14 + source: Quickshell.iconPath("list-add", true) + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: Controls.tile(chip.modelData).label + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.text + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: Prefs.setControls(Controls.show(Prefs.controls, chip.modelData)) + } + } + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add desktop/island/ControlsPanel.qml +git commit -m "feat(island): controls grid with an edit mode + +Four columns, the visible list from Prefs. Edit mode puts a MouseArea +over each tile that drags it and drops it into the slot under its +centre, a × that hides it, and a strip of hidden tiles with a + each. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 6: Wire it into the island and IPC + +**Files:** +- Modify: `desktop/island/StatusPill.qml` +- Modify: `desktop/island/Island.qml` +- Modify: `desktop/shell.qml` + +- [ ] **Step 1: StatusPill** + +In `desktop/island/StatusPill.qml`: + +Update the top comment's first sentence to: `// Controls icon, volume icon, tray, layout flag. The controls and volume icons open their panels in this capsule; the tray is a plain tray; the flag switches layout on scroll and ignores clicks.` (keep the line wrapping of the file). + +Replace the signals and sizing block: + +```qml + signal openSound() + signal openDrawer() + // Emitted just before a tray menu opens; the menu cannot report its close. + signal menuShown() + + expanded: panel === "sound" + restWidth: restRow.implicitWidth + 24 + panelWidth: 320 + panelHeight: soundPanel.implicitHeight + 28 +``` + +with: + +```qml + signal openSound() + signal openControls() + signal openDrawer() + // A drawer page, from a control tile's right-click. + signal openPage(string page) + // Emitted just before a tray menu opens; the menu cannot report its close. + signal menuShown() + + expanded: panel !== "" + restWidth: restRow.implicitWidth + 24 + panelWidth: 320 + panelHeight: (panel === "controls" ? controlsPanel.implicitHeight : soundPanel.implicitHeight) + 28 + + // Edit mode never outlives its panel. + onPanelChanged: controlsPanel.editing = false +``` + +As the first child of `restRow`, before the volume `IconImage`, add: + +```qml + IconImage { + anchors.verticalCenter: parent.verticalCenter + implicitSize: 18 + source: Quickshell.iconPath("preferences-system", true) + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: pill.openControls() + } + } +``` + +In `SoundPanel { ... }` change `opacity: pill.expanded ? 1 : 0` to `opacity: pill.panel === "sound" ? 1 : 0`. + +After the `SoundPanel` block add: + +```qml + ControlsPanel { + id: controlsPanel + x: 14 + y: 14 + width: pill.panelWidth - 28 + sound: pill.sound + opacity: pill.panel === "controls" ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { NumberAnimation { duration: 160 } } + onOpenDrawer: page => pill.openPage(page) + } +``` + +- [ ] **Step 2: Island** + +In `desktop/island/Island.qml`: + +Change the comment `// "" or "sound".` to `// "", "sound" or "controls".` + +After the `onPanelChanged` block add: + +```qml + // The keybind's way in: reveal, and open or close the controls panel. + // Closing goes through onPanelChanged, which starts the hide timer when + // the pointer is elsewhere. + function toggleControls() { + hideTimer.stop(); + shown = true; + panel = panel === "controls" ? "" : "controls"; + } +``` + +In `StatusPill { ... }` add after `onOpenDrawer`: + +```qml + onOpenControls: win.toggleControls() + onOpenPage: page => { win.panel = ""; win.drawer.show(page); } +``` + +- [ ] **Step 3: shell.qml** + +In `desktop/shell.qml` give the island an id: + +```qml + Island { + id: island + sound: soundModule.service + drawer: drawer + } +``` + +and after the `drawer` `IpcHandler` add: + +```qml + // The bare SUPER release bind reaches this: + // qs -p <this dir> ipc call island controls + IpcHandler { + target: "island" + function controls() { island.toggleControls(); } + } +``` + +Editing `shell.qml` is also what makes the running shell re-index `island/` and `desktop/`, so the new files resolve (AGENTS.md, Reloading). + +- [ ] **Step 4: Check the running shell's log** + +The user's `desktop` shell hot-reloads on save. Do not start another `qs` from a tool call. Read its log: + +Run: `qs log -p ~/Programming/GIT/quickshell/desktop 2>&1 | tail -40` +Expected: a reload line after the edit and no `ReferenceError`, `TypeError`, `is not a type` or `Cannot assign` warnings mentioning `Prefs`, `Controls`, `ControlTile`, `ControlsPanel`, `StatusPill` or `Island`. + +If `qs log` reports no running instance, ask the user to run `./reload.sh desktop` from their terminal, then read the log again. + +Then exercise the IPC path and the store without a screenshot: + +Run: `qs -p ~/Programming/GIT/quickshell/desktop ipc call island controls; sleep 0.5; qs -p ~/Programming/GIT/quickshell/desktop ipc call island controls; qs log -p ~/Programming/GIT/quickshell/desktop 2>&1 | tail -10` +Expected: both calls return with no error, no new warnings in the log. (`sleep` in a single foreground command is fine; a separate tool call cannot hold it.) + +Run: `ls ~/.local/state/quickshell/desktop.json 2>&1` +Expected: `No such file or directory`: opening the panel must not write the file. + +- [ ] **Step 5: Commit** + +```bash +git add desktop/island/StatusPill.qml desktop/island/Island.qml desktop/shell.qml +git commit -m "feat(island): controls panel in the status pill, island IPC + +A settings icon in the status pill opens the controls panel in the same +capsule the sound panel uses. Right-click on a tile closes the panel and +opens its drawer page. The island IPC target reveals the island and +toggles the panel, for the SUPER release bind. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 7: The SUPER release bind + +**Files:** +- Modify: `~/.config/hypr/sections/keybindings.lua` (live config, not in this repo; commit nothing for it here) + +- [ ] **Step 1: Add the bind** + +After the `Desktop: Drawer` bind (the `mainMod .. " + Return"` line) add: + +```lua +-- Bare SUPER, pressed and released alone: the island's controls panel. A key +-- pressed in between cancels a release bind, so SUPER+<key> is unaffected. +hl.bind("SUPER + Super_L", hl.dsp.exec_cmd("qs -p ~/Programming/GIT/quickshell/desktop ipc call island controls"), { release = true, description = "Desktop: Controls" }) +``` + +- [ ] **Step 2: Reload and confirm it registered** + +Run: `hyprctl reload && hyprctl binds -j | python3 -c 'import json,sys; print([b for b in json.load(sys.stdin) if "island controls" in b.get("arg","")])'` +Expected: one entry with `"release": true` and `"key": "Super_L"`. If the list is empty, check `hyprctl configerrors`. + +- [ ] **Step 3: User checks the bind (ask, do not screenshot)** + +Ask the user to try, in order, and report each: + +1. Press and release SUPER alone: panel opens; again: it closes. +2. `SUPER+c` on a window, `SUPER+k`, `SUPER+Return`: none also opens the panel. +3. `SUPER` + left drag to move a window, `SUPER` + right drag to resize. +4. `SUPER` + scroll to change workspace. + +If 3 or 4 opens the panel, replace the bind with: + +```lua +hl.bind(mainMod .. " + q", hl.dsp.exec_cmd("qs -p ~/Programming/GIT/quickshell/desktop ipc call island controls"), { description = "Desktop: Controls" }) +``` + +and `hyprctl reload`. Record which way it went for Task 9. + +--- + +### Task 8: Visual check with the user + +No code. Ask the user to check, and fix anything reported before moving on: + +- The settings icon sits left of the volume icon; clicking it opens a 4×2 grid, seven tiles, labels readable. +- Each toggle flips its state and its fill: wifi, bluetooth, DND, presentation, auto-lock (lit means auto-lock on). +- Output and mic: the bar matches the volume, muted greys it, click mutes, scroll steps 5%. +- Right-click on each tile opens the right drawer page and closes the panel. +- Edit mode: drag a tile to another slot, hide one with ×, bring it back with +, hide all (only the strip remains), leave edit mode with the pencil and with Escape. +- After an edit, `cat ~/.local/state/quickshell/desktop.json` shows the list; `./reload.sh desktop` from the user's terminal keeps the order. + +--- + +### Task 9: Docs + +**Files:** +- Modify: `desktop/README.md` +- Modify: `AGENTS.md` + +- [ ] **Step 1: README** + +In `desktop/README.md`, in the island section, add a paragraph: + +``` +The status pill's settings icon, or SUPER pressed and released alone, opens +the controls panel: wifi, bluetooth, DND, presentation, auto-lock, output and +mic. Click toggles, right-click opens the tile's drawer page, scroll on the +audio tiles changes volume. The pencil enters edit mode: drag to reorder, × +to hide, + to show. The visible tiles are kept in +`$XDG_STATE_HOME/quickshell/desktop.json` (`Prefs.qml`), written only when +they change. Tests: `node island/test_controls.js`. +``` + +Use the bind that Task 7 settled on; if it fell back, say `SUPER+q` instead of "SUPER pressed and released alone". + +- [ ] **Step 2: AGENTS.md** + +Add per-component notes for what the build established. At minimum: + +``` +- **`FileView.setText` creates missing parent directories.** Checked while + planning the preferences file, so `Prefs.qml` has no mkdir step. + `Quickshell.stateDir` is a per-shell hashed directory + (`by-shell/<hash>`), not `~/.local/state/quickshell`, which is why the + path is built from `XDG_STATE_HOME`. +``` + +and one line on the SUPER release bind's outcome from Task 7 (whether SUPER+drag and SUPER+scroll cancelled it on 0.56.2, or forced the fallback). Add any other trap Tasks 6 to 8 hit. Update the `desktop/` line in "What this is" to mention the controls panel. + +- [ ] **Step 3: Commit** + +```bash +git add desktop/README.md AGENTS.md +git commit -m "docs: the controls panel, its preferences file and bind + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` |
