diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-17 13:11:24 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-17 13:11:24 +0200 |
| commit | e39df38860ba10b4611572ec721a8be39cf8bcae (patch) | |
| tree | 04d61c342b34876320ccb2fcf302728e7fdc46a5 | |
| parent | c3bd98ea7f9095d734ce83f6ca138060371c5539 (diff) | |
| download | quickshell-e39df38860ba10b4611572ec721a8be39cf8bcae.tar.gz quickshell-e39df38860ba10b4611572ec721a8be39cf8bcae.zip | |
docs: OBS pill enhancements plan
| -rw-r--r-- | docs/superpowers/plans/2026-10-17-obs-pill-enhancements.md | 343 |
1 files changed, 343 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-17-obs-pill-enhancements.md b/docs/superpowers/plans/2026-10-17-obs-pill-enhancements.md new file mode 100644 index 0000000..bb6ec08 --- /dev/null +++ b/docs/superpowers/plans/2026-10-17-obs-pill-enhancements.md @@ -0,0 +1,343 @@ +# OBS pill enhancements 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:** Add desktop-audio mute and webcam corner placement to the OBS pill, replace the text scene list with a mapped icon grid, and add a settings page that edits the map in a watched `~/.config` file. + +**Architecture:** `Obs.qml` grows the audio and canvas state plus two requests. A pure `SceneIcons.js` parses the map, `SceneIcons.qml` owns the watched file, `ObsPill.qml` renders the grid and the new buttons, and a new `modules/obs` settings page edits the same map through `SceneIcons`'s draft API. + +**Tech Stack:** Quickshell 0.3.1 QML, obs-websocket v5, node (tests). + +**Spec:** `docs/superpowers/specs/2026-10-17-obs-pill-enhancements-design.md` + +## Global Constraints + +- GPLv2 header on every new file (copy from `desktop/island/privacy.sh` / `Privacy.qml`). +- Never start `qs` detached or run `./reload.sh` from a tool call; the user runs the reload in their terminal (AGENTS.md). +- Process name is `qs`; never `pkill -f`. +- Glyphs render with `Theme.iconFamily`. The spec's table gives the JS/JSON escape for each; the `md-*` ones are surrogate pairs, never a five-digit `\u` escape. +- Config path is `$XDG_CONFIG_HOME/quickshell/obs-scene-icons.json`, defaulting to `~/.config/quickshell/obs-scene-icons.json`. +- A glyph field accepts exactly one code point; a supplementary-plane glyph is two UTF-16 units. +- No home paths or personal data in committed files; `~` in docs. + +## Review Focus + +1. A pasted supplementary-plane glyph (U+F02DC) is one code point but two UTF-16 units; it must validate, survive the JSON round-trip, and render. +2. A hand-edited config with a multi-character or non-string value must read as `generic`, never crash or render junk. +3. A missing or unparseable config must yield the starter defaults, not an empty map. +4. An OBS scene with no mapping must show the `generic` tile, never an empty tile. +5. The shared `Button.qml` gains an optional icon; every existing text-only button in the drawer and settings must look identical. + +--- + +### Task 1: Scene icon map logic + +**Files:** +- Create: `desktop/island/SceneIcons.js` +- Create: `desktop/island/test_scene_icons.js` + +**Interfaces:** +- Produces: `SceneIcons.defaults` (`{ generic: string, scenes: { name: string } }`), `validGlyph(s) -> bool`, `validMap(map) -> bool`, `normalize(map) -> map`, `parse(text) -> map`, `serialize(map) -> string`, `iconFor(map, name) -> string`. + +- [ ] **Step 1: Write the failing test** + +Create `desktop/island/test_scene_icons.js`. Strip `.pragma library` the way `test_logic.js` does, then: + +```js +const fs = require("fs"); +const assert = require("assert"); + +const src = fs.readFileSync(__dirname + "/SceneIcons.js", "utf8") + .replace(/^\.pragma library$/m, ""); +const I = new Function(src + + "\nreturn { defaults, validGlyph, validMap, normalize, parse, serialize, iconFor };")(); + +// One code point, even when it is a surrogate pair (U+F02DC, md-home). +assert.strictEqual(I.validGlyph("\u{f02dc}"), true); +assert.strictEqual(I.validGlyph("a"), true); +assert.strictEqual(I.validGlyph(""), false); +assert.strictEqual(I.validGlyph("ab"), false); + +// A missing or unparseable file is the starter map, not an empty one. +assert.deepStrictEqual(I.parse(""), I.defaults); +assert.deepStrictEqual(I.parse("not json"), I.defaults); + +// A valid file is used as-is: no merge with the defaults. +const one = I.parse('{"generic":"\\udb80\\uded7","scenes":{"Desktop":"\\udb80\\udf79"}}'); +assert.deepStrictEqual(one, { generic: "\udb80\uded7", scenes: { Desktop: "\udb80\udf79" } }); +assert.strictEqual(I.iconFor(one, "Desktop"), "\udb80\udf79"); +assert.strictEqual(I.iconFor(one, "Webcam Only"), "\udb80\uded7"); + +// Invalid values are dropped; the generic falls back. +const bad = I.parse('{"generic":"","scenes":{"A":"ab","B":"\\udb80\\udf79"}}'); +assert.deepStrictEqual(bad, { generic: I.defaults.generic, scenes: { B: "\udb80\udf79" } }); + +// A non-string value is dropped, never passed to charCodeAt. +assert.deepStrictEqual(I.parse('{"scenes":{"A":42}}'), + { generic: I.defaults.generic, scenes: {} }); + +// normalize is what Save writes: empty generic -> default, invalid scene dropped. +assert.deepStrictEqual( + I.normalize({ generic: "", scenes: { A: I.defaults.scenes.Desktop, B: "ab" } }), + { generic: I.defaults.generic, scenes: { A: I.defaults.scenes.Desktop } }); + +// Whole-draft validity: an empty generic is allowed (Save substitutes the default). +assert.strictEqual(I.validMap({ generic: "", scenes: { A: "\u{f02dc}" } }), true); +assert.strictEqual(I.validMap({ generic: I.defaults.generic, scenes: { A: "ab" } }), false); + +// Round-trip. +assert.deepStrictEqual(I.parse(I.serialize(one)), one); +assert.ok(I.serialize(one).endsWith("\n")); + +console.log("ok"); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `node desktop/island/test_scene_icons.js` +Expected: FAIL, `ReferenceError: validGlyph is not defined`. + +- [ ] **Step 3: Implement `SceneIcons.js`** + +`pragma library` after the header. Implementation: + +- `defaults`: `generic` = `"\udb80\uded7"` (md-help_circle), `scenes` = Desktop `"\udb80\udf79"`, Webcam Only `"\udb81\udd67"`, Window Only `"\uf2d0"`, Intervallo `"\udb80\udd76"`. +- `validGlyph(s)`: count Unicode code points by walking `charCodeAt`, skipping a low surrogate that follows a high one; true when the count is 1. Empty or non-string is false. +- `normalize(map)`: `{ generic: validGlyph(map.generic) ? map.generic : defaults.generic, scenes: {} }`, keeping scene entries whose value passes `validGlyph`. +- `parse(text)`: `JSON.parse`, and if the result is not a plain object return a deep copy of `defaults`; otherwise return `normalize(v)`. +- `serialize(map)`: `JSON.stringify({ generic, scenes }, null, 2) + "\n"`. +- `iconFor(map, name)`: `map.scenes[name] || map.generic || defaults.generic`. +- `validMap(map)`: false unless every scene value passes `validGlyph`; the generic may be empty, else it must pass. + +- [ ] **Step 4: Run it to verify it passes** + +Run: `node desktop/island/test_scene_icons.js` +Expected: `ok`. + +- [ ] **Step 5: Commit** + +```bash +git add desktop/island/SceneIcons.js desktop/island/test_scene_icons.js +git commit -m "island: scene icon map logic and its tests" +``` + +### Task 2: `SceneIcons.qml` singleton + +**Files:** +- Create: `desktop/island/SceneIcons.qml` + +**Interfaces:** +- Consumes: `SceneIcons.js` (`parse`, `serialize`, `iconFor`, `defaults`). +- Produces: singleton `SceneIcons` with `readonly property string path`, `property var map` (default `Icons.defaults`), `iconFor(name) -> string`, `save(map)`. + +- [ ] **Step 1: Write it** + +`pragma Singleton`, then `import Quickshell`, `import Quickshell.Io`, `import QtQuick`, `import "SceneIcons.js" as Icons`. Contents: + +- `path`: `(Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config") + "/quickshell/obs-scene-icons.json"`. +- `map`: `Icons.defaults` until the file loads. +- `iconFor(name)`: `Icons.iconFor(root.map, name)`. +- `save(map)`: `root.map = map; fv.setText(Icons.serialize(map));`. +- `FileView { id: fv; path: root.path; atomicWrites: true; watchChanges: true; onFileChanged: reload(); onLoaded: root.map = Icons.parse(text()); onLoadFailed: root.map = Icons.defaults }`. +- No draft/dirty state yet; Task 6 adds it. + +QML has no unit-test rig here; the logic it calls is covered in Task 1 and the singleton is exercised by the live check. + +- [ ] **Step 2: Check syntax** + +Run: `qmllint desktop/island/SceneIcons.qml` +Expected: exit 0; only unresolved-import warnings (the Quickshell modules are not on qmllint's path) and no syntax errors. + +- [ ] **Step 3: Commit** + +```bash +git add desktop/island/SceneIcons.qml +git commit -m "island: watched scene icon map singleton" +``` + +### Task 3: Corner position helper + +**Files:** +- Modify: `desktop/island/Logic.js` +- Modify: `desktop/island/test_logic.js` + +**Interfaces:** +- Produces: `cornerPos(corner, W, H, w, h, m) -> { x: number, y: number }`. + +- [ ] **Step 1: Write the failing test** + +In `test_logic.js`, extend the return list to `{ volumeIcon, vmRole, flagFor, privacyRoles, parseOpenWindow, obsElapsed, cornerPos }` and add before `console.log("ok")`: + +```js +// Corner positions: 1920x1080 canvas, 298x168 webcam, 16px margin. +for (const [c, x, y] of [["tl", 16, 16], ["tr", 1606, 16], ["bl", 16, 896], ["br", 1606, 896]]) + assert.deepStrictEqual(L.cornerPos(c, 1920, 1080, 298, 168, 16), { x, y }); +// An unknown corner reads as top-left, never undefined. +assert.deepStrictEqual(L.cornerPos("xx", 1920, 1080, 298, 168, 16), { x: 16, y: 16 }); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `node desktop/island/test_logic.js` +Expected: FAIL, `TypeError: L.cornerPos is not a function`. + +- [ ] **Step 3: Implement `cornerPos` in `Logic.js`** + +Top-left position for a `w x h` source on a `W x H` canvas with margin `m`: `x` is `m` unless the corner is `tr` or `br` (then `W - w - m`); `y` is `m` unless it is `bl` or `br` (then `H - h - m`). Anything unrecognised stays top-left. + +- [ ] **Step 4: Run it to verify it passes** + +Run: `node desktop/island/test_logic.js` +Expected: `ok`. + +- [ ] **Step 5: Commit** + +```bash +git add desktop/island/Logic.js desktop/island/test_logic.js +git commit -m "island: corner position helper for the OBS webcam" +``` + +### Task 4: `Obs.qml` audio and corner state + +**Files:** +- Modify: `desktop/island/Obs.qml` + +**Interfaces:** +- Consumes: `Logic.cornerPos`. +- Produces: `desktopAudio: string`, `desktopMuted: bool`, `baseWidth: real`, `baseHeight: real`, `camW: real`, `camH: real`, `toggleAudio()`, `moveCam(corner)` where corner is `"tl" | "tr" | "bl" | "br"`. + +- [ ] **Step 1: Add the state and import** + +`import "Logic.js" as Logic`; after `camOn` add the six properties initialised to `""` / `false` / `0`. + +- [ ] **Step 2: Subscribe to Inputs and request the new state** + +In `identify()`, `eventSubscriptions` becomes `4 | 8 | 64 | 128` (Scenes | Inputs | Outputs | SceneItems = **204**). On `Identified` (op 2), in addition to the existing two requests, send `GetSpecialInputs` and `GetVideoSettings`. + +- [ ] **Step 3: Handle the responses** + +In `response()`, before the existing cases: + +- `GetSpecialInputs`: `desktopAudio = r.desktop1 || ""`; if non-empty, request `GetInputMute { inputName: desktopAudio }`. +- `GetVideoSettings`: `baseWidth = r.baseWidth; baseHeight = r.baseHeight`. +- `GetInputMute`: `desktopMuted = r.inputMuted`. +- `GetSceneItemList`: also set `camW` / `camH` from the webcam item's `sceneItemTransform.width` / `.height`, and `0` when no webcam item. + +- [ ] **Step 4: Handle the mute event and add the actions** + +Event: `InputMuteStateChanged` sets `desktopMuted = d.inputMuted` when `d.inputName === desktopAudio`. +`toggleAudio()`: if `desktopAudio`, `SetInputMute { inputName: desktopAudio, inputMuted: !desktopMuted }`. +`moveCam(corner)`: return unless `camId >= 0 && baseWidth > 0 && camW > 0 && camH > 0`; then `Logic.cornerPos(corner, baseWidth, baseHeight, camW, camH, 16)` and `SetSceneItemTransform { sceneName: scene, sceneItemId: camId, sceneItemTransform: { positionX, positionY } }`. Position only: a full echoed transform fails on `boundsWidth: 0`. Add a comment that the math assumes `alignment: 5` (top-left). + +- [ ] **Step 5: Clear the new state on disconnect** + +Extend `reset()` with `desktopAudio = ""; desktopMuted = false; baseWidth = 0; baseHeight = 0; camW = 0; camH = 0;`. + +- [ ] **Step 6: Check syntax and commit** + +Run: `qmllint desktop/island/Obs.qml` (exit 0, no syntax errors), then: + +```bash +git add desktop/island/Obs.qml +git commit -m "island: OBS desktop audio and webcam corner control" +``` + +### Task 5: Icon buttons and the scene grid + +**Files:** +- Modify: `desktop/Button.qml` +- Modify: `desktop/island/ObsPill.qml` +- Modify: `desktop/island/README.md` + +**Interfaces:** +- Consumes: `Obs.desktopAudio`, `Obs.desktopMuted`, `Obs.camId`, `Obs.camOn`, `Obs.toggleAudio()`, `Obs.moveCam(corner)`, `SceneIcons.iconFor(name)`. +- Produces: `Button.icon: string` (optional, default `""`). + +- [ ] **Step 1: Give `Button` an optional icon** + +Add `property string icon: ""`. Replace the centred label `Text` with a `Row` (id `content`, `spacing: 6`) holding an icon `Text` (id `iconText`, `visible: btn.icon !== ""`, `font.family: Theme.iconFamily`, `font.pixelSize: label.font.pixelSize`) and the existing label `Text`. Set `iconText.color: label.color`. `implicitWidth: content.implicitWidth + 22`, `implicitHeight: content.implicitHeight + 12`. Keep the `text` and `font` aliases pointing at `label`. With no icon, `Row` lays out only the label, so existing buttons are unchanged. + +- [ ] **Step 2: Convert the control buttons to icon + label** + +In `ObsPill.qml`'s button `Row`: + +- Record/Stop: icon `Obs.recording ? "\udb81\ude66" : "\udb83\udec2"`. +- Pause/Resume: icon `Obs.paused ? "\udb81\udc0a" : "\udb80\udfe4"`. +- Webcam: icon `Obs.camOn ? "\udb81\udd67" : "\udb81\udd68"`, label `"Webcam"`. +- Audio: visible when `Obs.desktopAudio !== ""`, icon `Obs.desktopMuted ? "\udb81\udd81" : "\udb81\udd7e"`, label `"Audio"`, `onClicked: Obs.toggleAudio()`. + +Below that row add a `Row` of four icon-only `Button`s, `visible: Obs.camId >= 0`, icons `"\udb80\udc5b"`, `"\udb80\udc5c"`, `"\udb80\udc42"`, `"\udb80\udc43"`, calling `Obs.moveCam("tl" | "tr" | "bl" | "br")`. + +- [ ] **Step 3: Replace the scene list with the grid** + +Replace the scene `Repeater`/`Column` with a `Flickable` (`width: controls.width`, `height: Math.min(80, grid.childrenRect.height)`, `contentHeight: grid.childrenRect.height`, `clip: true`) holding a `Grid { id: grid; columns: 4; spacing: 8 }` of `Rectangles` (36x36, radius 8), each with a `Text` whose text is `SceneIcons.iconFor(modelData)` in `Theme.iconFamily` at pixelSize 18, and a `MouseArea` calling `Obs.setScene(modelData)`. Highlight the current scene with `Qt.alpha(Theme.accent, 0.25)` as the old list did. Click-only selection; the `Flickable` scrolls with the wheel when there are more than eight scenes. + +- [ ] **Step 4: Update the island README** + +In both the "OBS pill" section and, if needed, the intro: the panel now has Record/Stop, Pause/Resume, Webcam, Audio (desktop audio mute) and four corner buttons; the scene list is a four-column icon grid from `~/.config/quickshell/obs-scene-icons.json` (paste-only glyphs, `?` for unmapped); recording still pulses the sliver. + +- [ ] **Step 5: Check syntax and commit** + +Run: `qmllint desktop/Button.qml desktop/island/ObsPill.qml` (exit 0, no syntax errors), then: + +```bash +git add desktop/Button.qml desktop/island/ObsPill.qml desktop/island/README.md +git commit -m "island: icon buttons, audio and corner controls, scene grid" +``` + +### Task 6: OBS scenes settings page + +**Files:** +- Create: `desktop/modules/obs/ObsScenesModule.qml` +- Create: `desktop/modules/obs/ObsScenesPage.qml` +- Create: `desktop/modules/obs/README.md` +- Modify: `desktop/island/SceneIcons.qml` +- Modify: `desktop/shell.qml` + +**Interfaces:** +- Consumes: `SceneIcons.map`, `SceneIcons.iconFor`, `SceneIcons.save(map)`; `Obs.scenes`. +- Produces: `SceneIcons.draft` (`map` or `null`), `SceneIcons.dirty` (bool), `SceneIcons.valid` (readonly bool), `SceneIcons.validGlyph(s)`, `SceneIcons.begin()`, `SceneIcons.editScene(name, text)`, `SceneIcons.editGeneric(text)`, `SceneIcons.discard()`; `save(map)` also clears the draft. + +- [ ] **Step 1: Add the draft API to `SceneIcons.qml`** + +- `property var draft: null`, `property bool dirty: false`, `readonly property bool valid: draft !== null && Icons.validMap(draft)`. +- `validGlyph(s)`: forward to `Icons.validGlyph`. +- `begin()`: if not dirty, `draft = Icons.parse(Icons.serialize(map))`. +- `editScene(name, text)`: ensure the draft, delete the key when `text === ""` else set it, reassign `draft` with a fresh `scenes` object so bindings fire, set `dirty = true`. +- `editGeneric(text)`: ensure the draft, reassign with the new generic, `dirty = true`. +- `discard()`: `draft = null; dirty = false`. +- `save(map)`: `Icons.normalize(map)`, set `root.map`, write with `Icons.serialize`, then `draft = null; dirty = false`. + +- [ ] **Step 2: Write the module** + +`ObsScenesModule.qml`: `import QtQuick`, `import "../.."`, `import "../../island"`. A `Module` with `name: "obs"`, `label: "OBS scenes"`, `icon: "\udb81\ude66"`, `settings: true`, `group: "Desktop"`, `dirty: SceneIcons.dirty`, keywords (`obs`, `scene`, `icon`, `glyph`, `recording`, `webcam`, `stream`). `refresh()` calls `SceneIcons.begin()` when not dirty and sets `searchItems` to `{ text: name, key: name }` for the union of `Object.keys(SceneIcons.map.scenes)` and `Obs.scenes`. The `page` is a `Page` titled "OBS scenes", with `actions: Component { SaveBar { dirty: SceneIcons.dirty; valid: SceneIcons.valid; onSave: SceneIcons.save(SceneIcons.draft); onDiscard: SceneIcons.discard() } }` and `ObsScenesPage { width: parent.width }`. + +- [ ] **Step 3: Write the page** + +`ObsScenesPage.qml`: a `Column` with `import "../.."` and `import "../../island"`. A hint line ("Paste an icon from the Nerd Fonts cheat sheet."). A `Repeater` over the union of the draft's scene keys and `Obs.scenes`, each row showing the name and a `TextInput` styled like the settings fields (`Theme.surface` background, radius 8), `font.family: Theme.iconFamily`, text `SceneIcons.draft?.scenes?.[name] ?? ""`, `onTextEdited: SceneIcons.editScene(name, text)`, red border when the text is non-empty and `!SceneIcons.validGlyph(text)`. A final row "Default for unmapped scenes" bound to `SceneIcons.draft?.generic ?? SceneIcons.map.generic` with `onTextEdited: SceneIcons.editGeneric(text)` and the same red-border rule. + +- [ ] **Step 4: Register the module and document it** + +In `desktop/shell.qml`, add `import "modules/obs"` and `ObsScenesModule {}` after `AutostartModule {}` in the modules list (keeps the "Desktop" group order). Create `desktop/modules/obs/README.md`: the page edits the same config file the pill watches; glyphs are pasted from the Nerd Fonts cheat sheet; empty generic falls back to `?`; the JSON may also be hand-edited. + +- [ ] **Step 5: Check syntax and commit** + +Run: `qmllint desktop/modules/obs/*.qml desktop/modules/obs/../island/SceneIcons.qml desktop/shell.qml` (exit 0, no syntax errors), then: + +```bash +git add desktop/modules/obs desktop/island/SceneIcons.qml desktop/shell.qml +git commit -m "settings: OBS scenes icon page" +``` + +- [ ] **Step 6: Live check (user-run)** + +Ask the user to run `./reload.sh desktop` from their terminal (new files in `island/` and `modules/obs/` need the reindex). Then confirm by eye: + +1. Existing buttons in the drawer/settings look unchanged (no blank icon gap). +2. Pill: Record/Stop, Pause/Resume, Webcam, Audio show icon + label; corner buttons move the webcam to each corner. +3. Audio button mutes/unmutes `Desktop Audio`; a mute from OBS's own mixer moves it too. +4. Scene grid: four columns, icon tiles, current highlighted, click switches; the dropdown's old text list is gone. +5. Settings → OBS scenes: rows for every scene, paste a glyph, Save, pill tile updates without restart; an invalid (multi-character) glyph shows red and blocks Save. +6. Remove the config file, reload, and the starter icons return; create a scene in OBS and its tile is `?` until mapped. + +Agent side: `qs log -p desktop --log-times | tail -40` shows no `ReferenceError`, no import error, and no `obs:` warnings. |
