9.9 KiB
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
- 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.
- 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.
- Fix the clobber bug.
pushLayoutcurrently emits only five keys, and a Hyprland monitor rule replaces the previous rule wholesale — so every Settings apply dropsmonitors.lua'sbitdepth = 10/cmvalues (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 livehyprctl -j monitorsreadback (vrrModecannot 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.pushLayoutemits every field with the omission rules above. If thehl.monitorLua bridge only serializes the current five keys, extend the bridge (find it underconfig/dot/hypr/); keep its serialization literal-shaped for whatever parses it.- Validation (
validRequestedLayout/DisplayLayout.validate):mirrorOfmust 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 frombounds/normalizegeometry and from overlap/position concerns inDisplayLayout—canvasRectsstacks 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 intoapplyLayout(). The existingapply(output, mode, scale, transform)stays as a thin wrapper so nothing external breaks. confirm()persists the extended record;isPersistedLayoutEntryaccepts the new fields as optional (old stored blobs must remain valid);forget()unchanged.monitors.lua'sdisplay_entryconsumer emits the new fields when present and validates them; invalid values fall back exactly like invalid geometry does today. Its hardcoded Kuyconbitdepth = 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."
- 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 untilcanConfirm). - 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 " 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.
- 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.displaysmatched by connector), detail "Hardware brightness over DDC — the same dial as the monitor's buttons". Hidden when the connector has no DDC entry;Brightness.lastErrordegrades the detail text exactly as today. The quicksettingsBrightnessControlis 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 →
mirrorOf. Disabled with one display, detail "Mirroring needs a second connected display".
- Resolution — expandable mode list (existing
- 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.
- Profile tiles: Automatic / sRGB / Wide gamut / HDR (gradient swatches per the mock) →
- "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.qmlhand-written entries (allpage: "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 whenvrrMode === -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-contractandswitcher-contract.
Agent ownership (parallel)
- A — transaction:
services/Displays.qml,services/DisplayLayout.js,config/dot/hypr/monitors.lua+ thehl.monitorbridge. - B — UI:
modules/settings/DisplaysPage.qml,DisplayArrangement.qml,DisplayModePicker.qml, any new components inmodules/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.