Rebuild Displays around the canvas, and let the transaction keep color
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -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.8–2.0, default 1.0 | `sdrbrightness` | Yes — `sdrBrightness` |
|
||||
| `sdrSaturation` | real | 0.8–1.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.
|
||||
Reference in New Issue
Block a user