From f73d7cd634c2c453069e0ae43a357e7cf30835c2 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 13 Oct 2026 09:47:03 +0200 Subject: docs(plan): controls panel implementation plan Nine tasks: two spec corrections found while planning, the pure helpers with their node test, the preferences singleton, the tile, the panel, the island and IPC wiring, the SUPER release bind with its fallback, a visual pass with the user, and docs. The plan's test was run against the plan's Controls.js before committing. Co-Authored-By: Claude Opus 5.5 --- .../superpowers/plans/2026-10-13-controls-panel.md | 1063 ++++++++++++++++++++ 1 file changed, 1063 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-13-controls-panel.md (limited to 'docs') 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. +// +// 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 `// `; 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 " +``` + +--- + +### 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 +// + +// 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 + +// + +// 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 " +``` + +--- + +### 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 +// + +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 " +``` + +--- + +### Task 4: ControlTile.qml + +**Files:** +- Create: `desktop/island/ControlTile.qml` + +- [ ] **Step 1: Write the tile** + +`desktop/island/ControlTile.qml`: + +```qml +// + +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 " +``` + +--- + +### Task 5: ControlsPanel.qml + +**Files:** +- Create: `desktop/island/ControlsPanel.qml` + +- [ ] **Step 1: Write the panel** + +`desktop/island/ControlsPanel.qml`: + +```qml +// + +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 " +``` + +--- + +### 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 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 " +``` + +--- + +### 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+ 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/`), 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 " +``` -- cgit v1.2.3