371 lines
18 KiB
Markdown
371 lines
18 KiB
Markdown
# Phase 2 Display Arrangement 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 safe drag-and-snap multi-monitor positioning and a Panama primary-display role without weakening the existing 15-second rollback contract.
|
|
|
|
**Architecture:** A pure layout module validates, normalizes, scales, and snaps logical monitor rectangles. `Displays.qml` upgrades its pending operation to a complete-layout transaction, and a focused canvas component edits drafts before invoking that transaction.
|
|
|
|
**Tech Stack:** Quickshell 0.3, Qt 6 QML/JavaScript, Hyprland 0.56.2 Lua evaluation, Lua startup config, Bash/QML contracts
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-08-18-phase2-expectation-gaps-design.md`
|
|
|
|
## Global Constraints
|
|
|
|
- One connected output is primary; it anchors persisted coordinates at logical `0,0`.
|
|
- Primary does not promise where third-party Wayland applications open.
|
|
- Every layout operation captures, applies, verifies, confirms, reverts, and verifies the complete connected layout.
|
|
- Keep is disabled until every output matches mode, scale, transform, x, and y.
|
|
- Stored disconnected outputs remain untouched and do not enter a live transaction.
|
|
- Development uses static fixtures; run the existing live display contract once at final completion.
|
|
|
|
---
|
|
|
|
### Task 1: Pure display-layout geometry
|
|
|
|
**Files:**
|
|
- Create: `config/dot/quickshell/services/DisplayLayout.js`
|
|
- Create: `config/dot/quickshell/display-layout-harness.qml`
|
|
- Create: `tests/quickshell/display-layout-contract.sh`
|
|
|
|
**Interfaces:**
|
|
- Consumes: records `{name,width,height,scale,transform,x,y,primary}`.
|
|
- Produces: `logicalSize`, `validate`, `normalize`, `snap`, `bounds`, and `canvasRects`.
|
|
|
|
- [ ] **Step 1: Write the failing geometry contract**
|
|
|
|
Use literal fixtures:
|
|
|
|
```json
|
|
[
|
|
{"name":"DP-2","width":4500,"height":3000,"scale":1.5,"transform":0,"x":140,"y":80,"primary":true},
|
|
{"name":"HDMI-A-1","width":2560,"height":1440,"scale":1,"transform":1,"x":3140,"y":80,"primary":false}
|
|
]
|
|
```
|
|
|
|
Assert transformed logical sizes, normalized DP-2 position `0,0`, normalized HDMI position `3000,0`, exactly one primary, and canvas rectangles preserving the complete desktop aspect ratio. Move HDMI within 16 logical pixels of DP-2's right edge and assert it snaps to x `3000`; move it 17 pixels away and assert no snap.
|
|
|
|
- [ ] **Step 2: Run and verify RED**
|
|
|
|
Run: `tests/quickshell/display-layout-contract.sh`
|
|
|
|
Expected: FAIL because the geometry module and harness do not exist.
|
|
|
|
- [ ] **Step 3: Implement geometry functions**
|
|
|
|
Rules:
|
|
|
|
```text
|
|
transform 0 or 2 -> logical width = width/scale, height = height/scale
|
|
transform 1 or 3 -> logical width = height/scale, height = width/scale
|
|
coordinates -> finite integers between -100000 and 100000
|
|
snap threshold -> 16 logical pixels
|
|
canvas padding -> caller supplied; return data only, never QML objects
|
|
```
|
|
|
|
`normalize` subtracts the primary x/y from every record and returns new objects. `snap` compares the moving rectangle's four edges with every stationary rectangle's opposite and same-axis edges; choose the smallest eligible delta, then stable output-name order on ties.
|
|
|
|
- [ ] **Step 4: Add invalid-layout cases**
|
|
|
|
Assert rejection of duplicate outputs, zero or two primaries, fractional coordinates, non-positive scale, unsupported transform, non-finite values, and rectangles with zero logical size.
|
|
|
|
- [ ] **Step 5: Run and verify GREEN**
|
|
|
|
Run: `tests/quickshell/display-layout-contract.sh`
|
|
|
|
Expected: PASS for geometry, deterministic snapping, normalization, and invalid cases.
|
|
|
|
- [ ] **Step 6: Commit layout geometry**
|
|
|
|
```bash
|
|
git add config/dot/quickshell/services/DisplayLayout.js config/dot/quickshell/display-layout-harness.qml tests/quickshell/display-layout-contract.sh
|
|
git commit -m "Model multi-monitor layout geometry"
|
|
```
|
|
|
|
### Task 2: Parse and persist extended monitor records
|
|
|
|
**Files:**
|
|
- Modify: `config/dot/quickshell/services/Displays.qml`
|
|
- Modify: `config/dot/quickshell/displays-harness.qml`
|
|
- Modify: `config/dot/hypr/monitors.lua`
|
|
- Modify: `config/dot/quickshell/config/PreferenceSchema.qml`
|
|
- Modify: `tests/quickshell/displays-contract.sh`
|
|
- Modify: `tests/quickshell/settings-preferences-contract.sh`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `hyprctl -j monitors` x/y values and backward-compatible persisted entries.
|
|
- Produces: monitor records with `x`, `y`, `primary`; Lua validation for optional `x`, `y`, `primary`.
|
|
|
|
- [ ] **Step 1: Extend the static contract and Lua fixture**
|
|
|
|
Assert parsed monitors retain literal x/y. Feed Lua old, valid new, and malformed records. Expected startup calls:
|
|
|
|
```lua
|
|
DP-2 position = "0x0"
|
|
HDMI-A-1 position = "3000x0"
|
|
old valid entry position = "auto"
|
|
malformed x/y/primary entry = ignored in favor of shipped/auto behavior
|
|
```
|
|
|
|
Assert only one valid persisted primary is honored and DP-2's 10-bit/color policy remains unchanged.
|
|
|
|
- [ ] **Step 2: Run and verify RED**
|
|
|
|
Run: `PANAMA_DISPLAYS_STATIC_ONLY=1 tests/quickshell/displays-contract.sh`
|
|
|
|
Expected: FAIL because x/y/primary are neither parsed nor replayed.
|
|
|
|
- [ ] **Step 3: Extend monitor parsing**
|
|
|
|
Read integer `monitor.x` and `monitor.y`. Derive the live primary from the connected persisted primary when valid, otherwise the output at `0,0`, otherwise the first connected monitor. Include the boolean only in the service model; Hyprland receives position, not a nonexistent primary flag.
|
|
|
|
- [ ] **Step 4: Extend Lua validation**
|
|
|
|
Add `valid_position(entry)` and `valid_primary(entry)`. An entry is extended only when all three new fields are present and valid; an entry with none remains legacy and uses `position = "auto"`; a partially extended entry is invalid. Render position with `string.format("%dx%d", entry.x, entry.y)`.
|
|
|
|
- [ ] **Step 5: Update schema documentation**
|
|
|
|
Change the internal `displays` detail to **Resolution, scale, rotation, position, and primary display**. Do not add a second preference.
|
|
|
|
- [ ] **Step 6: Run and verify GREEN**
|
|
|
|
Run: `PANAMA_DISPLAYS_STATIC_ONLY=1 tests/quickshell/displays-contract.sh && tests/quickshell/settings-preferences-contract.sh`
|
|
|
|
Expected: PASS for old and new records.
|
|
|
|
- [ ] **Step 7: Commit extended persistence**
|
|
|
|
```bash
|
|
git add config/dot/quickshell/services/Displays.qml config/dot/quickshell/displays-harness.qml config/dot/hypr/monitors.lua config/dot/quickshell/config/PreferenceSchema.qml tests/quickshell/displays-contract.sh tests/quickshell/settings-preferences-contract.sh
|
|
git commit -m "Persist complete monitor layouts"
|
|
```
|
|
|
|
### Task 3: Whole-layout transaction and rollback
|
|
|
|
**Files:**
|
|
- Modify: `config/dot/quickshell/services/Displays.qml`
|
|
- Modify: `config/dot/quickshell/displays-harness.qml`
|
|
- Modify: `tests/quickshell/displays-contract.sh`
|
|
- Create: `tests/quickshell/display-transaction-contract.sh`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `DisplayLayout.validate/normalize` and complete current monitor records.
|
|
- Produces: public `Displays.currentLayout()`, `Displays.applyLayout(layout)`, `Displays.makePrimary(output)`; internal `matchesLayout(monitors, layout)`; and backward-compatible `apply(output, mode, scale, transform)`.
|
|
|
|
- [ ] **Step 1: Write the failing fixture transaction contract**
|
|
|
|
Fake `hyprctl` monitor JSON and `eval`. Request a two-output layout and assert the evaluator receives both literal monitor calls in one argv payload. Assert `canConfirm` remains false when one output has wrong y, becomes true only when both match, and confirm persists both records with one primary.
|
|
|
|
- [ ] **Step 2: Add rollback and generation cases**
|
|
|
|
Assert timeout, explicit revert, non-zero apply exit, wrong readback, and a disconnect generation each restore all still-connected outputs. Return a stale pre-operation query after a newer operation starts and prove it cannot confirm or clear the newer transaction. Force wrong revert readback and assert the existing manual-restoration error.
|
|
|
|
- [ ] **Step 3: Run and verify RED**
|
|
|
|
Run: `tests/quickshell/display-transaction-contract.sh`
|
|
|
|
Expected: FAIL because `Displays.qml` tracks only one output per operation.
|
|
|
|
- [ ] **Step 4: Replace pending records with complete layouts**
|
|
|
|
Use:
|
|
|
|
```qml
|
|
property var pendingPreviousLayout: null
|
|
property var pendingRequestedLayout: null
|
|
property var revertExpectedLayout: null
|
|
readonly property bool awaitingConfirmation: root.pendingRequestedLayout !== null
|
|
```
|
|
|
|
Retain the existing generation counters and timers. `matchesLayout` requires equal connected output-name sets and exact x/y/transform, with the existing tolerances for refresh and scale.
|
|
|
|
- [ ] **Step 5: Implement one validated evaluation payload**
|
|
|
|
Build each call only from compositor-reported output names and validated numeric/mode fields:
|
|
|
|
```text
|
|
hl.monitor({ output = "DP-2", mode = "[email protected]", position = "0x0", scale = 1.5, transform = 0 }); hl.monitor({ output = "HDMI-A-1", mode = "[email protected]", position = "3000x0", scale = 1, transform = 0 })
|
|
```
|
|
|
|
Reject quotes or non-connector characters in output names before generation. Preserve sequential Process/readback timing around the single eval.
|
|
|
|
- [ ] **Step 6: Preserve one-field callers**
|
|
|
|
Keep `apply(output, mode, scale, transform)` by cloning `currentLayout()`, replacing one output's four existing fields, retaining every position and primary flag, then calling `applyLayout`. This keeps `DisplayModePicker`, scale, rotation, and the existing live contract working.
|
|
|
|
- [ ] **Step 7: Run and verify GREEN**
|
|
|
|
Run: `tests/quickshell/display-transaction-contract.sh && PANAMA_DISPLAYS_STATIC_ONLY=1 tests/quickshell/displays-contract.sh`
|
|
|
|
Expected: PASS for apply, confirm, timeout, all revert paths, disconnect, and stale generations.
|
|
|
|
- [ ] **Step 8: Commit safe transactions**
|
|
|
|
```bash
|
|
git add config/dot/quickshell/services/Displays.qml config/dot/quickshell/displays-harness.qml tests/quickshell/displays-contract.sh tests/quickshell/display-transaction-contract.sh
|
|
git commit -m "Apply monitor layouts transactionally"
|
|
```
|
|
|
|
### Task 4: Arrangement canvas
|
|
|
|
**Files:**
|
|
- Create: `config/dot/quickshell/modules/settings/DisplayArrangement.qml`
|
|
- Modify: `config/dot/quickshell/modules/settings/DisplaysPage.qml`
|
|
- Create: `tests/quickshell/display-arrangement-contract.sh`
|
|
- Modify: `tests/quickshell/settings-pages-contract.sh`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `Displays.currentLayout()`, `Displays.applyLayout()`, `Displays.makePrimary()`, and `DisplayLayout.canvasRects/snap`.
|
|
- Produces: selected output, pointer/keyboard draft positioning, **Make primary**, and narrow-layout textual selection.
|
|
|
|
- [ ] **Step 1: Write the failing visual-behavior contract**
|
|
|
|
Render two literal monitors at wide and 500 px content widths. Through harness IPC, drag HDMI next to DP-2, release, and assert one `applyLayout` call with snapped logical x. Focus HDMI and send Left plus Shift+Left; assert 10 and 100 logical-pixel draft steps. Activate Make primary and assert normalized DP-2 coordinates become negative while HDMI becomes `0,0`.
|
|
|
|
- [ ] **Step 2: Run and verify RED**
|
|
|
|
Run: `tests/quickshell/display-arrangement-contract.sh`
|
|
|
|
Expected: FAIL because no arrangement component exists.
|
|
|
|
- [ ] **Step 3: Implement responsive canvas data flow**
|
|
|
|
Keep `draftLayout` as copied plain records. Recalculate canvas rectangles from `DisplayLayout.canvasRects` when width or monitors change, but never mutate live service records. Use `DragHandler` for the selected tile and call `DisplayLayout.snap` before mapping canvas movement back to logical coordinates.
|
|
|
|
- [ ] **Step 4: Add keyboard and primary actions**
|
|
|
|
Each tile is focusable and exposes accessible name **Move DISPLAY_NAME**. Arrow keys modify the draft by 10 logical pixels; Shift uses 100. Enter applies the draft. Escape discards it. **Make primary** changes one boolean, normalizes through the pure module, then applies through the same transaction.
|
|
|
|
- [ ] **Step 5: Integrate Displays page**
|
|
|
|
Show the card only for two or more monitors. Keep the existing connected-display ChoiceGrid below/inside the selected-display area for narrow tiled widths. Bind arrangement selection and `root.selectedOutput` both ways without loops.
|
|
|
|
- [ ] **Step 6: Run and verify GREEN**
|
|
|
|
Run: `tests/quickshell/display-arrangement-contract.sh && PANAMA_DISPLAYS_STATIC_ONLY=1 tests/quickshell/displays-contract.sh && tests/quickshell/settings-pages-contract.sh`
|
|
|
|
Expected: PASS at both widths with no QML warnings.
|
|
|
|
- [ ] **Step 7: Commit the canvas**
|
|
|
|
```bash
|
|
git add config/dot/quickshell/modules/settings/DisplayArrangement.qml config/dot/quickshell/modules/settings/DisplaysPage.qml tests/quickshell/display-arrangement-contract.sh tests/quickshell/settings-pages-contract.sh
|
|
git commit -m "Add monitor arrangement canvas"
|
|
```
|
|
|
|
### Task 5: Static display identification overlays
|
|
|
|
**Files:**
|
|
- Create: `config/dot/quickshell/modules/settings/DisplayIdentify.qml`
|
|
- Modify: `config/dot/quickshell/modules/settings/DisplayArrangement.qml`
|
|
- Modify: `config/dot/quickshell/shell.qml`
|
|
- Modify: `tests/quickshell/display-arrangement-contract.sh`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `Displays.identifying` and each `Quickshell.screens` entry.
|
|
- Produces: `Displays.identify()` and one non-interactive three-second numbered overlay per screen.
|
|
|
|
- [ ] **Step 1: Write the failing overlay contract**
|
|
|
|
Invoke identify through the fixture harness. Assert screen 1 renders connector/name and number 1, screen 2 renders number 2, overlays accept no keyboard focus or pointer input, and all become invisible after one 3,000 ms single-shot timer. Assert repeated invocation restarts that timer without creating more windows.
|
|
|
|
- [ ] **Step 2: Run and verify RED**
|
|
|
|
Run: `tests/quickshell/display-arrangement-contract.sh`
|
|
|
|
Expected: FAIL because identify state and overlays do not exist.
|
|
|
|
- [ ] **Step 3: Add service state and shell-owned windows**
|
|
|
|
`Displays.identify()` sets one boolean and restarts one Timer. `DisplayIdentify.qml` uses a `Variants` model over `Quickshell.screens`, one transparent non-focusable `PanelWindow` per screen, centered static number card, and no animations. Instantiate it once from `shell.qml`; the Settings button only calls the service.
|
|
|
|
- [ ] **Step 4: Run and verify GREEN**
|
|
|
|
Run: `tests/quickshell/display-arrangement-contract.sh`
|
|
|
|
Expected: PASS with a fixed window count and no focus-grab warnings.
|
|
|
|
- [ ] **Step 5: Commit identification**
|
|
|
|
```bash
|
|
git add config/dot/quickshell/modules/settings/DisplayIdentify.qml config/dot/quickshell/modules/settings/DisplayArrangement.qml config/dot/quickshell/shell.qml tests/quickshell/display-arrangement-contract.sh
|
|
git commit -m "Add display identification overlays"
|
|
```
|
|
|
|
### Task 6: Search, backup, and ownership integration
|
|
|
|
**Files:**
|
|
- Modify: `config/dot/quickshell/services/SettingsSearch.qml`
|
|
- Modify: `config/dot/quickshell/services/SettingsBackup.qml`
|
|
- Modify: `config/dot/quickshell/modules/settings/README.md`
|
|
- Modify: `tests/quickshell/settings-search-contract.sh`
|
|
- Modify: `tests/quickshell/settings-backup-live-contract.sh`
|
|
- Modify: `tests/quickshell/settings-commit-reset-contract.sh`
|
|
- Modify: `tests/quickshell/settings-ownership-contract.sh`
|
|
|
|
**Interfaces:**
|
|
- Consumes: the existing internal `displays` preference and complete-layout transaction.
|
|
- Produces: search terms for arrangement/primary and protected whole-layout restore.
|
|
|
|
- [ ] **Step 1: Write failing integration assertions**
|
|
|
|
Assert **arrange displays**, **monitor position**, and **primary display** route to Displays. Restore a two-output snapshot while a current layout is protected; assert complete apply/verify occurs before shell reload and any failed restore retains/proves the original layout. Reset must clear confirmed arrangement fields so startup returns to shipped DP-2 plus automatic placement for other outputs.
|
|
|
|
- [ ] **Step 2: Run and verify RED**
|
|
|
|
Run: `tests/quickshell/settings-search-contract.sh && tests/quickshell/settings-backup-live-contract.sh && tests/quickshell/settings-commit-reset-contract.sh`
|
|
|
|
Expected: FAIL for missing terms and one-output restore assumptions.
|
|
|
|
- [ ] **Step 3: Update search and restore**
|
|
|
|
Add three manual search entries because `displays` is internal. Replace any single-output assumptions in SettingsBackup with cloned complete layout objects and wait on the existing `Displays.busy || Displays.awaitingConfirmation` boundary.
|
|
|
|
- [ ] **Step 4: Update ownership documentation**
|
|
|
|
Document Displays as the sole owner of mode, scale, rotation, arrangement, and primary role. No mirror entry is added.
|
|
|
|
- [ ] **Step 5: Run and verify GREEN**
|
|
|
|
Run: `tests/quickshell/settings-search-contract.sh && tests/quickshell/settings-backup-live-contract.sh && tests/quickshell/settings-commit-reset-contract.sh && tests/quickshell/settings-ownership-contract.sh`
|
|
|
|
Expected: PASS with no new duplicated controls.
|
|
|
|
- [ ] **Step 6: Commit integration**
|
|
|
|
```bash
|
|
git add config/dot/quickshell/services/SettingsSearch.qml config/dot/quickshell/services/SettingsBackup.qml config/dot/quickshell/modules/settings/README.md tests/quickshell/settings-search-contract.sh tests/quickshell/settings-backup-live-contract.sh tests/quickshell/settings-commit-reset-contract.sh tests/quickshell/settings-ownership-contract.sh
|
|
git commit -m "Integrate complete display layouts"
|
|
```
|
|
|
|
### Task 7: Slice verification
|
|
|
|
- [ ] **Step 1: Run all static display contracts**
|
|
|
|
```bash
|
|
tests/quickshell/display-layout-contract.sh
|
|
tests/quickshell/display-transaction-contract.sh
|
|
tests/quickshell/display-arrangement-contract.sh
|
|
PANAMA_DISPLAYS_STATIC_ONLY=1 tests/quickshell/displays-contract.sh
|
|
tests/quickshell/settings-search-contract.sh
|
|
tests/quickshell/settings-backup-live-contract.sh
|
|
```
|
|
|
|
Expected: all PASS without touching the physical display.
|
|
|
|
- [ ] **Step 2: Validate startup configuration once**
|
|
|
|
Run: `Hyprland --verify-config`
|
|
|
|
Expected: `config ok`.
|
|
|
|
- [ ] **Step 3: Defer the live display test**
|
|
|
|
Do not run the state-changing portion of `tests/quickshell/displays-contract.sh` here. The master Phase 2 completion gate runs it once after every slice is stable and restores the observed mode, scale, transform, and position.
|
|
|
|
- [ ] **Step 4: Review branch state**
|
|
|
|
Run: `git diff --check && git status --short`
|
|
|
|
Expected: clean after the Task 6 commit.
|