aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-10-17-obs-pill-enhancements.md
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-17 13:11:24 +0200
committerDanilo M. <danix@danix.xyz>2026-10-17 13:11:24 +0200
commite39df38860ba10b4611572ec721a8be39cf8bcae (patch)
tree04d61c342b34876320ccb2fcf302728e7fdc46a5 /docs/superpowers/plans/2026-10-17-obs-pill-enhancements.md
parentc3bd98ea7f9095d734ce83f6ca138060371c5539 (diff)
downloadquickshell-e39df38860ba10b4611572ec721a8be39cf8bcae.tar.gz
quickshell-e39df38860ba10b4611572ec721a8be39cf8bcae.zip
docs: OBS pill enhancements plan
Diffstat (limited to 'docs/superpowers/plans/2026-10-17-obs-pill-enhancements.md')
-rw-r--r--docs/superpowers/plans/2026-10-17-obs-pill-enhancements.md343
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.