Files
Panama/docs/superpowers/plans/2026-08-18-phase2-wallpaper-modes.md
T

328 lines
16 KiB
Markdown

# Phase 2 Wallpaper Modes 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:** Extend Panama's thumbnail-first wallpaper picker with verified single, slideshow, and per-monitor policies.
**Architecture:** A pure policy module validates collections, calculates output maps, and owns deterministic shuffle bags. `Wallpaper.qml` remains the sole hyprpaper process boundary and persists manual changes only after `listactive` verifies every output.
**Tech Stack:** Quickshell 0.3, Qt 6 QML/JavaScript, hyprpaper 0.8.4 IPC, Bash fixture contracts
**Spec:** `docs/superpowers/specs/2026-08-18-phase2-expectation-gaps-design.md`
## Global Constraints
- Preserve `wallpaperPath` as the single-image and migration fallback.
- Reject non-absolute paths, commas, newlines, and UI selections outside scanned candidates.
- Automatic slideshow changes never rewrite durable policy or spin on failure.
- No filesystem watcher, animated wallpaper, per-workspace mode, or continuous repaint.
- A manual policy mutation persists only after full output-map readback succeeds.
---
### Task 1: Schema and pure wallpaper policy
**Files:**
- Modify: `config/dot/quickshell/config/PreferenceSchema.qml`
- Create: `config/dot/quickshell/services/WallpaperPolicy.js`
- Create: `config/dot/quickshell/wallpaper-policy-harness.qml`
- Create: `tests/quickshell/wallpaper-policy-contract.sh`
**Interfaces:**
- Consumes: mode, global path, collection, per-monitor map, connected outputs, valid candidates, and shuffle bag.
- Produces: `validPath`, `validCollection`, `validAssignments`, `effectiveMap`, `orderedNext`, `shuffledBag`, and `shuffledNext`.
- [ ] **Step 1: Write the failing policy contract**
Use literal paths `/images/a.jpg`, `/images/b.jpg`, and `/images/c.jpg` with connected outputs `DP-2` and `HDMI-A-1`. Assert:
```json
single -> {"DP-2":"/images/a.jpg","HDMI-A-1":"/images/a.jpg"}
per-monitor -> {"DP-2":"/images/b.jpg","HDMI-A-1":"/images/a.jpg"}
slideshow item c -> {"DP-2":"/images/c.jpg","HDMI-A-1":"/images/c.jpg"}
```
Assert missing assignments fall back to the global path, invalid collection entries are skipped, ordered rotation wraps `a -> b -> c -> a`, and a seeded shuffle emits all three literal paths exactly once before any repeat.
- [ ] **Step 2: Run and verify RED**
Run: `tests/quickshell/wallpaper-policy-contract.sh`
Expected: FAIL because the policy module and harness are missing.
- [ ] **Step 3: Add schema entries**
Add exact keys and defaults:
```qml
{
key: "wallpaperMode", type: "enum", def: "single", group: "wallpaper",
label: "Wallpaper mode", detail: "Use one image, rotate a collection, or choose per display",
options: [
{ value: "single", label: "Single" },
{ value: "slideshow", label: "Slideshow" },
{ value: "per-monitor", label: "Per display" }
]
},
{
key: "wallpaperSlideshowPaths", type: "json", def: ([]), group: "wallpaper", internal: true,
label: "Slideshow collection", detail: "Backgrounds selected for rotation"
},
{
key: "wallpaperIntervalMinutes", type: "int", def: 30, min: 5, max: 1440, step: 5,
unit: "min", group: "wallpaper", label: "Change background every", detail: "Time between slideshow images"
},
{
key: "wallpaperShuffle", type: "bool", def: true, group: "wallpaper",
label: "Shuffle", detail: "Show every selected image before repeating"
},
{
key: "wallpaperPerMonitor", type: "json", def: ({}), group: "wallpaper", internal: true,
label: "Per-display backgrounds", detail: "Background assigned to each connected display"
}
```
Use `group: "wallpaper"` for every key. Keep the existing `wallpaperPath` pattern unchanged.
- [ ] **Step 4: Implement the pure policy API**
`validPath` accepts only strings matching `^/[^,\n]+$` that occur in the supplied candidate set. `validCollection` de-duplicates while preserving first appearance. `validAssignments` keeps only connector keys matching `^[A-Za-z0-9_.-]+$` and valid paths. `effectiveMap` returns a plain output-keyed object. Shuffle accepts an injected `random()` function so tests use the literal sequence `0.8, 0.1, 0.6` without mocking global state.
- [ ] **Step 5: Run and verify GREEN**
Run: `tests/quickshell/wallpaper-policy-contract.sh`
Expected: PASS for validation, fallback maps, ordered wrap, and shuffle-without-repeat.
- [ ] **Step 6: Commit policy model**
```bash
git add config/dot/quickshell/config/PreferenceSchema.qml config/dot/quickshell/services/WallpaperPolicy.js config/dot/quickshell/wallpaper-policy-harness.qml tests/quickshell/wallpaper-policy-contract.sh
git commit -m "Model wallpaper display policies"
```
### Task 2: Verified multi-output hyprpaper transaction
**Files:**
- Modify: `config/dot/quickshell/services/Wallpaper.qml`
- Create: `config/dot/quickshell/wallpaper-service-harness.qml`
- Create: `tests/quickshell/wallpaper-service-contract.sh`
- Modify: `tests/quickshell/settings-pages-contract.sh`
**Interfaces:**
- Consumes: `WallpaperPolicy.effectiveMap(...)` and hyprpaper `listactive` output.
- Produces: `activeByOutput`, `applyPolicy(candidatePolicy, persist)`, `applyCurrentPolicy(persist)`, `setSingle(path)`, `setAssignment(output, path)`, `toggleSlideshowPath(path)`, and `setMode(mode)`.
- [ ] **Step 1: Write the failing IPC transaction contract**
Put fake `hyprctl` first on PATH. Make it record each argv and return fixture `listactive` maps. Assert a two-output request calls exactly:
```text
hyprctl hyprpaper wallpaper DP-2,/images/a.jpg
hyprctl hyprpaper wallpaper HDMI-A-1,/images/b.jpg
hyprctl hyprpaper listactive
```
Assert preferences are written only after exact readback, wrong readback leaves the old policy untouched, and a second output failure stops the transaction and reports **Hyprpaper did not apply that background.**
- [ ] **Step 2: Run and verify RED**
Run: `tests/quickshell/wallpaper-service-contract.sh`
Expected: FAIL because `Wallpaper.qml` only tracks one active path and persists after exit status.
- [ ] **Step 3: Parse complete active state**
Replace `active` as the primary state with `property var activeByOutput: ({})`; keep a compatibility `active` property bound to the first current screen. Parse every `OUTPUT: /absolute/path` line. Reject malformed lines instead of accepting partial state as verification.
- [ ] **Step 4: Implement the transaction queue**
The operation record contains:
```qml
{
expected: { "DP-2": "/images/a.jpg", "HDMI-A-1": "/images/b.jpg" },
remaining: ["DP-2", "HDMI-A-1"],
candidatePolicy: { mode: "per-monitor", assignments: { "HDMI-A-1": "/images/b.jpg" } },
persist: true,
automatic: false
}
```
Apply outputs sequentially, then call `listactive`. Compare every expected key and path. Only then write the candidate schema values through `DesktopPreferences.set`. Keep the last verified map visible during work.
- [ ] **Step 5: Preserve the legacy API safely**
Keep `set(path)` as `return root.setSingle(path)` so SettingsBackup and existing IPC remain compatible until their dedicated integration task changes them. Keep title formatting and scan behavior unchanged.
- [ ] **Step 6: Run and verify GREEN**
Run: `tests/quickshell/wallpaper-service-contract.sh && tests/quickshell/settings-pages-contract.sh`
Expected: PASS for exact argv, readback gating, old API compatibility, and failure copy.
- [ ] **Step 7: Commit verified application**
```bash
git add config/dot/quickshell/services/Wallpaper.qml config/dot/quickshell/wallpaper-service-harness.qml tests/quickshell/wallpaper-service-contract.sh tests/quickshell/settings-pages-contract.sh
git commit -m "Verify wallpaper policy application"
```
### Task 3: Event-driven slideshow and hotplug settle
**Files:**
- Modify: `config/dot/quickshell/services/Wallpaper.qml`
- Modify: `config/dot/quickshell/services/WallpaperPolicy.js`
- Modify: `config/dot/quickshell/wallpaper-service-harness.qml`
- Modify: `tests/quickshell/wallpaper-policy-contract.sh`
- Modify: `tests/quickshell/wallpaper-service-contract.sh`
**Interfaces:**
- Consumes: validated slideshow collection, interval, shuffle flag, and `Quickshell.screens`.
- Produces: `advanceSlideshow()`, runtime `slideshowIndex`, `shuffleBag`, and one interval Timer active only for a collection of at least two valid images.
- [ ] **Step 1: Write failing timer and failure tests**
Use a harness-adjustable interval measured in milliseconds while production derives `minutes * 60000`. Assert: empty and one-item collections do not repeat; two items advance once per trigger; automatic application uses `persist: false`; failure keeps the same policy and schedules only the next normal interval; three rapid screen-set changes coalesce into one reapply.
- [ ] **Step 2: Run and verify RED**
Run: `tests/quickshell/wallpaper-policy-contract.sh && tests/quickshell/wallpaper-service-contract.sh`
Expected: FAIL because no slideshow state or screen-set coalescing exists.
- [ ] **Step 3: Implement runtime rotation**
Add one repeating Timer whose `running` expression requires slideshow mode, at least two valid paths, and no active transaction. `advanceSlideshow()` chooses ordered or shuffled next state through `WallpaperPolicy`, updates runtime state only after verified apply, and never sets a preference.
- [ ] **Step 4: Implement connected-screen coalescing**
Bind a sorted screen-name signature and restart a 350 ms single-shot Timer when it changes. Reapply the current policy with `persist: false`. If a transaction is active, set one boolean follow-up flag rather than creating another queue.
- [ ] **Step 5: Run and verify GREEN**
Run: `tests/quickshell/wallpaper-policy-contract.sh && tests/quickshell/wallpaper-service-contract.sh`
Expected: PASS without rapid retry or durable writes during rotation.
- [ ] **Step 6: Commit runtime policy**
```bash
git add config/dot/quickshell/services/Wallpaper.qml config/dot/quickshell/services/WallpaperPolicy.js config/dot/quickshell/wallpaper-service-harness.qml tests/quickshell/wallpaper-policy-contract.sh tests/quickshell/wallpaper-service-contract.sh
git commit -m "Add event-driven wallpaper rotation"
```
### Task 4: Continuity wallpaper controls
**Files:**
- Create: `config/dot/quickshell/modules/settings/WallpaperControls.qml`
- Modify: `config/dot/quickshell/modules/settings/WallpaperPicker.qml`
- Modify: `config/dot/quickshell/modules/settings/AppearancePage.qml`
- Modify: `config/dot/quickshell/services/SettingsSearch.qml`
- Create: `tests/quickshell/wallpaper-settings-contract.sh`
- Modify: `tests/quickshell/settings-search-contract.sh`
**Interfaces:**
- Consumes: Wallpaper modes, connected displays, collection membership, assignments, and transaction state.
- Produces: compact mode controls above the existing grid and mode-aware tile actions/badges.
- [ ] **Step 1: Write the failing UI contract**
Render Appearance in all three modes. Assert Single tile activation calls `setSingle`; Slideshow activation toggles membership and exposes interval/shuffle; Per monitor exposes a display selector and calls `setAssignment` for the selected output. Assert the active image Prism and collection-member check are separate states.
- [ ] **Step 2: Run and verify RED**
Run: `tests/quickshell/wallpaper-settings-contract.sh && tests/quickshell/settings-search-contract.sh`
Expected: FAIL because only single-image tile activation exists.
- [ ] **Step 3: Implement `WallpaperControls.qml`**
Use `ChoiceGrid` for mode, `ChoiceGrid` for output only in per-monitor mode, `SliderRow`-equivalent layout for interval only in slideshow mode, and `SettingsToggle` for shuffle. Controls call Wallpaper transactional methods rather than writing preferences directly.
- [ ] **Step 4: Make the picker mode-aware**
Add `selectedOutput`, `mode`, `selected(path)`, and `activate(path)` properties/functions. Keep thumbnail decode bounds and event-driven fade. In slideshow mode, draw a quiet check in the upper-right for membership; preserve the Prism border exclusively for the image actually active on the selected/current output.
- [ ] **Step 5: Integrate and route search**
Place `WallpaperControls` in the existing wallpaper card above `WallpaperPicker`. Add search terms for slideshow, shuffle interval, and per-monitor assignment without creating a new Settings page.
- [ ] **Step 6: Run and verify GREEN**
Run: `tests/quickshell/wallpaper-settings-contract.sh && tests/quickshell/settings-search-contract.sh && tests/quickshell/settings-ownership-contract.sh`
Expected: PASS with no new mirrors or QML warnings.
- [ ] **Step 7: Commit the wallpaper UI**
```bash
git add config/dot/quickshell/modules/settings/WallpaperControls.qml config/dot/quickshell/modules/settings/WallpaperPicker.qml config/dot/quickshell/modules/settings/AppearancePage.qml config/dot/quickshell/services/SettingsSearch.qml tests/quickshell/wallpaper-settings-contract.sh tests/quickshell/settings-search-contract.sh
git commit -m "Add wallpaper mode controls"
```
### Task 5: Restore and reset integration
**Files:**
- Modify: `config/dot/quickshell/services/SettingsBackup.qml`
- Modify: `tests/quickshell/settings-backup-live-contract.sh`
- Modify: `tests/quickshell/settings-commit-reset-contract.sh`
**Interfaces:**
- Consumes: `Wallpaper.applyCurrentPolicy(false)`.
- Produces: policy-aware restore and shipped single-wallpaper reset.
- [ ] **Step 1: Write failing restore assertions**
Restore slideshow and per-monitor fixture snapshots. Assert the restored policy applies after preferences reload, uses `persist: false`, and shell reload waits for the bounded wallpaper transaction. Reset must yield mode `single`, empty collection/assignments, interval 30, shuffle true, and shipped `wallpaperPath` fallback.
- [ ] **Step 2: Run and verify RED**
Run: `tests/quickshell/settings-backup-live-contract.sh && tests/quickshell/settings-commit-reset-contract.sh`
Expected: FAIL because restore calls `Wallpaper.set(path)` only.
- [ ] **Step 3: Replace path-only restore**
Inject `applyWallpaperPolicy: function() { return Wallpaper.applyCurrentPolicy(false); }`, include `Wallpaper.busy` in the existing bounded settle condition, and remove the path parameter from the restore callback. Do not create a second wallpaper snapshot format; all policy keys are already in Settings JSON.
- [ ] **Step 4: Run and verify GREEN**
Run: `tests/quickshell/settings-backup-live-contract.sh && tests/quickshell/settings-commit-reset-contract.sh && tests/quickshell/wallpaper-service-contract.sh`
Expected: PASS for both policy modes and shipped reset.
- [ ] **Step 5: Commit restore integration**
```bash
git add config/dot/quickshell/services/SettingsBackup.qml tests/quickshell/settings-backup-live-contract.sh tests/quickshell/settings-commit-reset-contract.sh
git commit -m "Restore complete wallpaper policies"
```
### Task 6: Slice verification
- [ ] **Step 1: Run all wallpaper contracts once**
```bash
tests/quickshell/wallpaper-policy-contract.sh
tests/quickshell/wallpaper-service-contract.sh
tests/quickshell/wallpaper-settings-contract.sh
tests/quickshell/settings-backup-live-contract.sh
tests/quickshell/settings-commit-reset-contract.sh
tests/quickshell/settings-search-contract.sh
```
Expected: all PASS. The live desktop wallpaper is never changed by fixture tests.
- [ ] **Step 2: Perform one read-only live comparison**
Run: `hyprctl hyprpaper listactive`
Expected: every connected output reports an absolute path; compare it with `Wallpaper.activeByOutput` through the existing IPC/harness without applying an image.
- [ ] **Step 3: Review formatting and shell state**
Run: `git diff --check && git status --short`
Expected: clean after the Task 5 commit.