Rebuild Displays around the canvas, and let the transaction keep color

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 10:44:11 -04:00
parent 1f694b00b6
commit 9bc68ba358
29 changed files with 3065 additions and 388 deletions
+13 -6
View File
@@ -4,7 +4,7 @@
Do not edit this file. Run `quickshell/scripts/panama-settings-docs`
after changing the schema; a contract fails when this copy is stale.
162 settings across 33 groups. 70 of them are applied to the compositor and confirmed by reading the value back.
162 settings across 34 groups. 70 of them are applied to the compositor and confirmed by reading the value back.
## accessibility
@@ -233,11 +233,6 @@ Found on **Shell Tiling**.
| **Keep split direction**<br>`preserveSplit` `dwindle:preserve_split` | true | New windows follow the split of the window they replace, instead of always halving the longer side |
| **New windows open**<br>`forceSplit` `dwindle:force_split` | 0 | Where a new window lands relative to the one that was focused Choices: Where the pointer is, Always left or above, Always right or below. |
| **Snap floating windows**<br>`windowSnapping` `general:snap:enabled` | true | Floating windows stick to screen edges and to each other as you drag them |
| **Switch back and forth**<br>`workspaceBackAndForth` `binds:workspace_back_and_forth` | false | Selecting the workspace you are already on returns you to the previous one |
| **Wrap around at the ends**<br>`allowWorkspaceCycles` `binds:allow_workspace_cycles` | false | Moving past the last workspace continues from the first |
| **Let applications take focus**<br>`focusOnActivate` `misc:focus_on_activate` | false | An application asking for attention is switched to, rather than only highlighted |
| **Hide the terminal that launched a window**<br>`windowSwallow` `misc:enable_swallow` | false | A terminal disappears while an application started from it is open, and returns when it closes |
| **Pointer changes active display**<br>`mouseMoveFocusesMonitor` `misc:mouse_move_focuses_monitor` | true | Moving the pointer to another display makes it the active one |
## nightLight
@@ -399,3 +394,15 @@ Found on **Appearance**.
| **Fullscreen opacity**<br>`fullscreenOpacity` `decoration:fullscreen_opacity` | 1.0 | Applied instead of the focused opacity when a window is fullscreen. Range 0.51.0. |
| **Corner shape**<br>`roundingPower` `decoration:rounding_power` | 2.0 | 2 is a circular corner; higher values approach a squircle. Range 2.010.0. |
## workspaces
Found on **Shell Workspaces**.
| Setting | Default | What it does |
|---|---|---|
| **Switch back and forth**<br>`workspaceBackAndForth` `binds:workspace_back_and_forth` | false | Selecting the workspace you are already on returns you to the previous one |
| **Wrap around at the ends**<br>`allowWorkspaceCycles` `binds:allow_workspace_cycles` | false | Moving past the last workspace continues from the first |
| **Let applications take focus**<br>`focusOnActivate` `misc:focus_on_activate` | false | An application asking for attention is switched to, rather than only highlighted |
| **Hide the terminal that launched a window**<br>`windowSwallow` `misc:enable_swallow` | false | A terminal disappears while an application started from it is open, and returns when it closes |
| **Pointer changes active display**<br>`mouseMoveFocusesMonitor` `misc:mouse_move_focuses_monitor` | true | Moving the pointer to another display makes it the active one |
@@ -187,4 +187,65 @@ phase-3 flake above: that failure only appeared under a storming full-suite run.
- 2026-08-24 full-suite run: 166 contracts, all green except `displays-contract`
and `switcher-contract`, which are live interactive tests that cannot run
behind hyprlock (both passed in the same day's unlocked run; neither
subsystem changed in phase 4). Re-verify after unlock.
subsystem changed in phase 4). Re-verify after unlock. **Still pending**
and `displays-contract` has since changed (phase 5), so this run is now the
first one that exercises the new material as well.
## Phase 5 (Displays) — append below
Spec: `2026-08-24-displays-redesign.md`. The Displays page became canvas-first,
the per-monitor record grew VRR / colour profile / bit depth / SDR trim /
mirroring, and `pushLayout` stopped clobbering `monitors.lua`'s colour values.
**Nothing in this wave was run.** The test window was closed while it was
written: these are live harnesses that drive the real compositor through
display transactions, and three agents were editing the tree concurrently. What
*was* verified is listed as static below — evaluated directly against the
implementation files without a harness, by loading `monitors.lua` under a stub
`prefs` and by evaluating `DisplayLayout.js` in node. Everything else is
deferred to the sweep.
### Contracts changed (4)
| Contract | What it now pins | Verified |
|---|---|---|
| `quickshell/display-transaction-contract` | The extended record end to end: `applyRecord` merging one field at a time; `vrr` **omitted** when `vrrMode === -1` and emitted when it is not; neutral `sdrsaturation` omitted rather than written; a mirrored rule asking for `position = "auto"`; the mirror x/y carve-out in `matchesLayout`, and that it does not leak to an unmirrored record; a framebuffer format with no 8/10 mapping skipping the bit-depth assertion instead of blocking Keep; `confirm()` persisting the whole record; old-shape stored blobs still validating and an impossible `vrrMode` not; and seven refusals (self-mirror, primary mirroring, absent target, out-of-range vrr/profile/depth/SDR). | Bash and Python syntax; every new static grep checked against the landed `Displays.qml` (including the `vrrMode >= 0` omission branch in `monitorRule`) and against the extended harness; the fake compositor's rule parser dry-run on a real `monitorRule` payload, covering the `position = "auto"` and `mirror =` branches and the `mirrorOf: "none"` readback spelling. Every IPC assertion is **deferred**. |
| `quickshell/display-arrangement-contract` | **Flipped**: the canvas is no longer hidden below two displays. The `visible: … monitors.length` gate is now asserted *absent* from the `DisplayArrangement` element (by AST-free block scan, so the selector chips and Workspaces card may keep theirs), with a solo hint string, a `draggable` flag, and an `enabled:` binding on the `DragHandler`. Plus the mirror badge: `Mirrors ` in the component, `mirrorOf` read off the rects, and two new harness fixtures — solo renders one rect with `draggable == false`, mirrored stacks rect 1 on rect 0 and flags it. | **Statically verified** against the rebuilt `DisplayArrangement.qml`: every grep hits, the `DragHandler`'s `enabled:` is found by the brace-matching helper, and the flip check was mutation-tested both ways — it fails on the pre-redesign page (where the gate sat on the enclosing card, not on the canvas) and passes once the gate is gone. The two new harness fixtures are **deferred**. |
| `quickshell/display-layout-contract` | Mirror geometry in `DisplayLayout.js`: a valid mirror validates; the mirrored record keeps its stored coordinates through `normalize` (the primary's anchor does not apply to a position nothing reads back); it contributes nothing to `bounds`; `canvasRects` stacks its rect on its target's and carries `mirrorOf`/`mirrored`. Five refusals: self, absent target, mirroring primary, a two-hop chain, and a non-string. | **Statically verified** in node against the real `DisplayLayout.js` — every expected value in the two new `jq` filters came from that run, including the 3140/80 the mirrored record keeps. Harness plumbing deferred. |
| `quickshell/displays-contract` | The Lua consumer half: `color_profile` / `bitdepth_value` / `vrr_value` / `sdr_value` / `mirror_value` present, `cm`/`bitdepth`/`sdrbrightness`/`vrr`/`mirror` emitted under Hyprland's own key names, a mirrored entry's `position` forced to `auto`, neutral SDR saturation and `vrrMode = -1` written as absence rather than as a value, and an entry with every new field impossible surviving with its geometry while each bad field drops. Plus the extended-record greps on `Displays.qml`. | **Statically verified**: the whole `LUA` block was run against `config/dot/hypr/monitors.lua` with a stub `prefs` and passes. The `Displays.qml` greps were checked by hand and all hit. The live compositor half is deferred. |
### Cross-agent shapes these contracts now pin
Written from the spec's pinned API while agents A and B worked in parallel, and
re-checked against their files as those landed:
- `DisplayArrangement.canvasSnapshot()` exposes `draggable` alongside
`rects`/`scale`, and passes `canvasRects`' `mirrorOf`/`mirrored` through — it
returns `canvasData.rects`, not the solo-shrunk `tiles`, which is what the
new fixtures assert against. Confirmed in the landed component.
- The solo hint is pinned as the prefix `One display connected` rather than the
full sentence, so the em dash cannot break the grep.
- The mirror badge is pinned as `Mirrors ` in `DisplayArrangement.qml`.
- `soloFixture`/`mirrorFixture` mutate `fixtureService.monitors` and call
`resetDraft()`, which is what the component's own `onMonitorsChanged` does.
Whether that ordering settles before `canvasSnapshot()` reads back is the one
thing only a run can answer.
### Still open before the run
- ~~`PreferenceSchema.qml`'s stale `displays` detail string~~ — resolved: the
detail now names color, VRR override, and mirroring, and the matching grep in
`displays-contract` was updated in the same commit.
- `displays-contract` also greps `DisplaysPage.qml` for `selectedOutput`,
`scalesForMode(`, `primaryFirstMonitors.map(` and `enabled: Displays.canConfirm`.
All four still hit, but the page was still the pre-redesign one when this was
written — re-check them once the rebuilt page lands, particularly
`primaryFirstMonitors.map(`, since the spec replaces the "Connected display"
picker card with selector chips.
- No contract file was added or removed, so the README count line stays at
**166** and `setup/readme-contract` needs nothing.
- Run order for the sweep: `display-layout-contract` first (pure geometry, no
compositor), then `display-transaction-contract` (fake compositor on `PATH`),
then `display-arrangement-contract`, and `displays-contract` last — it is the
only one that drives the physical display, and it refuses to start from a
scale that does not match what `monitors.lua` ships.
@@ -0,0 +1,142 @@
# Displays redesign — canvas-first, transaction-complete
Approved mock: `home-mocks/displays.html` (scratchpad, served on :8642). This spec is the
implementation contract; where the mock and this file disagree, this file wins.
## Goals
1. **Canvas-first page.** The arrangement canvas is the hero and renders even with a single
display. Everything below it belongs to the selected display; global settings (Night Light,
Gaming, Workspaces) sit in an "All displays" section at the bottom.
2. **More complete.** Per-display VRR override, full color section (profile / bit depth / SDR
trim), hardware (DDC) brightness in the display panel, and mirroring — all riding the existing
keep-or-revert transaction where they are verifiable.
3. **Fix the clobber bug.** `pushLayout` currently emits only five keys, and a Hyprland monitor
rule replaces the previous rule wholesale — so every Settings apply drops `monitors.lua`'s
`bitdepth = 10` / `cm` values (live compositor is 8-bit sRGB today while the page claims
otherwise). Once color fields are part of the record, applies preserve them.
Non-goals: disabling outputs, reserved areas, tearing controls, HDR capability probing from EDID,
per-display wallpaper. The verify/revert machinery keeps its shape: 15 s countdown, exact readback,
generation tags, hotplug revert.
## Extended display record (service API — pinned)
`Displays.qml` per-monitor records gain, alongside `mode/scale/transform/x/y/primary`:
| Field | Type | Values | Hyprland key | Verified via readback? |
|---|---|---|---|---|
| `vrrMode` | int | `-1` follow global policy (default) / `0` off / `1` always / `2` fullscreen | `vrr`**omitted when -1** | **No** — readback `vrr` is live state, not config. Applied, not verified. |
| `colorProfile` | string | `"auto" \| "srgb" \| "wide" \| "hdr"` | `cm` | Yes — `colorManagementPreset` |
| `bitdepth` | int | `8 \| 10` | `bitdepth` | Yes, conservatively — `currentFormat` `XRGB8888`→8, `XRGB2101010`→10; any other format skips this field's assertion |
| `sdrBrightness` | real | 0.82.0, default 1.0 | `sdrbrightness` | Yes — `sdrBrightness` |
| `sdrSaturation` | real | 0.81.2, default 1.0 | `sdrSaturation` readback / `sdrsaturation` key | Yes |
| `mirrorOf` | string | `""` none (default) / another connected output name | `mirror` | Yes — `mirrorOf`; **and the x/y assertions in `matchesLayout` are skipped for a mirrored record** (position is compositor-chosen) |
Rules:
- `currentLayout()` reads all of these from the live `hyprctl -j monitors` readback (`vrrMode`
cannot be read back; it comes from the persisted record, defaulting to -1), so an apply that
changes one field carries the rest unchanged — that is the clobber fix.
- `pushLayout` emits every field with the omission rules above. If the `hl.monitor` Lua bridge
only serializes the current five keys, extend the bridge (find it under `config/dot/hypr/`);
keep its serialization literal-shaped for whatever parses it.
- Validation (`validRequestedLayout` / `DisplayLayout.validate`): `mirrorOf` must name a
*different* connected output that is not itself mirrored (no chains); the primary display may
not mirror; enum/range checks per the table. A mirrored record still carries mode/scale/
transform (Hyprland applies them) but is excluded from `bounds`/`normalize` geometry and from
overlap/position concerns in `DisplayLayout``canvasRects` stacks it on its target with a
badge flag instead.
- New API: `applyRecord(output, partial)` — merges a partial change object into the cloned
layout and funnels into `applyLayout()`. The existing `apply(output, mode, scale, transform)`
stays as a thin wrapper so nothing external breaks.
- `confirm()` persists the extended record; `isPersistedLayoutEntry` accepts the new fields as
**optional** (old stored blobs must remain valid); `forget()` unchanged.
- `monitors.lua`'s `display_entry` consumer emits the new fields when present and validates them;
invalid values fall back exactly like invalid geometry does today. Its hardcoded Kuycon
`bitdepth = 10` / `cm = "auto"` become the *defaults* the record inherits rather than values
the Settings path fights with.
- VRR override options deliberately exclude "fullscreen games" (that is the global policy's
value 3); the override menu is Follow / Off / Always on / Fullscreen only.
## Page layout (top to bottom)
`DisplaysPage.qml`, rebuilt. Page lede: "Changes apply to every display together and revert on
their own in 15 seconds unless you keep them."
1. **Confirmation banner** (pinned `header:` component — must stay outside the Flickable, the
displays-contract asserts this). Restyled: warn-tinted card with a **countdown ring** (SVG-like
Canvas or two arcs; updates once per second — no continuous animation), seconds numeral,
title "Keep these display settings?", body keeps the "If you cannot read this, just wait."
sentence, Revert now + Keep (Keep still disabled until `canConfirm`).
2. **Arrangement canvas** — always visible, single display included. Solo: the tile renders
centered with primary star, logical size caption, and the toolbar hint reads "One display
connected — plug in another to arrange"; drag disabled. Multi: existing drag/snap/nudge
behavior unchanged. Mirrored displays render stacked on their target with a "Mirrors <name>"
badge. Toolbar: Identify · Make primary (disabled for the primary/solo) · hint text. The
separate "Connected display" picker card is **replaced** by selector chips under the canvas
(one chip per display: thumbnail, name, primary star, connector + mode summary), shown only
with >1 display.
3. **Selected display panel** — one card. Header: display glyph, description, connector chip,
meta line ("4500 × 3000 at 60 Hz · 150% scale · sRGB 8-bit"), and the Custom setting pill +
Forget button when `isOverridden()`. Rows:
- **Resolution** — expandable mode list (existing `DisplayModePicker`, restyled: aspect-ratio
captions, "Current"/"Native" tags).
- **Refresh rate** — pill row (existing behavior), only when >1 rate at the current resolution.
- **Scale** — segmented control over `scalesForMode`, percent labels (100% / 150% / …).
- **Rotation** — segmented control with orientation glyphs, tooltips carry the full names.
- **Brightness** — DDC slider for this display (`Brightness.displays` matched by connector),
detail "Hardware brightness over DDC — the same dial as the monitor's buttons". Hidden when
the connector has no DDC entry; `Brightness.lastError` degrades the detail text exactly as
today. The quicksettings `BrightnessControl` is untouched.
- **Variable refresh rate** — dropdown: Follow gaming policy (shows the policy's current label
in the detail) / Off / Always on / Fullscreen only → `vrrMode`.
- **Use as** — dropdown: Extended display / Mirror of <each other display> → `mirrorOf`.
Disabled with one display, detail "Mirroring needs a second connected display".
4. **Color card** (per selected display). Subtitle notes color rides the same keep-or-revert
transaction. Right-aligned live readout "Now: sRGB · 8-bit (XRGB8888)" from readback.
- Profile tiles: Automatic / sRGB / Wide gamut / HDR (gradient swatches per the mock) →
`colorProfile`.
- Bit depth: 8-bit / 10-bit segmented, caption: "10-bit reduces gradient banding, but some
screen capture and recording tools can't read a 10-bit framebuffer."
- SDR brightness + SDR saturation sliders, visible only while the profile is `hdr`.
5. **"All displays" section**: Night Light card (existing rows; the temperature slider gets a
warm→cool gradient track) and Gaming card (Auto HDR, global VRR policy — detail notes displays
can override above — and Direct scanout) side by side in a 2-column grid; Workspaces card
(existing rows) below, ≥2 displays only. Error cards keep their current behavior.
Every per-display edit funnels through `applyRecord` and therefore the full transaction —
including brightness? **No**: brightness stays outside the transaction (hardware state, no
readback verification, bezel buttons change it behind our back) exactly as today.
## Search & docs
- `SettingsSearch.qml` hand-written entries (all `page: "displays"`): Resolution, Refresh rate,
Scale, Rotation, HDR, Color profile, Bit depth / 10-bit, SDR brightness, Mirror displays,
Variable refresh rate, Monitor brightness. Keep the existing three arrangement entries.
- README "Displays" ownership paragraph extends to name the new transaction fields and the
mirroring position carve-out. Keep the contract count line accurate if contracts are added.
- Settings docs / manual regeneration follows the generators; schema comments stay above entry
braces.
## Contracts (write, do NOT run — test window is closed)
- `display-transaction-contract`: extend fixtures for the new record fields; assert vrr omission
when `vrrMode === -1`, mirror x/y carve-out, format-unknown bitdepth skip, and that old-shape
persisted blobs still validate.
- `display-arrangement-contract`: **flip** the "canvas hidden for a single display" pin to
"canvas rendered for a single display, drag disabled"; add the mirror badge pin.
- `displays-contract` / `display-layout-contract`: update for validate/normalize mirror rules.
- Add every new/changed contract to the test backlog spec for the end-of-redesign sweep, along
with the still-pending unlocked re-run of `displays-contract` and `switcher-contract`.
## Agent ownership (parallel)
- **A — transaction**: `services/Displays.qml`, `services/DisplayLayout.js`,
`config/dot/hypr/monitors.lua` + the `hl.monitor` bridge.
- **B — UI**: `modules/settings/DisplaysPage.qml`, `DisplayArrangement.qml`,
`DisplayModePicker.qml`, any new components in `modules/settings/`.
- **C — periphery**: `services/SettingsSearch.qml`, `tests/quickshell/display*`, README section,
test-backlog spec, manual/docs regeneration.
B programs against the record/API table above; A must not change it without updating this spec.