diff --git a/README.md b/README.md index def4e9a..22dece1 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,21 @@ and remote desktop. The allow-list in [`services/SystemSettings.qml`](config/dot/quickshell/services/SystemSettings.qml) is what decides; anything not on it is a panel Panama owns itself. +Displays is one of the panels it owns, and the only one where a wrong answer +can leave you unable to see well enough to undo it. So every change there is +applied as one complete layout, read back from the compositor, and reverted +after fifteen seconds unless you keep it — resolution, scale, rotation, +position and primary display, and now colour profile, bit depth, SDR +brightness and saturation, and mirroring with them. Two of those opt out of +part of that, for reasons rather than convenience. A per-display variable +refresh rate override is applied but never verified, because `hyprctl` reports +whether adaptive sync is live this instant rather than what was asked for. And +a mirrored display's position is not asserted at all: the compositor stacks it +on the display it mirrors and ignores the coordinates the rule carried, so +holding it to them would make Keep permanently unavailable. Monitor brightness +sits outside the transaction entirely — it is the panel's own backlight over +DDC, and the buttons on the bezel change it behind our back. + | Piece | What it is | |---|---| | `config/dot/hypr/` | Compositor config. **Lua, not hyprlang** — see its README | diff --git a/config/dot/hypr/monitors.lua b/config/dot/hypr/monitors.lua index 4011d7c..43c86c7 100644 --- a/config/dot/hypr/monitors.lua +++ b/config/dot/hypr/monitors.lua @@ -13,8 +13,9 @@ -- than an HDR desktop, the desktop runs SDR at 10-bit and HDR is handed to -- fullscreen games only, via render.cm_auto_hdr in looks.lua. -- --- To try full-time HDR anyway, set cm = "hdr" below (or override it in --- overrides.lua) and read the notes in that file first. +-- To try full-time HDR anyway, set shipped_cm = "hdr" below (or pick HDR for +-- this display in Settings, which saves it as the display's colorProfile and +-- wins over the shipped value) and read the notes in overrides.lua first. -- ───────────────────────────────────────────────────────────────────────────── local prefs = require("prefs") @@ -23,12 +24,21 @@ local prefs = require("prefs") -- { ["DP-2"] = { -- mode = "3840x2160@60", scale = 2, transform = 0, -- x = 0, y = 0, primary = true, +-- vrrMode = -1, colorProfile = "auto", bitdepth = 10, +-- sdrBrightness = 1, sdrSaturation = 1, mirrorOf = "", -- } } -- --- Only mode, scale, and transform are read. Color management and bit depth --- stay here, because those are the settings with a documented reason attached --- (see the header) rather than preferences, and a settings page has no way to --- explain the screencopy tradeoff at the moment you would be changing it. +-- The fields past `primary` are optional: records written before they existed +-- carry none of them, and the shipped values below stand instead. That is the +-- relationship in both directions -- what is written here is what a saved +-- record inherits, and what Settings saves is what overrides it, so the two +-- stop fighting over the same monitor rule. +-- +-- Geometry and colour fail differently on purpose. A half-written position is +-- refused outright (below), because guessing one can strand an output where +-- nothing can reach it. An unreadable colour, VRR or mirror value is dropped +-- on its own and the shipped default stands: the worst it costs is a wrong +-- shade, and taking the whole record down with it would cost the arrangement. local displays = prefs.get("displays", {}) if type(displays) ~= "table" then displays = {} @@ -125,19 +135,102 @@ local function display_position(entry, fallback) return fallback end +local color_profiles = { auto = true, srgb = true, wide = true, hdr = true } + +local function color_profile(entry, fallback) + local value = entry ~= nil and entry.colorProfile or nil + if type(value) == "string" and color_profiles[value] then + return value + end + return fallback +end + +local function bitdepth_value(entry, fallback) + local value = entry ~= nil and entry.bitdepth or nil + if value == 8 or value == 10 then + return value + end + return fallback +end + +-- -1 means the display follows the global misc.vrr policy, which is said by +-- leaving the key out. 3 is the global policy's own value and not a per-display +-- choice, so it is not accepted here either. +local function vrr_value(entry) + local value = entry ~= nil and entry.vrrMode or nil + if type(value) ~= "number" or value ~= math.floor(value) + or value < 0 or value > 2 then + return nil + end + return value +end + +-- Neutral is 1.0, and the neutral value is left out rather than written: a rule +-- that names it pins the display to it, which is not the same as leaving the +-- trim alone. +local function sdr_value(value, minimum, maximum) + if type(value) ~= "number" or value ~= value + or value < minimum or value > maximum + or math.abs(value - 1) < 0.001 then + return nil + end + return value +end + +-- A mirror needs a target that is not itself and not another mirror -- Hyprland +-- has no chain to follow -- and the primary may not mirror at all, since the +-- arrangement is anchored on it. +local function mirror_value(entry, output) + local value = entry ~= nil and entry.mirrorOf or nil + if type(value) ~= "string" or value == "" or value == output + or value:match("^[%w_.-]+$") == nil + or entry.primary == true then + return nil + end + local target = displays[value] + if type(target) == "table" and type(target.mirrorOf) == "string" + and target.mirrorOf ~= "" then + return nil + end + return value +end + +-- Colour, VRR and mirroring layered onto a rule that already carries +-- mode/position/scale/transform. A monitor rule replaces the previous rule for +-- that output whole, so the shipped defaults are passed in here rather than +-- written in a rule of their own. `connector` is the output name the record was +-- saved under, which is not always the rule's own output: the Kuycon rule +-- matches by description. +local function with_display_fields(rule, entry, connector, default_bitdepth, default_cm) + rule.bitdepth = bitdepth_value(entry, default_bitdepth) + rule.cm = color_profile(entry, default_cm) + rule.vrr = vrr_value(entry) + rule.sdrbrightness = entry ~= nil and sdr_value(entry.sdrBrightness, 0.8, 2.0) or nil + rule.sdrsaturation = entry ~= nil and sdr_value(entry.sdrSaturation, 0.8, 1.2) or nil + + -- A mirror shows its target's picture in its target's place, so the saved + -- position is not the compositor's to honour or ours to ask for. + local mirror = mirror_value(entry, connector) + if mirror ~= nil then + rule.mirror = mirror + rule.position = "auto" + end + return rule +end + -- Every connected output uses the same validated per-output store. Automatic -- placement and the compositor's normal color policy unless the entry says -- otherwise. for output, _ in pairs(displays) do local entry = display_entry(output) if entry ~= nil then - hl.monitor({ + hl.monitor(with_display_fields({ output = output, mode = entry.mode, position = display_position(entry, "auto"), scale = entry.scale, transform = entry.transform, - }) + }, entry, output, nil, nil)) end end @@ -150,22 +243,23 @@ end local shipped_mode = "4500x3000@60" local shipped_scale = 1.5 local shipped_transform = 0 + +-- 10-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of DP 1.4 +-- HBR3, so this relies on DSC. If the display fails to light up or falls back to +-- a lower mode, drop this to 8 first. +local shipped_bitdepth = 10 + +-- "auto" = sRGB at 8bpc, wide gamut at 10bpc. Not HDR; see header. +local shipped_cm = "auto" + local kuycon = display_entry("DP-2") -hl.monitor({ +hl.monitor(with_display_fields({ output = "desc:GVT Kuycon P20", mode = kuycon and kuycon.mode or shipped_mode, position = display_position(kuycon, "0x0"), scale = kuycon and kuycon.scale or shipped_scale, transform = kuycon and kuycon.transform or shipped_transform, - - -- 10-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of - -- DP 1.4 HBR3, so this relies on DSC. If the display fails to light up or - -- falls back to a lower mode, drop this line first. - bitdepth = 10, - - -- "auto" = sRGB at 8bpc, wide gamut at 10bpc. Not HDR; see header. - cm = "auto", -}) +}, kuycon, "DP-2", shipped_bitdepth, shipped_cm)) -- Any monitor not named above: sane defaults rather than nothing. hl.monitor({ diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index 1fddf84..5fe3b66 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -1583,16 +1583,16 @@ Singleton { }, // ── Display configuration ─────────────────────────────────────────── - // { "": { mode, scale, transform, x, y, primary } }, applied by - // hypr/monitors.lua on top of the shipped values. Color management and - // bit depth are deliberately not here: those carry a documented - // screencopy tradeoff that a settings page cannot explain at the moment - // you would be changing it. + // { "": { mode, scale, transform, x, y, primary, vrrMode, + // colorProfile, bitdepth, sdrBrightness, sdrSaturation, mirrorOf } }, + // applied by hypr/monitors.lua on top of the shipped values. Everything + // past `primary` is optional, so records written before those fields + // existed still load and the shipped defaults stand for them. { key: "displays", type: "json", def: ({}), group: "display", internal: true, label: "Display configuration", - detail: "Resolution, scale, rotation, position, and primary display" + detail: "Resolution, scale, rotation, position, primary display, color, VRR override, and mirroring" }, // ── Per-application notification rules ────────────────────────────── diff --git a/config/dot/quickshell/display-arrangement-harness.qml b/config/dot/quickshell/display-arrangement-harness.qml index 332c6fd..d587be6 100644 --- a/config/dot/quickshell/display-arrangement-harness.qml +++ b/config/dot/quickshell/display-arrangement-harness.qml @@ -75,6 +75,36 @@ ShellRoot { }))); } + // One display is the common case on a laptop, and the canvas is the + // page's hero now: it renders that display rather than disappearing and + // leaving a picker for a list of one. Only dragging goes away, because + // there is nothing to arrange it against. + function soloFixture(): string { + const previous = fixtureService.monitors; + fixtureService.monitors = [previous[0]]; + arrangement.resetDraft(); + const snapshot = arrangement.canvasSnapshot(); + fixtureService.monitors = previous; + arrangement.resetDraft(); + return JSON.stringify(snapshot); + } + + // A mirrored display has no position of its own -- the compositor puts + // it on top of its target -- so the canvas stacks it there and says so + // rather than drawing it wherever its stale coordinates point. + function mirrorFixture(): string { + const previous = fixtureService.monitors; + fixtureService.monitors = [ + previous[0], + Object.assign({}, previous[1], { mirrorOf: "DP-2" }) + ]; + arrangement.resetDraft(); + const snapshot = arrangement.canvasSnapshot(); + fixtureService.monitors = previous; + arrangement.resetDraft(); + return JSON.stringify(snapshot); + } + function identify(): void { Displays.identify(); } function identifying(): bool { return Displays.identifying; } } diff --git a/config/dot/quickshell/display-layout-harness.qml b/config/dot/quickshell/display-layout-harness.qml index a33ffa4..743d04f 100644 --- a/config/dot/quickshell/display-layout-harness.qml +++ b/config/dot/quickshell/display-layout-harness.qml @@ -10,9 +10,53 @@ ShellRoot { { name: "HDMI-A-1", width: 2560, height: 1440, scale: 1, transform: 1, x: 3140, y: 80, primary: false } ] + // The same two displays, with the second mirroring the first. A mirrored + // display has no position of its own: the compositor puts it on top of its + // target, so its stored coordinates are stale the moment mirroring is on. + readonly property var mirrored: [ + { name: "DP-2", width: 4500, height: 3000, scale: 1.5, transform: 0, x: 140, y: 80, primary: true, mirrorOf: "" }, + { name: "HDMI-A-1", width: 2560, height: 1440, scale: 1, transform: 0, x: 3140, y: 80, primary: false, mirrorOf: "DP-2" } + ] + IpcHandler { target: "display-layout-test" + function mirror(): string { + const normalized = DisplayLayout.normalize(mirrored); + const canvas = DisplayLayout.canvasRects(normalized, 800, 500, 20); + return JSON.stringify({ + valid: DisplayLayout.validate(mirrored), + normalized: normalized.map(record => ({ + name: record.name, x: record.x, y: record.y, + primary: record.primary, mirrorOf: record.mirrorOf ?? "" + })), + bounds: canvas.bounds, + rects: canvas.rects + }); + } + + function invalidMirrors(): string { + const base = mirrored.map(record => Object.assign({}, record)); + const cases = []; + const add = layout => cases.push(DisplayLayout.validate(layout)); + // Mirroring itself. + add([base[0], Object.assign({}, base[1], { mirrorOf: "HDMI-A-1" })]); + // Mirroring an output that is not in the layout. + add([base[0], Object.assign({}, base[1], { mirrorOf: "NOPE-1" })]); + // The primary may not mirror: the desktop is anchored on it. + add([Object.assign({}, base[0], { mirrorOf: "HDMI-A-1" }), base[1]]); + // No chains. Hyprland resolves a mirror to one target, and a chain + // is a question nobody can answer from the canvas. + add([ + base[0], + Object.assign({}, base[1], { mirrorOf: "DP-3" }), + { name: "DP-3", width: 1920, height: 1080, scale: 1, transform: 0, x: 6000, y: 0, primary: false, mirrorOf: "DP-2" } + ]); + // Not a name at all. + add([base[0], Object.assign({}, base[1], { mirrorOf: 5 })]); + return JSON.stringify(cases); + } + function status(): string { const normalized = DisplayLayout.normalize(fixture); const canvas = DisplayLayout.canvasRects(normalized, 800, 500, 20); diff --git a/config/dot/quickshell/displays-harness.qml b/config/dot/quickshell/displays-harness.qml index 05d460c..3301b06 100644 --- a/config/dot/quickshell/displays-harness.qml +++ b/config/dot/quickshell/displays-harness.qml @@ -112,6 +112,30 @@ ShellRoot { }); } + // The extended record — vrr override, colour profile, bit depth, SDR + // trim, mirroring — merged one field at a time, which is how every + // control on the page changes it. Base64 for the reason restorePlan + // documents above: `qs ipc call` splits JSON that looks like an array + // of objects into one argument per object. + function applyRecordFixture(output: string, patchB64: string): bool { + return Displays.applyRecord(output, JSON.parse(Qt.atob(patchB64))); + } + + // matchesLayout as a pure function. The readback carve-outs — a + // mirrored output's position, a framebuffer format Panama does not + // recognise — are decisions about what NOT to assert, and proving them + // through a compositor would mean owning a compositor that mirrors. + function layoutMatch(monitorsB64: string, layoutB64: string): bool { + return Displays.matchesLayout(JSON.parse(Qt.atob(monitorsB64)), + JSON.parse(Qt.atob(layoutB64))); + } + + // A settings.json written before any of the new fields existed is the + // common case on every machine that has this installed today. + function persistedEntryValid(entryB64: string): bool { + return Displays.isPersistedLayoutEntry(JSON.parse(Qt.atob(entryB64))); + } + function expireApplyVerification(): void { Displays.verificationTimedOut(); } diff --git a/config/dot/quickshell/manual/04-when-something-breaks.md b/config/dot/quickshell/manual/04-when-something-breaks.md index 064d5dc..0a87526 100644 --- a/config/dot/quickshell/manual/04-when-something-breaks.md +++ b/config/dot/quickshell/manual/04-when-something-breaks.md @@ -28,10 +28,13 @@ to load. `Hyprland --verify-config` says why without touching your session. ## The screen resolution is wrong -Settings has a Displays page. Every change there reverts itself after fifteen -seconds unless you confirm it, so a mode your monitor cannot show cannot -strand you. If you are already stranded, `hyprctl monitors` from a terminal -shows what is applied. +Settings has a Displays page. It opens on a picture of what is connected — +one display or several — and everything under that picture belongs to +whichever one you have selected. Every change there reverts itself after +fifteen seconds unless you confirm it, so a mode your monitor cannot show +cannot strand you, and that covers colour, bit depth and mirroring as well as +resolution, scale and rotation. If you are already stranded, `hyprctl +monitors` from a terminal shows what is applied. ## Something asked for a password and I do not know why diff --git a/config/dot/quickshell/modules/settings/ColorProfileTiles.qml b/config/dot/quickshell/modules/settings/ColorProfileTiles.qml new file mode 100644 index 0000000..23334e9 --- /dev/null +++ b/config/dot/quickshell/modules/settings/ColorProfileTiles.qml @@ -0,0 +1,164 @@ +// The four color profiles Hyprland's `cm` accepts, as swatches. +// +// A dropdown reading "srgb / wide / hdr" tells someone who already knows what +// they mean which one is set. The swatch is the difference itself: the same +// three primaries drawn narrow, drawn wide, and drawn against a range no SDR +// screen can show. +// +// The values come from Displays.colorProfiles so this can never offer one the +// service would refuse; the swatch and the sentence under each name live here, +// because they are how the choice is presented rather than what it is. + +import QtQuick +import qs.config +import qs.services + +Flow { + id: root + + property string current: "auto" + property bool enabled: true + + signal picked(string value) + + width: parent ? parent.width : 620 + spacing: 10 + bottomPadding: 12 + + readonly property var descriptions: ({ + "auto": "Let Hyprland choose from what the display reports", + "srgb": "Standard color, the safe default", + "wide": "The panel's full gamut, for photo and color work", + "hdr": "High dynamic range, where the display supports it" + }) + + // Four across when they fit, two across when they do not, and one across in + // a window narrow enough that two would be unreadable. + readonly property int columns: root.width >= 620 ? 4 : (root.width >= 340 ? 2 : 1) + readonly property real tileWidth: + (root.width - root.spacing * (root.columns - 1)) / root.columns + + Repeater { + model: Displays.colorProfiles + + Rectangle { + id: tile + + required property var modelData + + readonly property string value: String(tile.modelData.value) + readonly property bool selected: tile.value === root.current + + width: root.tileWidth + implicitHeight: body.implicitHeight + 22 + radius: Theme.cardRadius + opacity: root.enabled ? 1 : 0.5 + color: tile.selected + ? Theme.alpha(Theme.accent, 0.09) + : Theme.alpha(Theme.fg, tileHover.hovered && root.enabled ? 0.08 : 0.04) + border.width: tile.selected || tile.activeFocus ? 2 : 1 + border.color: tile.activeFocus + ? Theme.accentSecondary + : (tile.selected ? Theme.alpha(Theme.accent, 0.6) : Theme.alpha(Theme.fg, 0.08)) + activeFocusOnTab: root.enabled + + Accessible.role: Accessible.RadioButton + Accessible.name: String(tile.modelData.label ?? "") + Accessible.checked: tile.selected + + function choose(): void { + if (root.enabled && !tile.selected) + root.picked(tile.value); + } + + Keys.onReturnPressed: tile.choose() + Keys.onSpacePressed: tile.choose() + + Column { + id: body + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 11 + spacing: 7 + + // Automatic is the prism, because it is Panama deciding rather + // than a color space; sRGB is the same primaries pulled toward + // grey; wide is them at full strength; HDR runs from black + // through a highlight no SDR screen reaches. + Rectangle { + width: parent.width + height: 22 + radius: 6 + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.12) + + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { + position: 0.0 + color: { + if (tile.value === "auto") return Theme.accent; + if (tile.value === "srgb") return Theme.mix(Theme.red, Theme.fgMuted, 0.4); + if (tile.value === "wide") return Theme.red; + return Theme.bgDark; + } + } + GradientStop { + position: 0.5 + color: { + if (tile.value === "auto") return Theme.accentSecondary; + if (tile.value === "srgb") return Theme.mix(Theme.green, Theme.fgMuted, 0.4); + if (tile.value === "wide") return Theme.green; + return Theme.orange; + } + } + GradientStop { + position: 1.0 + color: { + if (tile.value === "auto") return Theme.teal; + if (tile.value === "srgb") return Theme.mix(Theme.accent, Theme.fgMuted, 0.4); + if (tile.value === "wide") return Theme.accent; + return Theme.fg; + } + } + } + } + + Text { + width: parent.width + text: String(tile.modelData.label ?? "") + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.DemiBold + elide: Text.ElideRight + } + + Text { + width: parent.width + text: String(root.descriptions[tile.value] ?? "") + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1) + wrapMode: Text.WordWrap + } + } + + HoverHandler { + id: tileHover + enabled: root.enabled + cursorShape: Qt.PointingHandCursor + } + + TapHandler { + enabled: root.enabled && !tile.selected + onTapped: { + tile.choose(); + tile.forceActiveFocus(); + } + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/CountdownRing.qml b/config/dot/quickshell/modules/settings/CountdownRing.qml new file mode 100644 index 0000000..5a49f2f --- /dev/null +++ b/config/dot/quickshell/modules/settings/CountdownRing.qml @@ -0,0 +1,73 @@ +// The fifteen seconds a display change has left, drawn as a ring. +// +// Repainted once per second, when `secondsLeft` changes, and never otherwise: +// this shell has no idle animation, and a countdown that redraws every frame +// would be one -- on a 240 Hz panel it is 240 repaints to move a number that +// changes once. + +import QtQuick +import qs.config + +Item { + id: root + + property int secondsLeft: 0 + property int totalSeconds: 15 + + implicitWidth: 40 + implicitHeight: 40 + + onSecondsLeftChanged: ring.requestPaint() + onTotalSecondsChanged: ring.requestPaint() + onVisibleChanged: if (root.visible) ring.requestPaint() + + Canvas { + id: ring + + anchors.fill: parent + + onPaint: { + const context = ring.getContext("2d"); + context.reset(); + + const stroke = 3.5; + const radius = Math.min(ring.width, ring.height) / 2 - stroke / 2 - 1; + const centreX = ring.width / 2; + const centreY = ring.height / 2; + if (radius <= 0) + return; + + context.lineWidth = stroke; + context.lineCap = "round"; + + context.strokeStyle = Theme.alpha(Theme.warn, 0.18); + context.beginPath(); + context.arc(centreX, centreY, radius, 0, Math.PI * 2); + context.stroke(); + + const remaining = root.totalSeconds > 0 + ? Math.max(0, Math.min(1, root.secondsLeft / root.totalSeconds)) + : 0; + if (remaining <= 0) + return; + + // Twelve o'clock, clockwise, so the arc empties the way a clock + // face does rather than unwinding backwards. + context.strokeStyle = Theme.warn; + context.beginPath(); + context.arc(centreX, centreY, radius, + -Math.PI / 2, -Math.PI / 2 + remaining * Math.PI * 2); + context.stroke(); + } + } + + Text { + anchors.centerIn: parent + text: String(Math.max(0, root.secondsLeft)) + color: Theme.warn + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.DemiBold + } +} diff --git a/config/dot/quickshell/modules/settings/DisplayArrangement.qml b/config/dot/quickshell/modules/settings/DisplayArrangement.qml index e5cbf8d..a72cdb4 100644 --- a/config/dot/quickshell/modules/settings/DisplayArrangement.qml +++ b/config/dot/quickshell/modules/settings/DisplayArrangement.qml @@ -1,3 +1,16 @@ +// Where the displays are, drawn to scale. +// +// The hero of the Displays page, and it renders whatever is connected -- +// including one display. A laptop used to open this page on a picker offering a +// list of one and never saw the canvas at all, which made the page's clearest +// surface the one thing a single-display machine could not have. Solo, the +// display is drawn centred and dragging goes away: there is nothing to arrange +// it against. +// +// A mirrored display has no position of its own. The compositor puts it on top +// of the display it mirrors, so the canvas stacks it there and says what it is +// mirroring, rather than drawing it wherever its stale coordinates point. + import QtQuick import qs.config import qs.widgets @@ -17,6 +30,34 @@ Item { readonly property var canvasData: DisplayLayout.canvasRects( root.draftLayout, canvas.width, canvas.height, 18) + readonly property bool solo: root.draftLayout.length <= 1 + readonly property bool draggable: root.interactionEnabled && !root.solo + + // What is drawn, which is not quite what the layout resolved to: a single + // display scaled to fill the whole canvas reads as a wall rather than as a + // screen on a desk, so it is drawn at a size that leaves it room to sit in. + readonly property real soloFactor: 0.62 + readonly property var tiles: { + const rects = root.canvasData.rects; + if (rects.length !== 1) + return rects; + const rect = rects[0]; + return [Object.assign({}, rect, { + x: rect.x + rect.width * (1 - root.soloFactor) / 2, + y: rect.y + rect.height * (1 - root.soloFactor) / 2, + width: rect.width * root.soloFactor, + height: rect.height * root.soloFactor + })]; + } + + readonly property string hint: { + if (root.solo) + return "One display connected — plug in another to arrange"; + return root.width >= 600 + ? "Drag to arrange · arrows move 10 px · Shift moves 100 px" + : "Drag or use the arrow keys"; + } + function copied(layout): var { return (layout || []).map(record => Object.assign({}, record)); } @@ -55,10 +96,13 @@ Item { return root.applyDraft(); } + // The layout as it resolved, not as it is drawn: this is what the + // arrangement math produced, which is the thing worth asserting about. function canvasSnapshot(): var { return { bounds: root.canvasData.bounds, scale: root.canvasData.scale, + draggable: root.draggable, rects: root.canvasData.rects.map(record => Object.assign({}, record)) }; } @@ -82,28 +126,79 @@ Item { Rectangle { id: canvas width: parent.width - height: root.width >= 620 ? 232 : 190 - radius: Theme.cardRadius + height: root.width >= 620 ? 250 : 200 + radius: Theme.cardRadius + 2 color: Theme.alpha(Theme.bgDark, 0.76) border.width: 1 border.color: Theme.alpha(Theme.fg, 0.07) clip: true + PrismEdge { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + inset: parent.radius + opacity: 0.36 + z: 2 + } + + // Light from above, so the screens read as objects standing on a + // surface rather than as boxes floating in a field. + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: parent.height * 0.55 + border.width: 0 + gradient: Gradient { + GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.05) } + GradientStop { position: 1.0; color: Theme.alpha(Theme.accent, 0.0) } + } + } + // A restrained coordinate field makes the topology feel like a // precision instrument without turning it into a technical graph. Repeater { - model: 4 + model: Math.max(1, Math.ceil(canvas.height / 44)) Rectangle { required property int index - y: (index + 1) * canvas.height / 5 + y: (index + 1) * 44 width: canvas.width height: 1 - color: Theme.alpha(Theme.fg, 0.025) + color: Theme.alpha(Theme.fg, 0.03) } } Repeater { - model: root.canvasData.rects + model: Math.max(1, Math.ceil(canvas.width / 44)) + Rectangle { + required property int index + x: (index + 1) * 44 + width: 1 + height: canvas.height + color: Theme.alpha(Theme.fg, 0.03) + } + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: parent.width * 0.1 + anchors.rightMargin: parent.width * 0.1 + anchors.bottom: parent.bottom + anchors.bottomMargin: 26 + height: 1 + border.width: 0 + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.0; color: Theme.alpha(Theme.fg, 0.0) } + GradientStop { position: 0.5; color: Theme.alpha(Theme.fg, 0.14) } + GradientStop { position: 1.0; color: Theme.alpha(Theme.fg, 0.0) } + } + } + + Repeater { + model: root.tiles Rectangle { id: tile @@ -113,26 +208,44 @@ Item { readonly property var draft: root.draftLayout.find( record => record.name === modelData.name) + // The rect carries the mirror flag; the draft record is the + // fallback for a layout that has not been through + // canvasRects yet. + readonly property string mirrorOf: { + if (tile.modelData.mirrorOf) + return String(tile.modelData.mirrorOf); + return tile.draft && tile.draft.mirrorOf ? String(tile.draft.mirrorOf) : ""; + } + readonly property bool mirrored: tile.mirrorOf !== "" + readonly property bool tileDraggable: root.draggable && !tile.mirrored + x: modelData.x y: modelData.y + z: tile.mirrored ? 1 : 0 width: Math.max(64, modelData.width) height: Math.max(48, modelData.height) radius: 11 color: tile.selected ? Theme.alpha(Theme.bgHighlight, 0.92) - : Theme.alpha(Theme.bgPanel, hover.hovered ? 0.94 : 0.78) + : Theme.alpha(Theme.bgPanel, hover.hovered ? 0.94 : 0.82) border.width: tile.selected || activeFocus ? 2 : 1 border.color: activeFocus ? Theme.accentSecondary - : (tile.selected ? Theme.accent : Theme.alpha(Theme.fg, 0.14)) + : (tile.selected ? Theme.accent : Theme.alpha(Theme.fg, 0.18)) opacity: root.interactionEnabled ? 1 : 0.5 activeFocusOnTab: root.interactionEnabled Accessible.role: Accessible.Button Accessible.name: "Move " + tile.modelData.name - Accessible.description: tile.draft && tile.draft.primary - ? "Primary display. Drag or use the arrow keys to move it." - : "Drag or use the arrow keys to move this display." + Accessible.description: { + if (tile.mirrored) + return "Mirroring " + tile.mirrorOf + ". Its position is chosen by the compositor."; + if (!tile.tileDraggable) + return "The only connected display. There is nothing to arrange it against."; + return tile.draft && tile.draft.primary + ? "Primary display. Drag or use the arrow keys to move it." + : "Drag or use the arrow keys to move this display."; + } Rectangle { anchors.fill: parent @@ -147,6 +260,21 @@ Item { } } + // The strip a bar would occupy, so the top of the picture is + // the top of the tile without anything having to say so. + Rectangle { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 5 + anchors.leftMargin: 8 + anchors.rightMargin: 8 + height: 3 + radius: 2 + border.width: 0 + color: Theme.alpha(Theme.fg, 0.14) + } + Column { anchors.left: parent.left anchors.right: parent.right @@ -165,9 +293,13 @@ Item { } Text { width: parent.width - text: tile.draft - ? `${Math.round(tile.draft.width / tile.draft.scale)} × ${Math.round(tile.draft.height / tile.draft.scale)}` - : "" + text: { + if (!tile.draft) + return ""; + const size = DisplayLayout.logicalSize(tile.draft); + const caption = `${Math.round(size.width)} × ${Math.round(size.height)}`; + return tile.width >= 150 ? caption + " points" : caption; + } color: Theme.fgDim font.family: Theme.fontFamily font.features: Theme.tabularFigures @@ -176,21 +308,38 @@ Item { } } - Rectangle { + Text { anchors.top: parent.top anchors.right: parent.right - anchors.margins: 7 - width: primaryText.implicitWidth + 12 - height: 20 - radius: Theme.pillRadius + anchors.topMargin: 7 + anchors.rightMargin: 9 visible: tile.draft && tile.draft.primary - color: Theme.alpha(Theme.accent, 0.2) + text: "★" + color: Theme.warn + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + + Accessible.role: Accessible.StaticText + Accessible.name: "Primary display" + } + + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: 7 + visible: tile.mirrored + width: visible ? mirrorLabel.implicitWidth + 14 : 0 + height: 19 + radius: Theme.pillRadius + color: Theme.alpha(Theme.cyan, 0.1) + border.width: 1 + border.color: Theme.alpha(Theme.cyan, 0.3) Text { - id: primaryText + id: mirrorLabel anchors.centerIn: parent - text: "Primary" - color: Theme.accentAlt + text: "Mirrors " + tile.mirrorOf + color: Theme.cyan font.family: Theme.fontFamily font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1) font.weight: Font.DemiBold @@ -200,7 +349,7 @@ Item { HoverHandler { id: hover enabled: root.interactionEnabled - cursorShape: Qt.OpenHandCursor + cursorShape: tile.tileDraggable ? Qt.OpenHandCursor : Qt.PointingHandCursor } TapHandler { @@ -214,7 +363,7 @@ Item { DragHandler { id: drag target: null - enabled: root.interactionEnabled + enabled: tile.tileDraggable property real initialX: 0 property real initialY: 0 property bool moved: false @@ -250,7 +399,7 @@ Item { } Keys.onPressed: event => { - if (!root.interactionEnabled) + if (!tile.tileDraggable) return; const step = event.modifiers & Qt.ShiftModifier ? 100 : 10; let handled = true; @@ -278,19 +427,6 @@ Item { width: parent.width spacing: 8 - Text { - width: Math.max(0, parent.width - identifyButton.width - - primaryButton.width - applyButton.width - 24) - anchors.verticalCenter: parent.verticalCenter - text: root.width >= 600 - ? "Drag to arrange · arrows move 10 px · Shift moves 100 px" - : "Drag or use the arrow keys" - color: Theme.fgMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSmall - elide: Text.ElideRight - } - SettingsButton { id: identifyButton text: "Identify" @@ -301,17 +437,21 @@ Item { SettingsButton { id: primaryButton text: "Make primary" - enabled: root.interactionEnabled && root.selectedOutput !== "" + enabled: root.interactionEnabled && !root.solo && root.selectedOutput !== "" && !root.draftLayout.find(record => record.name === root.selectedOutput)?.primary onClicked: root.makePrimary(root.selectedOutput) } - SettingsButton { - id: applyButton - text: "Apply" - enabled: root.interactionEnabled - onClicked: root.applyDraft() + Text { + width: Math.max(0, parent.width - identifyButton.width - primaryButton.width - 16) + anchors.verticalCenter: parent.verticalCenter + horizontalAlignment: Text.AlignRight + text: root.hint + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + elide: Text.ElideRight } } } diff --git a/config/dot/quickshell/modules/settings/DisplayChips.qml b/config/dot/quickshell/modules/settings/DisplayChips.qml new file mode 100644 index 0000000..0892fb5 --- /dev/null +++ b/config/dot/quickshell/modules/settings/DisplayChips.qml @@ -0,0 +1,164 @@ +// The connected displays, as chips under the arrangement canvas. +// +// This replaces the "Connected display" picker card. Selecting a display is not +// a setting -- it decides which display everything below the canvas is talking +// about -- so it reads as a row of tabs rather than as a preference with a +// value. Clicking a tile on the canvas does the same thing; this is the same +// choice for anyone who would rather read names than shapes. +// +// Hidden for a single display, because a chooser offering one option is not a +// choice. + +import QtQuick +import qs.config + +Flow { + id: root + + // [{ value, label, detail, primary }] + property var options: [] + property string current: "" + property bool enabled: true + + signal picked(string value) + + visible: root.options.length > 1 + width: parent ? parent.width : 620 + height: root.visible ? implicitHeight : 0 + spacing: 10 + + // Chips share the width evenly while they fit; below that they wrap into + // rows of whatever does fit, so a dock with four outputs is still legible + // in a half-screen window. + readonly property int columns: Math.max(1, + Math.min(root.options.length, Math.floor(root.width / 210))) + readonly property real chipWidth: root.columns > 0 + ? (root.width - root.spacing * (root.columns - 1)) / root.columns + : root.width + + Repeater { + model: root.options + + Rectangle { + id: chip + + required property var modelData + required property int index + + readonly property bool selected: String(chip.modelData.value) === root.current + + width: root.chipWidth + implicitHeight: 52 + radius: Theme.cardRadius + opacity: root.enabled ? 1 : 0.5 + color: chip.selected + ? Theme.alpha(Theme.accent, 0.1) + : Theme.alpha(Theme.bgPanel, chipHover.hovered && root.enabled ? 0.9 : 0.72) + border.width: chip.selected || chip.activeFocus ? 2 : 1 + border.color: chip.activeFocus + ? Theme.accentSecondary + : (chip.selected ? Theme.alpha(Theme.accent, 0.55) : Theme.alpha(Theme.fg, 0.07)) + activeFocusOnTab: root.enabled + + Accessible.role: Accessible.Button + Accessible.name: "Select " + String(chip.modelData.label ?? "") + Accessible.focusable: true + Accessible.focused: chip.activeFocus + + function choose(): void { + if (root.enabled) + root.picked(String(chip.modelData.value)); + } + + Keys.onReturnPressed: chip.choose() + Keys.onSpacePressed: chip.choose() + + // A screen, small. The second display gets the other end of the + // palette so the two chips are told apart at a glance rather than + // by reading them. + Rectangle { + id: thumbnail + + anchors.left: parent.left + anchors.leftMargin: 13 + anchors.verticalCenter: parent.verticalCenter + width: 34 + height: 24 + radius: 5 + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.25) + + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { + position: 0.0 + color: Theme.alpha(chip.index % 2 === 0 ? Theme.accent : Theme.teal, 0.5) + } + GradientStop { + position: 1.0 + color: Theme.alpha(chip.index % 2 === 0 ? Theme.accentSecondary : Theme.cyan, 0.4) + } + } + } + + Column { + anchors.left: thumbnail.right + anchors.leftMargin: 11 + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + spacing: 1 + + Row { + width: parent.width + spacing: 5 + + Text { + width: Math.max(0, parent.width - (star.visible ? star.width + 5 : 0)) + text: String(chip.modelData.label ?? "") + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.DemiBold + elide: Text.ElideRight + } + + Text { + id: star + visible: chip.modelData.primary === true + width: visible ? implicitWidth : 0 + text: "★" + color: Theme.warn + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + } + + Text { + width: parent.width + visible: text !== "" + text: String(chip.modelData.detail ?? "") + color: Theme.fgDim + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1) + elide: Text.ElideRight + } + } + + HoverHandler { + id: chipHover + enabled: root.enabled + cursorShape: Qt.PointingHandCursor + } + + TapHandler { + enabled: root.enabled && !chip.selected + onTapped: { + chip.choose(); + chip.forceActiveFocus(); + } + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/DisplayModePicker.qml b/config/dot/quickshell/modules/settings/DisplayModePicker.qml index a7e9da7..1ba0872 100644 --- a/config/dot/quickshell/modules/settings/DisplayModePicker.qml +++ b/config/dot/quickshell/modules/settings/DisplayModePicker.qml @@ -1,13 +1,21 @@ // The resolution list for one display. // -// Grouped by resolution with refresh rates beside it, rather than a flat list -// of "3840x2160@60.00Hz" strings: this panel reports 35 modes, many of which -// differ only in refresh-rate rounding, and a flat list of those is a wall of -// near-identical text rather than a choice. +// One line per resolution, not per mode: this panel reports 35 modes, most of +// which differ only in refresh-rate rounding, and a flat list of +// "3840x2160@60.00Hz" strings is a wall of near-identical text rather than a +// choice. The rates live in their own row on the page, where changing only the +// rate does not mean going back through the resolution you already had. +// +// Each line carries the two facts that decide the choice: the shape of the +// picture, and whether this is the one the panel was built for. +// +// Applying is the page's job. Every per-display edit on the Displays page goes +// through one funnel so the whole record -- position, colour, mirror state -- +// rides the same keep-or-revert transaction; this reports which resolution was +// asked for and lets that funnel do the rest. import QtQuick import qs.config -import qs.services Column { id: root @@ -15,9 +23,10 @@ Column { property var monitor: null property bool enabled: true - // Emitted once a mode has been asked for, so a container can put the list - // away. Applying is still this component's job; closing is not. - signal picked + // The mode string for the resolution that was chosen, at the rate closest + // to the one in use. + signal picked(string mode) + spacing: 0 readonly property var grouped: { @@ -36,6 +45,44 @@ Column { return order.map(key => buckets[key]); } + // Modes arrive sorted by area, so the first is the largest the panel + // advertises -- which is the one it was built for. + readonly property string nativeLabel: root.grouped.length > 0 ? root.grouped[0].label : "" + + function aspectLabel(width: int, height: int): string { + if (!width || !height) + return ""; + const ratio = width / height; + const named = [ + { ratio: 1, label: "1:1" }, + { ratio: 5 / 4, label: "5:4" }, + { ratio: 4 / 3, label: "4:3" }, + { ratio: 3 / 2, label: "3:2" }, + { ratio: 16 / 10, label: "16:10" }, + { ratio: 16 / 9, label: "16:9" }, + { ratio: 21 / 9, label: "21:9" }, + { ratio: 32 / 9, label: "32:9" } + ]; + for (const entry of named) { + if (Math.abs(ratio - entry.ratio) < 0.05) + return entry.label; + } + const reduce = (a, b) => b === 0 ? a : reduce(b, a % b); + const divisor = reduce(width, height) || 1; + return `${Math.round(width / divisor)}:${Math.round(height / divisor)}`; + } + + // The rate nearest the one in use, so changing the resolution does not + // quietly change the refresh rate as well when the panel offers the same + // one at the new size. + function modeFor(group: var): string { + const target = root.monitor ? root.monitor.refreshRate : 0; + const rates = group.rates.slice().sort((left, right) => + Math.abs(left.refresh - target) - Math.abs(right.refresh - target) + || right.refresh - left.refresh); + return rates.length > 0 ? rates[0].mode : ""; + } + Repeater { model: root.grouped @@ -45,79 +92,66 @@ Column { required property var modelData required property int index - readonly property bool isCurrent: root.monitor + readonly property bool isCurrent: !!root.monitor && root.monitor.width === resolution.modelData.width && root.monitor.height === resolution.modelData.height + readonly property bool isNative: resolution.modelData.label === root.nativeLabel + readonly property string tag: { + if (resolution.isCurrent && resolution.isNative) + return "Current · Native"; + if (resolution.isCurrent) + return "Current"; + return resolution.isNative ? "Native" : ""; + } + width: parent.width label: resolution.modelData.label - detail: resolution.isCurrent ? "Current resolution" : "" - controlWidth: Math.max(120, resolution.modelData.rates.length * 84) + controlWidth: 170 divider: resolution.index < root.grouped.length - 1 + opacity: root.enabled ? 1 : 0.45 + activatable: root.enabled && !resolution.isCurrent + onActivated: { + const mode = root.modeFor(resolution.modelData); + if (mode !== "") + root.picked(mode); + } Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - spacing: 6 + spacing: 10 - Repeater { - model: resolution.modelData.rates + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.aspectLabel(resolution.modelData.width, resolution.modelData.height) + color: Theme.fgMuted + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSizeSmall + } - Rectangle { - id: rate + Rectangle { + anchors.verticalCenter: parent.verticalCenter + visible: resolution.tag !== "" + width: visible ? tagLabel.implicitWidth + 18 : 0 + height: 21 + radius: Theme.pillRadius + color: resolution.isCurrent + ? Theme.alpha(Theme.accent, 0.1) + : Theme.alpha(Theme.fg, 0.06) + border.width: 1 + border.color: resolution.isCurrent + ? Theme.alpha(Theme.accent, 0.25) + : Theme.alpha(Theme.fg, 0.1) - required property var modelData - - readonly property bool selected: resolution.isCurrent - && Displays.modeIsCurrent(root.monitor, rate.modelData) - - implicitWidth: Math.max(74, rateCaption.implicitWidth + 22) - implicitHeight: 30 - radius: 9 - opacity: root.enabled ? 1 : 0.45 - color: rate.selected ? "transparent" : Theme.alpha(Theme.fg, rateHover.hovered && root.enabled ? 0.11 : 0.06) - border.width: rate.selected ? 1 : 0 - border.color: Theme.alpha(Theme.accent, 0.5) - - Rectangle { - anchors.fill: parent - radius: parent.radius - visible: rate.selected - border.width: 0 - gradient: Gradient { - orientation: Gradient.Horizontal - GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.28) } - GradientStop { position: 1.0; color: Theme.alpha(Theme.accentSecondary, 0.28) } - } - } - - Text { - id: rateCaption - anchors.centerIn: parent - text: rate.modelData.refreshLabel - color: rate.selected ? Theme.fg : Theme.fgDim - font.family: Theme.fontFamily - font.features: Theme.tabularFigures - font.pixelSize: Theme.fontSizeSmall - font.weight: rate.selected ? Font.DemiBold : Font.Normal - } - - HoverHandler { - id: rateHover - enabled: root.enabled - cursorShape: Qt.PointingHandCursor - } - - TapHandler { - enabled: root.enabled && !rate.selected - onTapped: { - Displays.apply( - root.monitor.name, - rate.modelData.mode, - Displays.nearestCleanScale(rate.modelData.mode, root.monitor.scale), - root.monitor.transform); - root.picked(); - } - } + Text { + id: tagLabel + anchors.centerIn: parent + text: resolution.tag + color: resolution.isCurrent ? Theme.accentAlt : Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1) + font.weight: Font.DemiBold } } } diff --git a/config/dot/quickshell/modules/settings/DisplayPanelHeader.qml b/config/dot/quickshell/modules/settings/DisplayPanelHeader.qml new file mode 100644 index 0000000..66f7e64 --- /dev/null +++ b/config/dot/quickshell/modules/settings/DisplayPanelHeader.qml @@ -0,0 +1,165 @@ +// The identity of the display everything below it belongs to. +// +// A card title would say the same words in the same place, but this panel has +// to carry four facts at once -- which display, on which connector, what it is +// showing right now, and whether Panama is overriding it -- and a title with a +// subtitle can only carry two of them. + +import QtQuick +import QtQuick.Controls +import qs.config + +Item { + id: root + + property string title: "" + property string connector: "" + property string meta: "" + property bool overridden: false + property bool enabled: true + + signal forgetRequested + + width: parent ? parent.width : 620 + implicitHeight: Math.max(44, copy.implicitHeight) + 14 + + // A screen on a stand. Drawn rather than iconified: no symbolic icon in the + // set reads as "this particular display" beside its own name. + Rectangle { + id: glyph + + anchors.left: parent.left + anchors.top: parent.top + anchors.topMargin: 2 + width: 44 + height: 33 + radius: 7 + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.3) + + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.45) } + GradientStop { position: 1.0; color: Theme.alpha(Theme.accentSecondary, 0.35) } + } + + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.bottom + anchors.topMargin: 2 + width: 16 + height: 3 + radius: 2 + border.width: 0 + color: Theme.alpha(Theme.fg, 0.25) + } + } + + Column { + id: copy + + anchors.left: glyph.right + anchors.leftMargin: 13 + anchors.right: actions.left + anchors.rightMargin: 12 + anchors.top: parent.top + spacing: 3 + + Row { + width: parent.width + spacing: 8 + + Text { + anchors.verticalCenter: parent.verticalCenter + width: Math.max(0, parent.width - (connectorChip.visible ? connectorChip.width + 8 : 0)) + text: root.title + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.DemiBold + elide: Text.ElideRight + } + + Rectangle { + id: connectorChip + + anchors.verticalCenter: parent.verticalCenter + visible: root.connector !== "" + width: visible ? connectorLabel.implicitWidth + 16 : 0 + height: 19 + radius: Theme.pillRadius + color: Theme.alpha(Theme.fg, 0.08) + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.12) + + Text { + id: connectorLabel + anchors.centerIn: parent + text: root.connector + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1) + font.weight: Font.DemiBold + } + } + } + + Text { + width: parent.width + visible: root.meta !== "" + text: root.meta + color: Theme.fgDim + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSizeSmall + elide: Text.ElideRight + } + } + + Row { + id: actions + + anchors.right: parent.right + anchors.top: parent.top + anchors.topMargin: 4 + spacing: 8 + + Rectangle { + id: customPill + + anchors.verticalCenter: parent.verticalCenter + visible: root.overridden + width: visible ? customLabel.implicitWidth + 20 : 0 + height: 24 + radius: Theme.pillRadius + color: Theme.alpha(Theme.accent, 0.1) + border.width: 1 + border.color: Theme.alpha(Theme.accent, 0.25) + + ToolTip.visible: customHover.hovered + ToolTip.delay: 400 + ToolTip.text: "This display uses a setting you chose. Forget returns it to the one Panama ships." + + Text { + id: customLabel + anchors.centerIn: parent + text: "Custom setting" + color: Theme.accentAlt + font.family: Theme.fontFamily + font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1) + font.weight: Font.DemiBold + } + + HoverHandler { id: customHover } + } + + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + visible: root.overridden + width: visible ? implicitWidth : 0 + text: "Forget" + enabled: root.enabled + onClicked: root.forgetRequested() + } + } +} diff --git a/config/dot/quickshell/modules/settings/DisplaysPage.qml b/config/dot/quickshell/modules/settings/DisplaysPage.qml index 5008202..b1b1538 100644 --- a/config/dot/quickshell/modules/settings/DisplaysPage.qml +++ b/config/dot/quickshell/modules/settings/DisplaysPage.qml @@ -1,25 +1,31 @@ // Displays. // -// Resolution, refresh rate, scale, and rotation, plus panel brightness and the -// gaming display policy that was already here. +// The arrangement canvas is the page. Everything under it belongs to the +// display selected in it -- resolution, scale, rotation, color, hardware +// brightness, variable refresh, mirroring -- and the settings that belong to no +// display in particular are gathered at the bottom under "All displays". // -// Every geometry change goes through an apply-then-confirm countdown. This is -// the one page where a wrong value can leave the screen unreadable or blank, -// and no other control in the app can undo it once that happens. Confirming is -// what writes the choice to the settings store; letting the countdown run -// leaves nothing behind. +// Every per-display change goes through one funnel, applyWith, and therefore +// through the apply-then-confirm countdown. This is the one page where a wrong +// value can leave the screen unreadable or blank, and no other control in the +// app can undo it once that happens. Confirming is what writes the choice to +// the settings store; letting the countdown run leaves nothing behind. +// +// The one deliberate exception is brightness. It is hardware state rather than +// a stored preference: the monitor remembers it, the bezel buttons change it +// behind Panama's back, and there is nothing to read back and verify, so it is +// written straight to the panel and never enters the transaction. import QtQuick import qs.config import qs.services -import qs.modules.quicksettings import qs.widgets SettingsPage { id: root title: "Displays" - lede: SystemSettings.monitorDescription || "Reading the active display…" + lede: "Changes apply to every display together and revert on their own in 15 seconds unless you keep them." property string selectedOutput: "" readonly property var monitor: Displays.monitorNamed(root.selectedOutput) @@ -28,6 +34,18 @@ SettingsPage { ? root.monitor.mode : "" + // The complete record for the selected display, resolved exactly the way an + // apply resolves it: live readback for everything the compositor reports, + // and the stored entry for vrrMode, which it does not report. Reading it + // from the service rather than rebuilding it here is what stops the page + // from showing one thing while an apply carries another. + readonly property var record: { + const name = root.monitor ? root.monitor.name : ""; + if (name === "" || Displays.monitors.length === 0) + return null; + return Displays.currentLayout().find(entry => entry.name === name) ?? null; + } + // Every mode at the resolution in use, which is what a refresh-rate choice // actually is: the same width and height at a different rate. readonly property var ratesForCurrentResolution: { @@ -37,6 +55,71 @@ SettingsPage { && mode.height === root.monitor.height); } + readonly property var otherMonitors: Displays.monitors.filter(candidate => + !!root.monitor && candidate.name !== root.monitor.name) + + // Brightness is keyed by connector, the same name Hyprland uses, so a + // display either has a DDC entry or has no hardware brightness at all. + readonly property var brightnessEntry: root.monitor + ? Brightness.displayFor(root.monitor.name) + : null + + readonly property bool hdrSelected: !!root.record && root.record.colorProfile === "hdr" + readonly property bool mirrorPossible: Displays.monitors.length > 1 + && !!root.monitor && root.monitor.primary !== true + + readonly property string vrrPolicyLabel: { + const spec = PreferenceSchema.spec("vrrPolicy"); + const options = spec && spec.options ? spec.options : []; + const option = options.find(candidate => candidate.value === DesktopPreferences.get("vrrPolicy")); + return option ? String(option.label) : "the gaming policy"; + } + + function profileLabel(value: string): string { + const option = Displays.colorProfiles.find(candidate => candidate.value === value); + return option ? String(option.label) : "Automatic"; + } + + // What the display is showing right now, from readback -- not what was + // asked for. "auto" resolves to a concrete preset in the compositor, so + // this is the only place that says which one it landed on. + function liveColorSummary(): string { + if (!root.monitor) + return "Detecting"; + const preset = String(root.monitor.colorPreset || ""); + const pieces = [preset === "" ? "Color unreported" : root.profileLabel(preset)]; + if (root.monitor.bitdepth > 0) + pieces.push(root.monitor.bitdepth + "-bit"); + const summary = pieces.join(" · "); + return root.monitor.currentFormat !== "" + ? `${summary} (${root.monitor.currentFormat})` + : summary; + } + + function metaLine(): string { + if (!root.monitor) + return "Reading the active display…"; + const pieces = [ + `${root.monitor.width} × ${root.monitor.height} at ${Math.round(root.monitor.refreshRate)} Hz`, + `${Math.round(root.monitor.scale * 100)}% scale` + ]; + const profile = root.profileLabel(root.record ? root.record.colorProfile : "auto"); + pieces.push(root.monitor.bitdepth > 0 + ? `${profile} ${root.monitor.bitdepth}-bit` + : profile); + if (root.record && root.record.mirrorOf !== "") + pieces.push(`mirroring ${root.record.mirrorOf}`); + return pieces.join(" · "); + } + + function mirrorDetail(): string { + if (Displays.monitors.length < 2) + return "Mirroring needs a second connected display"; + if (root.monitor && root.monitor.primary === true) + return "The primary display cannot mirror another. Make a different display primary first."; + return "Extend the desktop, or show the same picture as another display"; + } + function syncSelectedOutput(): void { if (!Displays.monitorNamed(root.selectedOutput)) root.selectedOutput = Displays.primaryFirstMonitors.length > 0 @@ -56,13 +139,15 @@ SettingsPage { function onMonitorsChanged(): void { root.syncSelectedOutput(); } } - // The confirmation sits above everything, because while it is counting down - // it is the only thing that matters on this page. + // The confirmation sits above everything, pinned outside the scrolling + // surface, because while it is counting down it is the only thing on this + // page that matters -- and scrolling it away is exactly what someone does + // when they are looking for the setting that broke their screen. header: Component { Rectangle { visible: Displays.awaitingConfirmation - implicitHeight: visible ? confirmRow.implicitHeight + 28 : 0 - radius: Theme.cardRadius + implicitHeight: visible ? Math.max(44, confirmRow.implicitHeight) + 26 : 0 + radius: Theme.cardRadius + 2 color: Theme.mix(Theme.bgPanel, Theme.warn, 0.12) border.width: 1 border.color: Theme.alpha(Theme.warn, 0.4) @@ -75,14 +160,21 @@ SettingsPage { anchors.margins: 16 spacing: 14 + CountdownRing { + anchors.verticalCenter: parent.verticalCenter + secondsLeft: Displays.secondsLeft + totalSeconds: Displays.confirmSeconds + } + Column { - width: parent.width - keepButton.width - revertButton.width - 28 + width: Math.max(140, parent.width - 40 - keepButton.width + - revertButton.width - 42) anchors.verticalCenter: parent.verticalCenter spacing: 3 Text { width: parent.width - text: "Keep this display setting?" + text: "Keep these display settings?" color: Theme.fg font.family: Theme.fontFamily font.pixelSize: Theme.fontSize @@ -90,8 +182,11 @@ SettingsPage { } Text { width: parent.width - text: "Reverting in " + Displays.secondsLeft + (Displays.secondsLeft === 1 ? " second" : " seconds") - + " if you do nothing. If you cannot read this, just wait." + text: Displays.canConfirm + ? "Reverting in " + Displays.secondsLeft + + (Displays.secondsLeft === 1 ? " second" : " seconds") + + " if you do nothing. If you cannot read this, just wait." + : "Verifying with the compositor… If you cannot read this, just wait." color: Theme.fgDim font.family: Theme.fontFamily font.features: Theme.tabularFigures @@ -110,6 +205,7 @@ SettingsPage { id: keepButton anchors.verticalCenter: parent.verticalCenter text: "Keep" + tone: "accent" enabled: Displays.canConfirm onClicked: Displays.confirm() } @@ -117,194 +213,395 @@ SettingsPage { } } - SettingsCard { - visible: Displays.monitors.length > 1 - title: "Arrange displays" - subtitle: "Drag the screens into place. The primary display anchors the desktop at 0,0." - - DisplayArrangement { - width: parent.width - displayService: Displays - selectedOutput: root.selectedOutput - interactionEnabled: !Displays.awaitingConfirmation && !Displays.busy - onSelectionRequested: output => root.selectedOutput = output - } + // ── The canvas ────────────────────────────────────────────────────────── + DisplayArrangement { + width: parent.width + displayService: Displays + selectedOutput: root.selectedOutput + interactionEnabled: !Displays.awaitingConfirmation && !Displays.busy + onSelectionRequested: output => root.selectedOutput = output } - SettingsCard { - visible: Displays.monitors.length > 1 - title: "Connected display" - subtitle: "Choose the output whose resolution, scale, and rotation you want to adjust." - - ChoiceGrid { - width: parent.width - label: "Display" - options: Displays.primaryFirstMonitors.map(monitor => ({ - value: monitor.name, - label: monitor.description || monitor.name - })) - current: root.monitor ? root.monitor.name : "" - enabled: !Displays.awaitingConfirmation && !Displays.busy - divider: false - onPicked: value => root.selectedOutput = value - } - } - - SettingsCard { - title: root.monitor ? root.monitor.name : (SystemSettings.monitorName || "Active display") - subtitle: root.monitor - ? `${root.monitor.description} · ${root.monitor.width} × ${root.monitor.height} at ${Math.round(root.monitor.refreshRate)} Hz · ${root.monitor.scale.toFixed(2)}× scale` - : "Reading the active display…" - - TextRow { - label: "Color mode" - detail: "Wide-gamut SDR at 10-bit. Full-time HDR is left to the Hyprland config: it currently breaks screenshots, OBS, and the lock screen's blurred background." - value: root.monitor - ? `${root.monitor.colorPreset || "standard"} · ${root.monitor.currentFormat || "detecting format"}` - : "Detecting" - } - TextRow { - label: "Variable refresh" - detail: root.monitor && root.monitor.vrr - ? "Active on this output for current fullscreen content" - : "This output is ready when game or video content requests it" - value: root.monitor && root.monitor.vrr ? "Active" : "Standby" - divider: Displays.isOverridden(root.monitor ? root.monitor.name : "") - } - ActionRow { - visible: Displays.isOverridden(root.monitor ? root.monitor.name : "") - label: "Using a custom display setting" - detail: "Forget it to go back to the shipped resolution and scale" - action: "Forget" - divider: false - onTriggered: Displays.forget(root.monitor.name) - } + DisplayChips { + width: parent.width + options: Displays.primaryFirstMonitors.map(monitor => ({ + value: monitor.name, + label: monitor.description || monitor.name, + detail: `${monitor.name} · ${monitor.width} × ${monitor.height} at ${Math.round(monitor.refreshRate)} Hz`, + primary: monitor.primary === true + })) + current: root.monitor ? root.monitor.name : "" + enabled: !Displays.awaitingConfirmation && !Displays.busy + onPicked: value => root.selectedOutput = value } + // ── The selected display ──────────────────────────────────────────────── SettingsCard { visible: root.monitor !== null - title: "Resolution" - subtitle: "Applied straight away, then reverted automatically unless you confirm." + + DisplayPanelHeader { + width: parent.width + title: root.monitor ? (root.monitor.description || root.monitor.name) : "" + connector: root.monitor ? root.monitor.name : "" + meta: root.metaLine() + overridden: Displays.isOverridden(root.monitor ? root.monitor.name : "") + enabled: !Displays.awaitingConfirmation && !Displays.busy + onForgetRequested: { + if (root.monitor) + Displays.forget(root.monitor.name); + } + } PickerRow { id: modePicker label: "Resolution" - detail: root.monitor - ? root.monitor.width + " × " + root.monitor.height + " native" - : "No display selected" + detail: "The picture is sharpest at the resolution the panel was built for" value: root.monitor ? root.monitor.width + " × " + root.monitor.height : "" enabled: !Displays.awaitingConfirmation && !Displays.busy - divider: false DisplayModePicker { width: parent.width monitor: root.monitor enabled: !Displays.awaitingConfirmation && !Displays.busy - onPicked: modePicker.collapse() + onPicked: mode => { + root.applyWith({ mode: mode }); + modePicker.collapse(); + } } } // Refresh rate on its own, because the rates for a resolution used to be - // reachable only by opening the resolution list -- which is now - // collapsed, so changing only the rate meant going through the mode you - // already had. - PickerRow { - id: ratePicker - + // reachable only by opening the resolution list -- so changing only the + // rate meant going back through the resolution you already had. + SettingRow { label: "Refresh rate" - detail: "Rates this display offers at " + (root.monitor - ? root.monitor.width + " × " + root.monitor.height - : "the current resolution") - value: root.monitor ? root.monitor.refreshRate.toFixed(2) + " Hz" : "" + detail: root.monitor + ? "Rates this panel offers at " + root.monitor.width + " × " + root.monitor.height + : "" visible: root.ratesForCurrentResolution.length > 1 - enabled: !Displays.awaitingConfirmation && !Displays.busy - divider: false + controlWidth: Math.max(150, root.ratesForCurrentResolution.length * 76) - Repeater { - model: root.ratesForCurrentResolution + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 6 + opacity: !Displays.awaitingConfirmation && !Displays.busy ? 1 : 0.45 - delegate: TextRow { - required property var modelData - required property int index - width: parent.width - label: String(modelData.refreshLabel ?? "") - value: Displays.modeIsCurrent(root.monitor, modelData) ? "Current" : "" - controlWidth: 90 - divider: index < root.ratesForCurrentResolution.length - 1 - activatable: !Displays.modeIsCurrent(root.monitor, modelData) - onActivated: { - root.applyWith({ mode: modelData.mode }); - ratePicker.collapse(); + Repeater { + model: root.ratesForCurrentResolution + + Rectangle { + id: rate + + required property var modelData + + readonly property bool selected: Displays.modeIsCurrent(root.monitor, rate.modelData) + + implicitWidth: Math.max(70, rateCaption.implicitWidth + 22) + implicitHeight: 30 + radius: 9 + color: rate.selected + ? "transparent" + : Theme.alpha(Theme.fg, rateHover.hovered ? 0.11 : 0.06) + border.width: rate.selected ? 1 : 0 + border.color: Theme.alpha(Theme.accent, 0.5) + + Rectangle { + anchors.fill: parent + radius: parent.radius + visible: rate.selected + border.width: 0 + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.28) } + GradientStop { position: 1.0; color: Theme.alpha(Theme.accentSecondary, 0.28) } + } + } + + Text { + id: rateCaption + anchors.centerIn: parent + text: String(rate.modelData.refreshLabel ?? "") + color: rate.selected ? Theme.fg : Theme.fgDim + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSizeSmall + font.weight: rate.selected ? Font.DemiBold : Font.Medium + } + + HoverHandler { + id: rateHover + enabled: !Displays.awaitingConfirmation && !Displays.busy + cursorShape: Qt.PointingHandCursor + } + + TapHandler { + enabled: !Displays.awaitingConfirmation && !Displays.busy && !rate.selected + onTapped: root.applyWith({ mode: rate.modelData.mode }) + } } } } } - } - SettingsCard { - visible: root.monitor !== null - title: "Scale and rotation" - - ChoiceGrid { - width: parent.width + SegmentRow { label: "Scale" - detail: "Fractional scales that do not divide the resolution into whole pixels are rejected by the compositor, so only clean ones are offered." + detail: "Only scales that divide the resolution into whole pixels are offered — the compositor refuses the rest" options: Displays.scalesForMode(root.currentMode) - .map(scale => ({ value: scale, label: scale.toFixed(2) + "×" })) - current: root.monitor ? root.monitor.scale : 1 + .map(scale => ({ value: scale, label: Math.round(scale * 100) + "%" })) + value: root.monitor ? root.monitor.scale : 1 enabled: !Displays.awaitingConfirmation && !Displays.busy - onPicked: value => root.applyWith({ scale: value }) + onSelected: value => root.applyWith({ scale: value }) } - ChoiceGrid { - width: parent.width + RotationRow { label: "Rotation" options: Displays.transforms - current: root.monitor ? root.monitor.transform : 0 + value: root.monitor ? root.monitor.transform : 0 enabled: !Displays.awaitingConfirmation && !Displays.busy + onSelected: value => root.applyWith({ transform: value }) + } + + // Outside the transaction, deliberately: see the note at the top. + SettingRow { + visible: root.brightnessEntry !== null + label: "Brightness" + detail: Brightness.lastError !== "" + ? Brightness.lastError + : "Hardware brightness over DDC — the same dial as the monitor's buttons" + controlWidth: 250 + + Text { + id: brightnessReadout + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 44 + horizontalAlignment: Text.AlignRight + text: (root.brightnessEntry ? root.brightnessEntry.value : 0) + "%" + color: Theme.fgDim + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSize + } + + ValueSlider { + anchors.left: parent.left + anchors.right: brightnessReadout.left + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + value: root.brightnessEntry ? root.brightnessEntry.value / 100 : 0 + onMoved: ratio => { + if (root.brightnessEntry) + Brightness.set(root.brightnessEntry.bus, Math.round(ratio * 100)); + } + } + } + + OptionPickerRow { + label: "Variable refresh rate" + detail: root.record && root.record.vrrMode === -1 + ? `Following the gaming policy below, which is ${root.vrrPolicyLabel}` + : "This display overrides the gaming policy below" + options: Displays.vrrModes.map(mode => ({ + value: mode.value, + label: mode.label, + detail: root.vrrOptionDetail(mode.value) + })) + current: root.record ? root.record.vrrMode : -1 + enabled: !Displays.awaitingConfirmation && !Displays.busy + onPicked: value => root.applyWith({ vrrMode: value }) + } + + OptionPickerRow { + label: "Use as" + detail: root.mirrorDetail() + options: [{ + value: "", + label: "Extended display", + detail: "This display shows its own part of the desktop" + }].concat(root.otherMonitors.map(other => ({ + value: other.name, + label: "Mirror of " + (other.description || other.name), + detail: `Shows the same picture as ${other.name}, which the compositor places` + }))) + current: root.record ? root.record.mirrorOf : "" + enabled: !Displays.awaitingConfirmation && !Displays.busy && root.mirrorPossible divider: false - onPicked: value => root.applyWith({ transform: value }) + onPicked: value => root.applyWith({ mirrorOf: value }) } } + // ── Color ─────────────────────────────────────────────────────────────── SettingsCard { - title: "Night Light" - subtitle: NightLight.active - ? "On now, warming the display to reduce blue light." - : "Warms the display in the evening to reduce blue light." + visible: root.monitor !== null + title: "Color" + subtitle: "Color rides the same keep-or-revert transaction as resolution, so a profile the display refuses restores itself." - ToggleRow { setting: "nightLightEnabled" } - ToggleRow { setting: "nightLightAutomatic" } - TimeOfDayRow { setting: "nightLightFrom" } - TimeOfDayRow { setting: "nightLightTo" } - SliderRow { setting: "nightLightTemperature"; divider: false } + Text { + width: parent.width + horizontalAlignment: Text.AlignRight + text: "Now: " + root.liveColorSummary() + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + elide: Text.ElideRight + bottomPadding: 11 + } + + ColorProfileTiles { + width: parent.width + current: root.record ? root.record.colorProfile : "auto" + enabled: !Displays.awaitingConfirmation && !Displays.busy + onPicked: value => root.applyWith({ colorProfile: value }) + } + + SegmentRow { + label: "Bit depth" + detail: "10-bit reduces gradient banding, but some screen capture and recording tools can't read a 10-bit framebuffer" + options: Displays.bitdepths.map(depth => ({ value: depth, label: depth + "-bit" })) + value: root.record ? root.record.bitdepth : 8 + enabled: !Displays.awaitingConfirmation && !Displays.busy + divider: root.hdrSelected + onSelected: value => root.applyWith({ bitdepth: value }) + } + + GradientSliderRow { + id: sdrBrightnessRow + + visible: root.hdrSelected + label: "SDR brightness" + detail: "How bright regular, non-HDR content appears next to HDR content" + minimum: Displays.sdrBrightnessMin + maximum: Displays.sdrBrightnessMax + step: 0.05 + value: root.record ? root.record.sdrBrightness : 1 + readout: sdrBrightnessRow.shown.toFixed(2) + "×" + enabled: !Displays.awaitingConfirmation && !Displays.busy + onCommitted: amount => root.applyWith({ sdrBrightness: amount }) + } + + GradientSliderRow { + id: sdrSaturationRow + + visible: root.hdrSelected + label: "SDR saturation" + detail: "Compensates for washed-out colors in SDR content under HDR" + minimum: Displays.sdrSaturationMin + maximum: Displays.sdrSaturationMax + step: 0.05 + value: root.record ? root.record.sdrSaturation : 1 + readout: sdrSaturationRow.shown.toFixed(2) + "×" + enabled: !Displays.awaitingConfirmation && !Displays.busy + divider: false + onCommitted: amount => root.applyWith({ sdrSaturation: amount }) + } } - // Panel brightness, over DDC/CI. - // - // This is hardware state rather than a stored preference: the monitor - // remembers it, the bezel buttons change it behind Panama's back, and - // writing it into settings.json would mean restoring a value the panel had - // already moved on from. So there is no schema key here and no SliderRow -- - // the rows read and write the display directly. - // One brightness surface for both kinds this machine may have, shared - // with the quick-settings panel so the two can never disagree. This card - // was DDC/CI-only for a while, which on a laptop meant Settings showed - // an error about external-monitor brightness while the panel's backlight - // worked fine one panel over. - SettingsCard { - visible: pageBrightness.visible || Brightness.lastError !== "" - title: "Brightness" - subtitle: pageBrightness.visible - ? "The built-in panel through its backlight; external monitors over DDC/CI, the same channel their buttons use." - : Brightness.lastError + // ── Everything that belongs to no display in particular ───────────────── + Text { + width: parent.width + text: "All displays" + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.DemiBold + font.capitalization: Font.AllUppercase + font.letterSpacing: 0.7 + topPadding: 6 + } - BrightnessControl { - id: pageBrightness - width: parent.width + // Two cards side by side while there is room for two, and one above the + // other when there is not. This window is tiled: its width is anywhere from + // a half-screen split to the whole 4500px display. + Item { + id: globalGrid + + width: parent.width + readonly property bool twoUp: globalGrid.width >= 780 + readonly property real cardWidth: globalGrid.twoUp + ? (globalGrid.width - 16) / 2 + : globalGrid.width + implicitHeight: globalGrid.twoUp + ? Math.max(nightLightCard.implicitHeight, gamingCard.implicitHeight) + : nightLightCard.implicitHeight + 16 + gamingCard.implicitHeight + + SettingsCard { + id: nightLightCard + + width: globalGrid.cardWidth + title: "Night Light" + subtitle: NightLight.active + ? "On now, warming the display to reduce blue light." + : "Warms the display in the evening to reduce blue light." + + ToggleRow { setting: "nightLightEnabled" } + ToggleRow { setting: "nightLightAutomatic" } + TimeOfDayRow { setting: "nightLightFrom" } + TimeOfDayRow { setting: "nightLightTo" } + + GradientSliderRow { + id: temperatureRow + + readonly property var spec: PreferenceSchema.spec("nightLightTemperature") + + label: temperatureRow.spec ? temperatureRow.spec.label : "Color temperature" + detail: temperatureRow.spec ? temperatureRow.spec.detail : "" + minimum: temperatureRow.spec ? temperatureRow.spec.min : 2000 + maximum: temperatureRow.spec ? temperatureRow.spec.max : 6500 + step: temperatureRow.spec ? temperatureRow.spec.step : 100 + value: DesktopPreferences.get("nightLightTemperature") + readout: Math.round(temperatureRow.shown) + " K" + // The track is the setting: warm at the low end, daylight at + // the high one, so the number is a label rather than a riddle. + fullTrack: true + trackColors: [ + Theme.orange, + Theme.mix(Theme.yellow, Theme.fg, 0.35), + Theme.mix(Theme.accent, Theme.fg, 0.45) + ] + divider: false + onCommitted: kelvin => SystemSettings.commitPreference("nightLightTemperature", kelvin) + } + } + + SettingsCard { + id: gamingCard + + width: globalGrid.cardWidth + x: globalGrid.twoUp ? globalGrid.cardWidth + 16 : 0 + y: globalGrid.twoUp ? 0 : nightLightCard.implicitHeight + 16 + title: "Gaming" + subtitle: "Applied immediately and restored when the session starts." + + ToggleRow { setting: "autoHdr" } + + OptionPickerRow { + id: vrrPolicyRow + + readonly property var spec: PreferenceSchema.spec("vrrPolicy") + + label: vrrPolicyRow.spec ? vrrPolicyRow.spec.label : "Variable refresh rate" + detail: "The policy every display follows unless it overrides it above" + options: vrrPolicyRow.spec && vrrPolicyRow.spec.options ? vrrPolicyRow.spec.options : [] + current: DesktopPreferences.get("vrrPolicy") + onPicked: value => SystemSettings.commitPreference("vrrPolicy", value) + } + + OptionPickerRow { + id: scanoutRow + + readonly property var spec: PreferenceSchema.spec("directScanoutPolicy") + + label: scanoutRow.spec ? scanoutRow.spec.label : "Direct scanout" + detail: scanoutRow.spec ? scanoutRow.spec.detail : "" + options: scanoutRow.spec && scanoutRow.spec.options ? scanoutRow.spec.options : [] + current: DesktopPreferences.get("directScanoutPolicy") + divider: false + onPicked: value => SystemSettings.commitPreference("directScanoutPolicy", value) + } } } @@ -313,7 +610,7 @@ SettingsPage { SettingsCard { visible: Displays.monitors.length >= 2 title: "Workspaces" - subtitle: "GNOME asked this too. Off, every screen has its own workspaces and switching moves the one you are looking at; on, workspaces belong to the primary display and the others keep a screen of their own." + subtitle: "Off, every screen has its own workspaces and switching moves the one you are looking at; on, workspaces belong to the primary display and the others keep a screen of their own." SegmentRow { label: "Where workspaces live" @@ -353,33 +650,51 @@ SettingsPage { subtitle: Workspaces.lastError } - SettingsCard { - title: "Gaming display policy" - subtitle: "Applied immediately and restored when the session starts." - - ToggleRow { setting: "autoHdr" } - ChoiceRow { setting: "vrrPolicy" } - ChoiceRow { setting: "directScanoutPolicy"; divider: false } - } - SettingsCard { visible: Displays.lastError !== "" title: "Display problem" subtitle: Displays.lastError } - // Applies a change to one field, keeping the others at what is in effect. + // The brightness helper's explanation, which is usually the udev command + // that grants I2C access -- the difference between "no brightness control" + // and "brightness is one command away". It has nowhere else to go once the + // slider belongs to a display that does not answer over DDC. + SettingsCard { + visible: Brightness.lastError !== "" && Brightness.displays.length === 0 + title: "Brightness problem" + subtitle: Brightness.lastError + } + + function vrrOptionDetail(mode: int): string { + if (mode === -1) + return `Whatever the gaming policy below says, which is ${root.vrrPolicyLabel}`; + if (mode === 0) + return "This display runs at a fixed refresh rate"; + if (mode === 1) + return "Best on panels that handle low refresh rates without flicker"; + return "Any fullscreen window on this display, including video"; + } + + // The one funnel every per-display edit goes through. + // + // The service merges the change into the complete live record, so a change + // to one field carries every other field unchanged -- that is what stops an + // apply from dropping the color settings it never asked about. The only + // thing decided here is the scale, because a resolution and a scale are not + // independent: a scale that does not divide the new mode into whole pixels + // is refused by the compositor, so it moves to the nearest one that does. function applyWith(change: var): void { if (!root.monitor) return; - const mode = change.mode ?? root.currentMode; - const requestedScale = change.scale ?? root.monitor.scale; - Displays.apply( - root.monitor.name, - mode, - Displays.isScaleClean(mode, requestedScale) - ? requestedScale - : Displays.nearestCleanScale(mode, requestedScale), - change.transform ?? root.monitor.transform); + const partial = Object.assign({}, change); + if (partial.mode !== undefined || partial.scale !== undefined) { + const mode = partial.mode ?? root.currentMode; + const requested = partial.scale ?? root.monitor.scale; + partial.scale = Displays.isScaleClean(mode, requested) + ? requested + : Displays.nearestCleanScale(mode, requested); + } + Displays.applyRecord(root.monitor.name, partial); } } diff --git a/config/dot/quickshell/modules/settings/GradientSliderRow.qml b/config/dot/quickshell/modules/settings/GradientSliderRow.qml new file mode 100644 index 0000000..b827b37 --- /dev/null +++ b/config/dot/quickshell/modules/settings/GradientSliderRow.qml @@ -0,0 +1,230 @@ +// A slider whose track carries a meaning, and whose value is committed once the +// drag settles rather than on every pixel. +// +// Two rows on the Displays page need something SliderRow cannot give them: +// +// * The night light temperature, whose track should BE the warmth it sets -- +// a neutral track with a prism fill says nothing about what 2700 K looks +// like, and this is the one slider whose numbers most people cannot picture. +// * The SDR trims, which write through the display transaction. A transaction +// per pixel of drag would arm a fifteen-second countdown dozens of times; +// the value is applied once, when the pointer stops moving. +// +// Not schema-bound and not a SettingRow: the caller says what the value is and +// what to do with a new one, and the row stacks its control below the label in +// a narrow window exactly as SliderRow does. + +import QtQuick +import qs.config + +Item { + id: root + + property string label: "" + property string detail: "" + property bool divider: true + property bool enabled: true + + property real minimum: 0 + property real maximum: 100 + property real step: 1 + + // What is in effect. While a drag is in flight the row shows `pending` + // instead, and hands the display back once the commit has been made. + property real value: 0 + property var pending: null + readonly property real shown: root.pending !== null ? root.pending : root.value + + // The caller formats the number, because "1.15×", "3500 K" and "72%" have + // nothing in common but the digits. + property string readout: "" + + // Two or three colours, left to right. Painted across the whole track when + // `fullTrack` is set -- for a value whose range is the point, like colour + // temperature -- and as the fill alone otherwise. + property var trackColors: [Theme.accent, Theme.accentSecondary] + property bool fullTrack: false + + property int commitDelay: 320 + + signal committed(real value) + + readonly property bool inline: root.width >= 520 + readonly property int controlSpan: 300 + + function stopColor(index: int): color { + const colors = root.trackColors; + if (!colors || colors.length === 0) + return Theme.accent; + return colors[Math.min(index, colors.length - 1)]; + } + + function quantise(ratio: real): real { + const raw = root.minimum + ratio * (root.maximum - root.minimum); + const snapped = Math.round(raw / root.step) * root.step; + const clamped = Math.max(root.minimum, Math.min(root.maximum, snapped)); + return root.step < 1 ? Math.round(clamped * 100) / 100 : clamped; + } + + width: parent ? parent.width : 620 + implicitHeight: root.inline + ? Math.max(56, copy.implicitHeight + 20) + : copy.implicitHeight + 32 + 30 + opacity: root.enabled ? 1 : 0.45 + + Column { + id: copy + + x: 0 + y: root.inline ? (root.height - height) / 2 : 10 + width: root.inline ? root.width - root.controlSpan - 20 : root.width + spacing: 3 + + Text { + width: parent.width + text: root.label + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.Medium + elide: Text.ElideRight + } + + Text { + width: parent.width + visible: root.detail !== "" + text: root.detail + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.WordWrap + } + } + + Item { + id: control + + width: root.inline ? root.controlSpan : root.width + height: 32 + x: root.inline ? root.width - width : 0 + y: root.inline ? (root.height - height) / 2 : copy.y + copy.height + 10 + + Rectangle { + id: track + + anchors.left: parent.left + anchors.right: valueLabel.left + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + height: 10 + radius: 5 + border.width: 0 + color: Theme.alpha(Theme.fg, 0.12) + + readonly property real ratio: root.maximum > root.minimum + ? Math.max(0, Math.min(1, (root.shown - root.minimum) / (root.maximum - root.minimum))) + : 0 + + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: root.fullTrack ? parent.width : parent.width * track.ratio + radius: parent.radius + border.width: 0 + + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.0; color: root.stopColor(0) } + GradientStop { position: 0.5; color: root.stopColor(1) } + GradientStop { position: 1.0; color: root.stopColor(2) } + } + } + + Rectangle { + width: 16 + height: 16 + radius: 8 + border.width: 0 + color: Theme.fg + anchors.verticalCenter: parent.verticalCenter + x: Math.max(0, Math.min(parent.width - width, parent.width * track.ratio - width / 2)) + } + + MouseArea { + id: drag + + anchors.fill: parent + anchors.margins: -8 + enabled: root.enabled + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + // Local x is 8px ahead of the track's own origin because of the + // negative margin above; subtract it before turning the + // position into a fraction of the track. + function move(positionX: real): void { + root.pending = root.quantise( + Math.max(0, Math.min(1, (positionX - 8) / track.width))); + commitTimer.restart(); + } + + onPressed: event => drag.move(event.x) + onPositionChanged: event => { + if (drag.pressed) + drag.move(event.x); + } + onWheel: event => { + const direction = event.angleDelta.y > 0 ? 1 : -1; + root.pending = Math.max(root.minimum, + Math.min(root.maximum, root.shown + direction * root.step)); + commitTimer.restart(); + } + } + } + + Text { + id: valueLabel + + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 58 + horizontalAlignment: Text.AlignRight + text: root.readout + color: Theme.fgDim + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSize + elide: Text.ElideRight + } + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 1 + visible: root.divider + color: Theme.alpha(Theme.fg, 0.065) + } + + Timer { + id: commitTimer + interval: root.commitDelay + onTriggered: { + if (root.pending === null) + return; + root.committed(root.pending); + releaseTimer.restart(); + } + } + + // Hands the readout back to whatever is really in effect. If the change was + // refused -- an unclean value, a busy transaction -- the row snaps back to + // the value it had rather than showing one nothing accepted. + Timer { + id: releaseTimer + interval: 400 + onTriggered: root.pending = null + } +} diff --git a/config/dot/quickshell/modules/settings/OptionPickerRow.qml b/config/dot/quickshell/modules/settings/OptionPickerRow.qml new file mode 100644 index 0000000..d405036 --- /dev/null +++ b/config/dot/quickshell/modules/settings/OptionPickerRow.qml @@ -0,0 +1,49 @@ +// A choice with more options, or wider labels, than a segmented control can +// carry: collapsed to its current value, expanding to the list. +// +// ChoiceRow puts every option on one line, which works for two or three short +// ones and falls apart at four with sentences under them -- and on this page +// two cards sit side by side, so a row has half the width it used to. +// +// Not schema-bound. Some of these choices are Panama settings and some are +// display state that has to go through a keep-or-revert transaction, so the +// caller says what to do with the value rather than this writing it. + +import QtQuick +import qs.config + +PickerRow { + id: root + + // [{ value, label, detail }] + property var options: [] + property var current: null + + signal picked(var value) + + readonly property var currentOption: + root.options.find(option => option.value === root.current) ?? null + + value: root.currentOption ? String(root.currentOption.label ?? "") : "" + + Repeater { + model: root.options + + TextRow { + required property var modelData + required property int index + + width: parent.width + label: String(modelData.label ?? "") + detail: String(modelData.detail ?? "") + value: modelData.value === root.current ? "Current" : "" + controlWidth: 90 + divider: index < root.options.length - 1 + activatable: modelData.value !== root.current + onActivated: { + root.picked(modelData.value); + root.collapse(); + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/RotationRow.qml b/config/dot/quickshell/modules/settings/RotationRow.qml new file mode 100644 index 0000000..3b992b7 --- /dev/null +++ b/config/dot/quickshell/modules/settings/RotationRow.qml @@ -0,0 +1,115 @@ +// Rotation, as four little screens rather than four names. +// +// "Landscape (flipped)" and "Portrait (flipped)" are the two settings on this +// page nobody reads correctly the first time: the words describe the result, +// but what someone is looking for is the shape. The glyph is the shape, and the +// full name is a hover away for anyone who wants it spelled out. + +import QtQuick +import QtQuick.Controls +import qs.config + +SettingRow { + id: root + + // [{ value, label }] -- Displays.transforms, in its own order. + property var options: [] + property int value: 0 + property bool enabled: true + + signal selected(int value) + + controlWidth: Math.max(150, root.options.length * 48) + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 6 + opacity: root.enabled ? 1 : 0.45 + + Repeater { + model: root.options + + Rectangle { + id: segment + + required property var modelData + + readonly property bool current: root.value === segment.modelData.value + readonly property int orientation: Number(segment.modelData.value) + // 1 and 3 are the portrait transforms; 2 and 3 are the flipped + // ones. The glyph is those two facts drawn. + readonly property bool portrait: segment.orientation === 1 || segment.orientation === 3 + readonly property bool flipped: segment.orientation >= 2 + + width: 42 + height: 32 + radius: 9 + color: segment.current + ? Theme.alpha(Theme.accent, 0.22) + : Theme.alpha(Theme.fg, segmentHover.hovered && root.enabled ? 0.12 : 0.06) + border.width: 1 + border.color: segment.current + ? Theme.alpha(Theme.accent, 0.5) + : Theme.alpha(Theme.fg, 0.08) + + Accessible.role: Accessible.Button + Accessible.name: String(segment.modelData.label ?? "") + + ToolTip.visible: segmentHover.hovered + ToolTip.delay: 400 + ToolTip.text: String(segment.modelData.label ?? "") + + // The screen itself: portrait is the same rectangle stood up. + Rectangle { + anchors.centerIn: parent + width: segment.portrait ? 12 : 17 + height: segment.portrait ? 17 : 12 + radius: 3 + color: "transparent" + border.width: 2 + border.color: segment.current ? Theme.fg : Theme.fgDim + + // The thick edge is the bottom of the picture, so a flipped + // screen is one whose bottom is somewhere unexpected. Two + // rectangles rather than one with switched anchors: an + // anchor bound to undefined is not reliably released. + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: 2 + height: 3 + radius: 1 + border.width: 0 + visible: segment.flipped && !segment.portrait + color: segment.current ? Theme.fg : Theme.fgDim + } + + Rectangle { + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.margins: 2 + width: 3 + radius: 1 + border.width: 0 + visible: segment.flipped && segment.portrait + color: segment.current ? Theme.fg : Theme.fgDim + } + } + + HoverHandler { + id: segmentHover + enabled: root.enabled + cursorShape: Qt.PointingHandCursor + } + + TapHandler { + enabled: root.enabled && !segment.current + onTapped: root.selected(segment.orientation) + } + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index 78b7b70..3429ea0 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -66,6 +66,13 @@ ChoiceGrid 1.0 ChoiceGrid.qml DisplayModePicker 1.0 DisplayModePicker.qml DisplayArrangement 1.0 DisplayArrangement.qml DisplayIdentify 1.0 DisplayIdentify.qml +DisplayChips 1.0 DisplayChips.qml +DisplayPanelHeader 1.0 DisplayPanelHeader.qml +CountdownRing 1.0 CountdownRing.qml +RotationRow 1.0 RotationRow.qml +ColorProfileTiles 1.0 ColorProfileTiles.qml +GradientSliderRow 1.0 GradientSliderRow.qml +OptionPickerRow 1.0 OptionPickerRow.qml WifiPanel 1.0 WifiPanel.qml BluetoothPanel 1.0 BluetoothPanel.qml PasswordField 1.0 PasswordField.qml diff --git a/config/dot/quickshell/services/DisplayLayout.js b/config/dot/quickshell/services/DisplayLayout.js index 8641d3b..aaa46c0 100644 --- a/config/dot/quickshell/services/DisplayLayout.js +++ b/config/dot/quickshell/services/DisplayLayout.js @@ -1,3 +1,46 @@ +// Fields beyond mode/scale/transform/x/y/primary are optional on a record. +// Absent means "this layout says nothing about it", which every check below +// treats as valid: arrangements stored before the color and mirror fields +// existed must keep validating. +const colorProfiles = ["auto", "srgb", "wide", "hdr"]; +const vrrModes = [-1, 0, 1, 2]; +const bitdepths = [8, 10]; +const sdrBrightnessRange = { min: 0.8, max: 2.0 }; +const sdrSaturationRange = { min: 0.8, max: 1.2 }; + +function validColorProfile(value) { + return colorProfiles.indexOf(value) >= 0; +} + +function validVrrMode(value) { + return Number.isInteger(value) && vrrModes.indexOf(value) >= 0; +} + +function validBitdepth(value) { + return Number.isInteger(value) && bitdepths.indexOf(value) >= 0; +} + +function inRange(value, range) { + return Number.isFinite(value) && value >= range.min && value <= range.max; +} + +function validSdrBrightness(value) { + return inRange(value, sdrBrightnessRange); +} + +function validSdrSaturation(value) { + return inRange(value, sdrSaturationRange); +} + +// "" for a display that is not mirroring, so callers never branch on undefined. +function mirrorTarget(record) { + return record && typeof record.mirrorOf === "string" ? record.mirrorOf : ""; +} + +function isMirrored(record) { + return mirrorTarget(record) !== ""; +} + function logicalSize(record) { if (!record) return { width: 0, height: 0 }; @@ -22,6 +65,22 @@ function validCoordinate(value) { && value >= -100000 && value <= 100000; } +function validOptionalFields(record) { + if (record.vrrMode !== undefined && !validVrrMode(record.vrrMode)) + return false; + if (record.colorProfile !== undefined && !validColorProfile(record.colorProfile)) + return false; + if (record.bitdepth !== undefined && !validBitdepth(record.bitdepth)) + return false; + if (record.sdrBrightness !== undefined && !validSdrBrightness(record.sdrBrightness)) + return false; + if (record.sdrSaturation !== undefined && !validSdrSaturation(record.sdrSaturation)) + return false; + if (record.mirrorOf !== undefined && typeof record.mirrorOf !== "string") + return false; + return !isMirrored(record) || /^[A-Za-z0-9_.-]+$/.test(record.mirrorOf); +} + function validate(layout) { if (!Array.isArray(layout) || layout.length === 0) return false; @@ -41,7 +100,8 @@ function validate(layout) { || !Number.isInteger(record.transform) || record.transform < 0 || record.transform > 3 || !validCoordinate(record.x) || !validCoordinate(record.y) - || typeof record.primary !== "boolean") + || typeof record.primary !== "boolean" + || !validOptionalFields(record)) return false; const size = logicalSize(record); @@ -51,6 +111,21 @@ function validate(layout) { if (record.primary) primaryCount += 1; } + + // Mirroring is checked once every name is known. A mirror needs a target + // that is part of this same layout and is not itself a mirror, because + // Hyprland has no chain to follow; and the primary may not mirror, since + // the arrangement is anchored on it and a mirror has no position of its own. + for (const record of layout) { + const target = mirrorTarget(record); + if (target === "") + continue; + if (record.primary === true || target === record.name) + return false; + const other = layout.find(candidate => candidate.name === target); + if (!other || isMirrored(other)) + return false; + } return primaryCount === 1; } @@ -58,6 +133,14 @@ function cloneLayout(layout) { return (layout || []).map(record => Object.assign({}, record)); } +// A mirrored output is placed by the compositor, not by us, so it is left out +// of every geometry pass: shifting it, measuring the desktop by it, or snapping +// to it would all be arithmetic on a position nothing reads back. +function placedRecords(layout) { + const placed = (layout || []).filter(record => !isMirrored(record)); + return placed.length > 0 ? placed : (layout || []); +} + function normalize(layout) { const result = cloneLayout(layout); const primary = result.find(record => record.primary === true); @@ -66,9 +149,24 @@ function normalize(layout) { const anchorX = primary.x; const anchorY = primary.y; for (const record of result) { + if (isMirrored(record)) + continue; record.x -= anchorX; record.y -= anchorY; } + // A mirror sits on its target, so it is given the target's place rather + // than a shifted version of coordinates that stopped meaning anything the + // moment mirroring was turned on. Nothing asserts them -- the compositor + // chooses -- but a stale pair would still be drawn on the canvas. + for (const record of result) { + if (!isMirrored(record)) + continue; + const target = result.find(candidate => candidate.name === record.mirrorOf); + if (!target) + continue; + record.x = target.x; + record.y = target.y; + } return result; } @@ -80,7 +178,7 @@ function bounds(layout) { let top = Infinity; let right = -Infinity; let bottom = -Infinity; - for (const record of layout) { + for (const record of placedRecords(layout)) { const size = logicalSize(record); left = Math.min(left, record.x); top = Math.min(top, record.y); @@ -93,7 +191,7 @@ function bounds(layout) { function snap(layout, movingName, threshold) { const result = cloneLayout(layout); const moving = result.find(record => record.name === movingName); - if (!moving) + if (!moving || isMirrored(moving)) return result; const limit = Number.isFinite(threshold) && threshold >= 0 ? threshold : 16; @@ -101,7 +199,7 @@ function snap(layout, movingName, threshold) { const movingXEdges = [moving.x, moving.x + movingSize.width]; const movingYEdges = [moving.y, moving.y + movingSize.height]; const stationary = result - .filter(record => record.name !== movingName) + .filter(record => record.name !== movingName && !isMirrored(record)) .sort((left, right) => left.name.localeCompare(right.name)); let bestX = null; @@ -148,19 +246,35 @@ function canvasRects(layout, canvasWidth, canvasHeight, padding) { const contentHeight = desktopBounds.height * scale; const originX = inset + (availableWidth - contentWidth) / 2; const originY = inset + (availableHeight - contentHeight) / 2; - return { - bounds: desktopBounds, - scale, - rects: (layout || []).map(record => { - const size = logicalSize(record); - return { - name: record.name, - x: originX + (record.x - desktopBounds.x) * scale, - y: originY + (record.y - desktopBounds.y) * scale, - width: size.width * scale, - height: size.height * scale, - primary: record.primary === true - }; - }) - }; + const rects = (layout || []).map(record => { + const size = logicalSize(record); + return { + name: record.name, + x: originX + (record.x - desktopBounds.x) * scale, + y: originY + (record.y - desktopBounds.y) * scale, + width: size.width * scale, + height: size.height * scale, + primary: record.primary === true, + mirrorOf: mirrorTarget(record), + mirrored: isMirrored(record) + }; + }); + + // A mirror shows the same picture in the same place as its target, so it is + // drawn stacked on it with a badge rather than at coordinates of its own. + // Its target is guaranteed present by validate(); an unvalidated layout that + // names a missing one keeps its own rectangle instead of vanishing. + for (const rect of rects) { + if (!rect.mirrored) + continue; + const target = rects.find(candidate => candidate.name === rect.mirrorOf); + if (!target) + continue; + rect.x = target.x; + rect.y = target.y; + rect.width = target.width; + rect.height = target.height; + } + + return { bounds: desktopBounds, scale, rects }; } diff --git a/config/dot/quickshell/services/Displays.qml b/config/dot/quickshell/services/Displays.qml index 72b4eec..f80f3ab 100644 --- a/config/dot/quickshell/services/Displays.qml +++ b/config/dot/quickshell/services/Displays.qml @@ -1,6 +1,7 @@ pragma Singleton -// Display configuration: resolution, refresh rate, scale, and rotation. +// Display configuration: resolution, refresh rate, scale, rotation, position, +// colour management, variable refresh rate, and mirroring. // // This is the only page in Panama Settings where a wrong value can leave you // unable to SEE the screen well enough to undo it. A mode the display cannot @@ -27,7 +28,14 @@ Singleton { id: root // [{ name, description, width, height, refreshRate, scale, transform, + // x, y, primary, currentFormat, colorPreset, bitdepth, sdrBrightness, + // sdrSaturation, mirrorOf, vrr, // modes: [{ label, mode, width, height, refresh }] }] + // + // bitdepth, sdrBrightness and sdrSaturation are 0 when the compositor does + // not report them, which is not the same as a value: 0 is outside every + // valid range, so verification skips a field it cannot read rather than + // treating "unknown" as a mismatch. property var monitors: [] // Quickshell.screens is the topology authority. The override is only the // isolated harness model; normal sessions always observe Quickshell. @@ -77,6 +85,33 @@ Singleton { { value: 3, label: "Portrait (flipped)" } ] + // Colour management presets Hyprland accepts as `cm`. "auto" is a policy, + // not a state: the compositor resolves it to a concrete preset and reports + // that one back, so it is never verified against readback. + readonly property var colorProfiles: [ + { value: "auto", label: "Automatic" }, + { value: "srgb", label: "sRGB" }, + { value: "wide", label: "Wide gamut" }, + { value: "hdr", label: "HDR" } + ] + + // Per-display override of the global VRR policy. -1 means the display has + // no opinion and follows misc.vrr, which is expressed by leaving `vrr` out + // of the monitor rule entirely. 3 (fullscreen games only) is deliberately + // absent: it belongs to the global policy, not to one display. + readonly property var vrrModes: [ + { value: -1, label: "Follow gaming policy" }, + { value: 0, label: "Off" }, + { value: 1, label: "Always on" }, + { value: 2, label: "Fullscreen only" } + ] + + readonly property var bitdepths: [8, 10] + readonly property real sdrBrightnessMin: 0.8 + readonly property real sdrBrightnessMax: 2.0 + readonly property real sdrSaturationMin: 0.8 + readonly property real sdrSaturationMax: 1.2 + // Scales that divide this desktop's common resolutions into whole pixels. // Hyprland rejects a fractional scale that does not, and the message it // gives is not something to put in front of a user. @@ -197,7 +232,8 @@ Singleton { || Math.abs(entry.scale - record.scale) >= 0.001 || entry.transform !== record.transform || (entry.x !== undefined && entry.x !== record.x) - || (entry.y !== undefined && entry.y !== record.y)) + || (entry.y !== undefined && entry.y !== record.y) + || root.storedFieldsDiffer(entry, record)) changed = true; record.mode = entry.mode; record.width = parts.width; @@ -208,6 +244,7 @@ Singleton { if (entry.x !== undefined) record.x = entry.x; if (entry.y !== undefined) record.y = entry.y; record.primary = entry.primary === true; + root.overlayStoredFields(record, entry); } if (!changed) @@ -313,6 +350,11 @@ Singleton { primary: monitor.name === primaryName, currentFormat: monitor.currentFormat ?? "", colorPreset: monitor.colorManagementPreset ?? "", + bitdepth: root.formatBitdepth(monitor.currentFormat ?? ""), + sdrBrightness: Number.isFinite(monitor.sdrBrightness) ? monitor.sdrBrightness : 0, + sdrSaturation: Number.isFinite(monitor.sdrSaturation) ? monitor.sdrSaturation : 0, + // Hyprland reports "none" for an output that is not mirroring. + mirrorOf: (monitor.mirrorOf ?? "none") === "none" ? "" : String(monitor.mirrorOf), vrr: monitor.vrr === true, modes: modes }; @@ -384,6 +426,55 @@ Singleton { return root.monitors.find(monitor => monitor.name === name) ?? null; } + // The framebuffer format is the only honest report of the bit depth in + // effect: asking for 10-bit and getting it are different things, and a + // panel that cannot carry the link rate quietly stays at 8. Formats outside + // this map are read as "unknown", never as a mismatch. + function formatBitdepth(format: string): int { + if (format === "XRGB8888") + return 8; + if (format === "XRGB2101010") + return 10; + return 0; + } + + function persistedDisplays(): var { + const stored = DesktopPreferences.get("displays"); + return stored && typeof stored === "object" ? stored : {}; + } + + // The stored entry for an output, or null when nothing valid is stored. + function savedEntry(output: string): var { + const entry = root.persistedDisplays()[output]; + return root.isPersistedLayoutEntry(entry) ? entry : null; + } + + // Whether a stored entry asks for something the live record does not + // already have. A field the entry does not carry is not a difference: it + // predates that field, and the compositor's current value stands. + function storedFieldsDiffer(entry: var, record: var): bool { + return (entry.vrrMode !== undefined && entry.vrrMode !== record.vrrMode) + || (entry.colorProfile !== undefined && entry.colorProfile !== record.colorProfile) + || (entry.bitdepth !== undefined && entry.bitdepth !== record.bitdepth) + || (entry.mirrorOf !== undefined && entry.mirrorOf !== record.mirrorOf) + || (entry.sdrBrightness !== undefined + && Math.abs(entry.sdrBrightness - record.sdrBrightness) >= 0.001) + || (entry.sdrSaturation !== undefined + && Math.abs(entry.sdrSaturation - record.sdrSaturation) >= 0.001); + } + + function overlayStoredFields(record: var, entry: var): void { + for (const field of ["vrrMode", "colorProfile", "bitdepth", + "sdrBrightness", "sdrSaturation", "mirrorOf"]) { + if (entry[field] !== undefined) + record[field] = entry[field]; + } + } + + // Everything past `primary` is optional so that arrangements stored before + // the colour and mirror fields existed still load. Present but invalid is + // not optional: a half-written record is one Panama refuses rather than + // guesses at, exactly as it treats a half-written position. function isPersistedLayoutEntry(entry: var): bool { return !!entry && typeof entry === "object" && root.modeParts(entry.mode) !== null @@ -392,7 +483,14 @@ Singleton { && entry.transform >= 0 && entry.transform <= 3 && Number.isInteger(entry.x) && entry.x >= -100000 && entry.x <= 100000 && Number.isInteger(entry.y) && entry.y >= -100000 && entry.y <= 100000 - && typeof entry.primary === "boolean"; + && typeof entry.primary === "boolean" + && (entry.vrrMode === undefined || DisplayLayout.validVrrMode(entry.vrrMode)) + && (entry.colorProfile === undefined || DisplayLayout.validColorProfile(entry.colorProfile)) + && (entry.bitdepth === undefined || DisplayLayout.validBitdepth(entry.bitdepth)) + && (entry.sdrBrightness === undefined || DisplayLayout.validSdrBrightness(entry.sdrBrightness)) + && (entry.sdrSaturation === undefined || DisplayLayout.validSdrSaturation(entry.sdrSaturation)) + && (entry.mirrorOf === undefined || (typeof entry.mirrorOf === "string" + && (entry.mirrorOf === "" || /^[A-Za-z0-9_.-]+$/.test(entry.mirrorOf)))); } function modeParts(mode: string): var { @@ -429,19 +527,49 @@ Singleton { choices[0]); } + // The complete state of every connected display, read from the compositor. + // + // Every field is read live, because a Hyprland monitor rule replaces the + // previous rule for that output wholesale: a change to one field that did + // not carry the others would drop them back to compositor defaults. That is + // the clobber this reads against. + // + // vrrMode is the exception. The compositor's `vrr` readback is whether + // variable refresh is active right now, not which policy was configured, so + // it comes from the stored record and defaults to -1 (follow the global + // policy). Reverting to a record with -1 is still correct: the rule pushed + // for it carries no `vrr` key, and the display falls back to misc.vrr. function currentLayout(): var { - return root.monitors.map(monitor => ({ - name: monitor.name, - width: monitor.width, - height: monitor.height, - refreshRate: monitor.refreshRate, - mode: monitor.mode, - scale: monitor.scale, - transform: monitor.transform, - x: monitor.x, - y: monitor.y, - primary: monitor.primary === true - })); + return root.monitors.map(monitor => { + const saved = root.savedEntry(monitor.name); + const live = DisplayLayout.validColorProfile(monitor.colorPreset) + ? monitor.colorPreset : "auto"; + return { + name: monitor.name, + width: monitor.width, + height: monitor.height, + refreshRate: monitor.refreshRate, + mode: monitor.mode, + scale: monitor.scale, + transform: monitor.transform, + x: monitor.x, + y: monitor.y, + primary: monitor.primary === true, + vrrMode: saved && DisplayLayout.validVrrMode(saved.vrrMode) ? saved.vrrMode : -1, + // A display set to "auto" reads back as the preset auto chose. + // Keeping the stored policy stops one apply from pinning the + // display to whatever automatic happened to pick today. + colorProfile: saved && saved.colorProfile === "auto" ? "auto" : live, + bitdepth: monitor.bitdepth !== 0 + ? monitor.bitdepth + : (saved && DisplayLayout.validBitdepth(saved.bitdepth) ? saved.bitdepth : 8), + sdrBrightness: DisplayLayout.validSdrBrightness(monitor.sdrBrightness) + ? monitor.sdrBrightness : 1.0, + sdrSaturation: DisplayLayout.validSdrSaturation(monitor.sdrSaturation) + ? monitor.sdrSaturation : 1.0, + mirrorOf: typeof monitor.mirrorOf === "string" ? monitor.mirrorOf : "" + }; + }); } function matchesLayout(monitors: var, layout: var): bool { @@ -459,13 +587,72 @@ Singleton { || monitor.height !== parts.height || Math.abs(monitor.refreshRate - parts.refresh) >= 0.01 || Math.abs(monitor.scale - requested.scale) >= 0.001 - || monitor.transform !== requested.transform - || monitor.x !== requested.x || monitor.y !== requested.y) + || monitor.transform !== requested.transform) + return false; + + const mirrorOf = typeof requested.mirrorOf === "string" ? requested.mirrorOf : ""; + if (root.readbackMirror(monitor) !== mirrorOf) + return false; + // A mirror is placed by the compositor on top of what it copies, so + // its coordinates are not ours to assert. Every other display still + // has to land exactly where it was asked to. + if (mirrorOf === "" && (monitor.x !== requested.x || monitor.y !== requested.y)) + return false; + + if (!root.matchesColor(monitor, requested)) return false; } return true; } + // Readback accessors. They take either a parsed record from root.monitors + // or a raw `hyprctl -j monitors` object, because verification is also + // exercised against captured compositor output, and a comparison that only + // understood one of the two shapes would pass for the wrong reason. + function readbackMirror(monitor: var): string { + const value = typeof monitor.mirrorOf === "string" ? monitor.mirrorOf : ""; + return value === "none" ? "" : value; + } + + function readbackPreset(monitor: var): string { + if (typeof monitor.colorPreset === "string" && monitor.colorPreset !== "") + return monitor.colorPreset; + return typeof monitor.colorManagementPreset === "string" + ? monitor.colorManagementPreset : ""; + } + + function readbackBitdepth(monitor: var): int { + if (Number.isInteger(monitor.bitdepth)) + return monitor.bitdepth; + return root.formatBitdepth(String(monitor.currentFormat ?? "")); + } + + // The colour half of the readback comparison. Each field is asserted only + // where the compositor reports something to compare against; vrrMode is + // absent by design, since `vrr` reads back live state rather than policy. + function matchesColor(monitor: var, requested: var): bool { + // "auto" is resolved by the compositor into srgb or wide before it is + // reported, so there is no value it could equal. It is applied, and the + // preset it resolved to is what the page shows. + const preset = root.readbackPreset(monitor); + if (requested.colorProfile !== undefined && requested.colorProfile !== "auto" + && preset !== "" && preset !== requested.colorProfile) + return false; + const bitdepth = root.readbackBitdepth(monitor); + if (requested.bitdepth !== undefined && bitdepth !== 0 + && bitdepth !== requested.bitdepth) + return false; + if (requested.sdrBrightness !== undefined + && DisplayLayout.validSdrBrightness(monitor.sdrBrightness) + && Math.abs(monitor.sdrBrightness - requested.sdrBrightness) >= 0.01) + return false; + if (requested.sdrSaturation !== undefined + && DisplayLayout.validSdrSaturation(monitor.sdrSaturation) + && Math.abs(monitor.sdrSaturation - requested.sdrSaturation) >= 0.01) + return false; + return true; + } + function modeIsCurrent(monitor: var, candidate: var): bool { return !!monitor && !!candidate && monitor.width === candidate.width @@ -483,34 +670,66 @@ Singleton { return layout.every(record => { const monitor = root.monitorNamed(record.name); const parts = root.modeParts(record.mode); + const mirrorOf = typeof record.mirrorOf === "string" ? record.mirrorOf : ""; return !!monitor && !!parts && record.width === parts.width && record.height === parts.height && monitor.modes.some(candidate => candidate.mode === record.mode) && root.isScaleClean(record.mode, record.scale) - && root.transforms.some(candidate => candidate.value === record.transform); + && root.transforms.some(candidate => candidate.value === record.transform) + // DisplayLayout.validate already refuses chains and a mirroring + // primary; this is the connected-hardware half of the same rule. + && (mirrorOf === "" || !!root.monitorNamed(mirrorOf)); }); } // One-field controls remain callers of the complete-layout transaction. - // Their edit is cloned into the current layout so every output's position - // participates in apply, verification, and rollback. - function apply(output: string, mode: string, scale: real, transform: int): bool { + // Their edit is cloned into the current layout so every output's position, + // colour and mirror state participates in apply, verification, and + // rollback. `partial` carries only the fields being changed; anything it + // leaves out keeps the value currentLayout just read from the compositor. + function applyRecord(output: string, partial: var): bool { const layout = root.currentLayout(); const record = layout.find(candidate => candidate.name === output); - const parts = root.modeParts(mode); - if (!record || !parts) { - root.lastError = record ? "That display does not offer that mode." : "That display is not connected."; + if (!record) { + root.lastError = "That display is not connected."; return false; } - record.mode = mode; - record.width = parts.width; - record.height = parts.height; - record.refreshRate = parts.refresh; - record.scale = scale; - record.transform = transform; + const changes = (partial && typeof partial === "object") ? partial : {}; + if (changes.mode !== undefined) { + const parts = root.modeParts(changes.mode); + if (!parts) { + root.lastError = "That display does not offer that mode."; + return false; + } + record.mode = changes.mode; + record.width = parts.width; + record.height = parts.height; + record.refreshRate = parts.refresh; + } + for (const field of ["scale", "transform", "vrrMode", "colorProfile", + "bitdepth", "sdrBrightness", "sdrSaturation", "mirrorOf"]) { + if (changes[field] !== undefined) + record[field] = changes[field]; + } + + // The arrangement is anchored on the primary display, and a mirror has + // no position of its own to anchor to. Said plainly here rather than + // left to the layout validator's one generic message. + if (record.primary === true && typeof record.mirrorOf === "string" + && record.mirrorOf !== "") { + root.lastError = "The primary display cannot mirror another display. Make a different display primary first."; + return false; + } + return root.applyLayout(layout); } + // The original four-field entry point, kept so existing callers and the + // harness keep working. + function apply(output: string, mode: string, scale: real, transform: int): bool { + return root.applyRecord(output, { mode: mode, scale: scale, transform: transform }); + } + // Applies immediately and starts the countdown. Nothing is stored yet: the // complete connected layout is only written by confirm(). function applyLayout(layout: var, protectedOperation: bool): bool { @@ -554,19 +773,66 @@ Singleton { function makePrimary(output: string): bool { const layout = root.currentLayout(); - if (!layout.some(record => record.name === output)) { + const target = layout.find(record => record.name === output); + if (!target) { root.lastError = "That display is not connected."; return false; } + // Refused rather than silently un-mirrored: the arrangement is anchored + // on the primary, and a mirror has no position of its own. Turning the + // mirror off is a change to what is on screen, and the user makes it. + if (typeof target.mirrorOf === "string" && target.mirrorOf !== "") { + root.lastError = "A mirrored display cannot be the primary. Set it back to an extended display first."; + return false; + } for (const record of layout) record.primary = record.name === output; return root.applyLayout(DisplayLayout.normalize(layout)); } + // A monitor rule replaces the previous rule for that output entirely, so + // every field the record knows is emitted every time. Omission is not + // "leave it alone", it is "go back to the compositor's default", which is + // exactly what the omission rules below rely on: + // + // * vrr is left out for vrrMode -1, which returns the display to the + // global misc.vrr policy. Emitting the key at all IS the override. + // * mirror is left out unless the record names a target. + // * sdrbrightness / sdrsaturation are left out at their neutral 1.0. + // Naming the neutral value pins the display to it, which is not what + // "no opinion" means. + // * position is "auto" for a mirror, whose place is the compositor's to + // choose and ours to read back, never to ask for. + function monitorRule(record: var): string { + const mirrorOf = typeof record.mirrorOf === "string" ? record.mirrorOf : ""; + const fields = [ + `output = "${record.name}"`, + `mode = "${record.mode}"`, + mirrorOf === "" + ? `position = "${record.x}x${record.y}"` + : `position = "auto"`, + `scale = ${record.scale}`, + `transform = ${record.transform}` + ]; + if (mirrorOf !== "") + fields.push(`mirror = "${mirrorOf}"`); + if (DisplayLayout.validVrrMode(record.vrrMode) && record.vrrMode >= 0) + fields.push(`vrr = ${record.vrrMode}`); + if (DisplayLayout.validBitdepth(record.bitdepth)) + fields.push(`bitdepth = ${record.bitdepth}`); + if (DisplayLayout.validColorProfile(record.colorProfile)) + fields.push(`cm = "${record.colorProfile}"`); + if (DisplayLayout.validSdrBrightness(record.sdrBrightness) + && Math.abs(record.sdrBrightness - 1.0) >= 0.001) + fields.push(`sdrbrightness = ${record.sdrBrightness}`); + if (DisplayLayout.validSdrSaturation(record.sdrSaturation) + && Math.abs(record.sdrSaturation - 1.0) >= 0.001) + fields.push(`sdrsaturation = ${record.sdrSaturation}`); + return `hl.monitor({ ${fields.join(", ")} })`; + } + function pushLayout(layout: var, runner: var): void { - const payload = layout.map(record => - `hl.monitor({ output = "${record.name}", mode = "${record.mode}", position = "${record.x}x${record.y}", scale = ${record.scale}, transform = ${record.transform} })` - ).join("; "); + const payload = layout.map(record => root.monitorRule(record)).join("; "); runner.exec(["hyprctl", "eval", payload]); } @@ -587,7 +853,13 @@ Singleton { transform: record.transform, x: record.x, y: record.y, - primary: record.primary + primary: record.primary, + vrrMode: record.vrrMode, + colorProfile: record.colorProfile, + bitdepth: record.bitdepth, + sdrBrightness: record.sdrBrightness, + sdrSaturation: record.sdrSaturation, + mirrorOf: record.mirrorOf }; } if (!DesktopPreferences.set("displays", next)) { @@ -637,6 +909,14 @@ Singleton { const previous = (root.pendingPreviousLayout || []) .filter(record => connected[record.name]) .map(record => Object.assign({}, record)); + // A mirror whose target was unplugged has nothing left to copy, and a + // rule pointing at a missing output is one the compositor ignores + // silently. Give it back its own picture instead. + for (const record of previous) { + if (typeof record.mirrorOf === "string" && record.mirrorOf !== "" + && !connected[record.mirrorOf]) + record.mirrorOf = ""; + } if (previous.length > 0 && !previous.some(record => record.primary)) { const origin = previous.find(record => record.x === 0 && record.y === 0); (origin || previous[0]).primary = true; diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index e703eac..fbf485b 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -161,6 +161,18 @@ Singleton { { label: "Arrange displays", detail: "Drag connected displays into their physical positions", page: "displays" }, { label: "Monitor position", detail: "Set where each display sits in the desktop", page: "displays" }, { label: "Primary display", detail: "Choose the display that anchors the desktop", page: "displays" }, + { label: "Resolution", detail: "The mode each display runs, with its refresh rate", page: "displays" }, + { label: "Refresh rate", detail: "How many times a second the display redraws", page: "displays" }, + { label: "Scale", detail: "How large everything is drawn on a display", page: "displays" }, + { label: "Rotation", detail: "Turn a display a quarter, half, or three-quarter turn", page: "displays" }, + { label: "HDR", detail: "High dynamic range for a display that supports it", page: "displays" }, + { label: "Color profile", detail: "Automatic, sRGB, wide gamut, or HDR per display", page: "displays" }, + { label: "Bit depth", detail: "8-bit or 10-bit colour, per display", page: "displays" }, + { label: "10-bit", detail: "Deeper colour with less gradient banding", page: "displays" }, + { label: "SDR brightness", detail: "How bright ordinary content sits inside HDR", page: "displays" }, + { label: "Mirror displays", detail: "Show the same picture on a second display", page: "displays" }, + { label: "Variable refresh rate", detail: "Override the gaming policy for one display", page: "displays" }, + { label: "Monitor brightness", detail: "The monitor's own backlight, over DDC", page: "displays" }, { label: "Theme profiles", detail: "Switch between Moon, Moon Rose, Day, and saved themes", page: "appearance" }, { label: "Themes", detail: "Light and dark mode, and the theme for each", page: "appearance" }, { label: "Dark mode", detail: "Flip the desktop to your dark theme", page: "appearance" }, diff --git a/config/local/share/vicinae/scripts/settings-workspaces b/config/local/share/vicinae/scripts/settings-workspaces index 8ff7f76..2533c15 100755 --- a/config/local/share/vicinae/scripts/settings-workspaces +++ b/config/local/share/vicinae/scripts/settings-workspaces @@ -5,6 +5,6 @@ # @vicinae.mode silent # @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg # @vicinae.description Open Workspaces in Settings. -# @vicinae.keywords ["settings", "focus modes", "focus session length"] +# @vicinae.keywords ["settings", "focus modes", "focus session length", "switch back and forth", "wrap around at the ends", "let applications take focus", "hide the terminal that launched a window", "pointer changes active display"] exec "$HOME/.config/quickshell/scripts/panama-action" settings-page workspaces diff --git a/docs/settings.md b/docs/settings.md index e01cc11..731c3a5 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -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**
`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**
`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**
`windowSnapping` `general:snap:enabled` | true | Floating windows stick to screen edges and to each other as you drag them | -| **Switch back and forth**
`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**
`allowWorkspaceCycles` `binds:allow_workspace_cycles` | false | Moving past the last workspace continues from the first | -| **Let applications take focus**
`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**
`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**
`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**
`fullscreenOpacity` `decoration:fullscreen_opacity` | 1.0 | Applied instead of the focused opacity when a window is fullscreen. Range 0.5–1.0. | | **Corner shape**
`roundingPower` `decoration:rounding_power` | 2.0 | 2 is a circular corner; higher values approach a squircle. Range 2.0–10.0. | +## workspaces + +Found on **Shell › Workspaces**. + +| Setting | Default | What it does | +|---|---|---| +| **Switch back and forth**
`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**
`allowWorkspaceCycles` `binds:allow_workspace_cycles` | false | Moving past the last workspace continues from the first | +| **Let applications take focus**
`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**
`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**
`mouseMoveFocusesMonitor` `misc:mouse_move_focuses_monitor` | true | Moving the pointer to another display makes it the active one | + diff --git a/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md b/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md index 9bc6238..ae80ac5 100644 --- a/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md +++ b/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md @@ -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. diff --git a/docs/superpowers/specs/2026-08-24-displays-redesign.md b/docs/superpowers/specs/2026-08-24-displays-redesign.md new file mode 100644 index 0000000..627e25b --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-displays-redesign.md @@ -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 " + 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 → `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. diff --git a/tests/quickshell/display-arrangement-contract b/tests/quickshell/display-arrangement-contract index 0e4380a..a721b3d 100755 --- a/tests/quickshell/display-arrangement-contract +++ b/tests/quickshell/display-arrangement-contract @@ -31,8 +31,106 @@ for contract in \ done rg -Fq 'DisplayArrangement {' "$page" \ || fail 'Displays page does not expose the arrangement canvas' -rg -Fq 'visible: Displays.monitors.length > 1' "$page" \ - || fail 'arrangement is shown for a single display' + +# Flipped by the displays redesign, deliberately and in this direction. +# +# The canvas used to be hidden below two displays. That left a laptop opening +# its Displays page on a picker offering a list of one, and made the page's +# most legible surface the one thing a single-display machine never saw. It is +# the hero of the page now: a solo display is rendered in it, and only dragging +# goes away, because there is nothing to arrange that display against. +# The bindings of one QML element, found by matching its braces. A fixed-size +# window around the element would either miss a binding or catch a neighbour's, +# and both of those are the wrong answer here: the page's selector chips and its +# Workspaces card are legitimately gated on having two displays. +element_binding() { # file, element, pattern, present|absent + python3 - "$@" <<'PY' +import re +import sys + +path, opener, pattern, expectation = sys.argv[1:5] +text = open(path, encoding="utf-8").read() +start = text.find(opener) +if start < 0: + raise SystemExit(1) +depth = 0 +end = len(text) +for index in range(start + len(opener) - 1, len(text)): + if text[index] == "{": + depth += 1 + elif text[index] == "}": + depth -= 1 + if depth == 0: + end = index + break +found = re.search(pattern, text[start:end]) is not None +raise SystemExit(0 if found == (expectation == "present") else 1) +PY +} + +python3 - "$page" <<'PY' \ + || fail 'the arrangement canvas is gated on a monitor count again -- on the canvas itself or on the card holding it -- so a single display sees no canvas' +import re +import sys + +text = open(sys.argv[1], encoding="utf-8").read() +start = text.find("DisplayArrangement {") +if start < 0: + raise SystemExit(1) + + +def own_bindings(open_brace, stop): + """The block's own bindings, with nested elements left out: a sibling + card's gate is its business, and this is only about the canvas.""" + kept = [] + depth = 0 + for char in text[open_brace + 1:stop]: + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth < 0: + break + elif depth == 0: + kept.append(char) + return "".join(kept) + + +# The canvas's own bindings, and those of whatever element holds it: a gate on +# either one is a canvas a single display never sees. This lived on the card +# rather than on the canvas before the redesign, which is exactly why the +# element alone is not enough to look at. +stack = [] +for index, char in enumerate(text[:start]): + if char == "{": + stack.append(index) + elif char == "}" and stack: + stack.pop() +if not stack: + raise SystemExit(1) + +regions = [own_bindings(start + len("DisplayArrangement {") - 1, len(text)), + own_bindings(stack[-1], start)] +gate = re.compile(r"visible:.*monitors\.length") +raise SystemExit(1 if any(gate.search(region) for region in regions) else 0) +PY + +for contract in \ + 'One display connected' \ + 'draggable'; do + rg -Fq "$contract" "$component" \ + || fail "a solo display is not explained or not protected from dragging: $contract" +done +element_binding "$component" 'DragHandler {' 'enabled:' present \ + || fail 'the drag handler is unconditional, so a solo display can be dragged around a canvas with nothing to arrange against' + +# Mirroring has no position of its own: the compositor stacks a mirrored output +# on its target. The canvas says which display it is mirroring rather than +# drawing it wherever its stale coordinates happen to point. +rg -Fq 'Mirrors ' "$component" \ + || fail 'a mirrored display is drawn with no badge saying what it mirrors' +rg -Fq 'mirrorOf' "$component" \ + || fail 'the canvas does not read the mirror flag off its rects' [[ -f "$identify" ]] || fail 'DisplayIdentify.qml is missing' for contract in \ 'model: Quickshell.screens' \ @@ -94,6 +192,17 @@ keyboard="$(ipc keyboardFixture)" jq -e '.afterArrow == 2990 and .afterShiftArrow == 2890' <<<"$keyboard" >/dev/null \ || fail "keyboard movement did not use 10/100 logical-pixel steps: $keyboard" +solo="$(ipc soloFixture)" +jq -e '(.rects | length) == 1 and .scale > 0 and .draggable == false' <<<"$solo" >/dev/null \ + || fail "a single display did not render solo with dragging disabled: $solo" + +mirror="$(ipc mirrorFixture)" +jq -e '(.rects | length) == 2 + and (.rects[1].mirrorOf == "DP-2") + and (.rects[1].x == .rects[0].x) and (.rects[1].y == .rects[0].y)' \ + <<<"$mirror" >/dev/null \ + || fail "a mirrored display was not stacked on its target and flagged: $mirror" + primary="$(ipc primaryFixture)" jq -e '. == [ {"name":"DP-2","x":-3000,"y":0,"primary":false}, diff --git a/tests/quickshell/display-layout-contract b/tests/quickshell/display-layout-contract index 8071cc7..229ad68 100755 --- a/tests/quickshell/display-layout-contract +++ b/tests/quickshell/display-layout-contract @@ -54,4 +54,34 @@ jq -e '.valid == true invalid="$(qs_for_harness ipc call display-layout-test invalid)" jq -e 'all(. == false)' <<<"$invalid" >/dev/null || fail "an invalid layout was accepted: $invalid" +# Mirroring is the one arrangement where the position on file is not the +# position on the desk. Hyprland stacks a mirrored output on its target and +# ignores whatever the rule asked for, so the geometry here has to agree: +# a mirrored display contributes nothing to the desktop's bounds, and the +# canvas draws it on top of what it mirrors rather than at stale coordinates. +mirror="$(qs_for_harness ipc call display-layout-test mirror)" +# +# The mirrored record keeps the coordinates it was stored with -- normalize +# shifts everything by the primary's anchor, and applying that to a position +# nothing reads back would be arithmetic for its own sake. It contributes +# nothing to the bounds, so the desktop measures 3000 x 2000: one display. +jq -e '.valid == true + and .normalized == [ + {"name":"DP-2","x":0,"y":0,"primary":true,"mirrorOf":""}, + {"name":"HDMI-A-1","x":3140,"y":80,"primary":false,"mirrorOf":"DP-2"}] + and .bounds == {"x":0,"y":0,"width":3000,"height":2000} + and (.rects | length) == 2 + and .rects[1].mirrorOf == "DP-2" and .rects[1].mirrored == true + and .rects[0].mirrored == false + and .rects[1].x == .rects[0].x and .rects[1].y == .rects[0].y + and .rects[1].width == .rects[0].width' \ + <<<"$mirror" >/dev/null || fail "mirrored geometry was wrong: $mirror" + +# A mirror that names itself, an absent output, or a display that is itself +# mirroring is not a layout with a cosmetic problem -- it is one the compositor +# will answer in a way nobody predicted. +invalid_mirrors="$(qs_for_harness ipc call display-layout-test invalidMirrors)" +jq -e 'all(. == false)' <<<"$invalid_mirrors" >/dev/null \ + || fail "an impossible mirror was accepted: $invalid_mirrors" + printf 'display layout contract: PASS\n' diff --git a/tests/quickshell/display-transaction-contract b/tests/quickshell/display-transaction-contract index 9d6d657..3da5869 100755 --- a/tests/quickshell/display-transaction-contract +++ b/tests/quickshell/display-transaction-contract @@ -28,10 +28,34 @@ rg -Fq 'position = "${record.x}x${record.y}"' "$service" \ || fail 'the compositor payload does not include explicit positions' rg -Fq 'generation === root.operationGeneration' "$service" \ || fail 'stale monitor readback can settle a newer transaction' + +# The extended record. Every one of these is carried through currentLayout by +# readback, which is the whole clobber fix: an apply that changes the scale +# must not silently drop the bit depth monitors.lua asked for. +for contract in \ + 'function applyRecord(output:' \ + 'vrrMode' \ + 'colorProfile' \ + 'bitdepth' \ + 'sdrBrightness' \ + 'sdrSaturation' \ + 'mirrorOf'; do + rg -Fq "$contract" "$service" \ + || fail "the extended display record is missing: $contract" +done +rg -q 'vrrMode\s*(!==\s*-1|>\s*-1|>=\s*0)' "$service" \ + || fail 'nothing in the service decides when the vrr key is omitted, so following the global policy would be written as an override' + rg -Fq 'function applyLayoutFixture(' "$harness" \ || fail 'the fixture cannot exercise complete layout transactions' rg -Fq 'function injectReadback(' "$harness" \ || fail 'the fixture cannot prove stale readback isolation' +rg -Fq 'function applyRecordFixture(' "$harness" \ + || fail 'the fixture cannot exercise a partial record change' +rg -Fq 'function layoutMatch(' "$harness" \ + || fail 'the fixture cannot probe the readback carve-outs' +rg -Fq 'function persistedEntryValid(' "$harness" \ + || fail 'the fixture cannot prove an old stored blob still validates' fixture="$(mktemp -d /tmp/panama-display-transaction.XXXXXX)" state_home="$fixture/state-home" @@ -47,11 +71,15 @@ cat >"$monitor_state" <<'JSON' { "name":"DP-2","description":"Primary fixture","width":4500,"height":3000, "refreshRate":60,"scale":1.5,"transform":0,"x":0,"y":0, + "currentFormat":"XRGB8888","colorManagementPreset":"auto", + "sdrBrightness":1.0,"sdrSaturation":1.0,"mirrorOf":"none","vrr":false, "availableModes":["4500x3000@60.00Hz"] }, { "name":"HDMI-A-1","description":"Second fixture","width":2560,"height":1440, "refreshRate":60,"scale":1,"transform":0,"x":3000,"y":0, + "currentFormat":"XRGB8888","colorManagementPreset":"auto", + "sdrBrightness":1.0,"sdrSaturation":1.0,"mirrorOf":"none","vrr":false, "availableModes":["2560x1440@60.00Hz"] } ] @@ -78,7 +106,7 @@ if [[ -f "$fixture/fail-once" ]]; then fi [[ -f "$fixture/no-apply" ]] && exit 0 -python3 - "$fixture/monitors.json" "$payload" "$fixture/wrong-y" <<'PY' +python3 - "$fixture/monitors.json" "$payload" "$fixture/wrong-y" "$fixture/odd-format" <<'PY' import json import pathlib import re @@ -87,6 +115,7 @@ import sys state_path = pathlib.Path(sys.argv[1]) payload = sys.argv[2] wrong_y = pathlib.Path(sys.argv[3]).exists() +odd_format = pathlib.Path(sys.argv[4]).exists() monitors = json.loads(state_path.read_text(encoding="utf-8")) by_name = {monitor["name"]: monitor for monitor in monitors} @@ -97,10 +126,17 @@ for block in re.findall(r"hl\.monitor\(\{([^}]*)\}\)", payload): raise SystemExit(f"missing field {pattern}: {block}") return match.group(1) + def optional(pattern: str): + match = re.search(pattern, block) + return match.group(1) if match else None + name = field(r'output\s*=\s*"([A-Za-z0-9_.-]+)"') mode = field(r'mode\s*=\s*"(\d+x\d+@\d+(?:\.\d+)?)"') - position = re.search(r'position\s*=\s*"(-?\d+)x(-?\d+)"', block) - if not position or name not in by_name: + # A mirrored rule asks for "auto" instead of coordinates, because the + # compositor is the one that decides where a mirror lands. + placement = field(r'position\s*=\s*"([^"]+)"') + position = re.match(r"^(-?\d+)x(-?\d+)$", placement) + if name not in by_name or (position is None and placement != "auto"): raise SystemExit(f"bad output or position: {block}") width, height, refresh = re.match(r"(\d+)x(\d+)@(\d+(?:\.\d+)?)", mode).groups() monitor = by_name[name] @@ -110,9 +146,44 @@ for block in re.findall(r"hl\.monitor\(\{([^}]*)\}\)", payload): "refreshRate": float(refresh), "scale": float(field(r"scale\s*=\s*([0-9.]+)")), "transform": int(field(r"transform\s*=\s*(\d+)")), - "x": int(position.group(1)), - "y": int(position.group(2)), }) + if position is not None: + monitor["x"] = int(position.group(1)) + monitor["y"] = int(position.group(2)) + + # The compositor answers about colour with the names Hyprland uses in + # `hyprctl -j monitors`, not with the keys the rule was written in. + preset = optional(r'cm\s*=\s*"([a-z]+)"') + if preset is not None: + monitor["colorManagementPreset"] = preset + depth = optional(r"bitdepth\s*=\s*(\d+)") + if depth is not None: + monitor["currentFormat"] = "XRGB2101010" if depth == "10" else "XRGB8888" + if odd_format: + # A format Panama has no mapping for. The request is not wrong; the + # readback is simply not evidence either way. + monitor["currentFormat"] = "XBGR16161616F" + sdr_brightness = optional(r"sdrbrightness\s*=\s*([0-9.]+)") + if sdr_brightness is not None: + monitor["sdrBrightness"] = float(sdr_brightness) + sdr_saturation = optional(r"sdrsaturation\s*=\s*([0-9.]+)") + if sdr_saturation is not None: + monitor["sdrSaturation"] = float(sdr_saturation) + + # Mirroring is where the compositor stops taking instructions: a mirrored + # output lands on top of its target whatever position the rule carried. + # Panama must not hold the requested x/y against it. + # Hyprland says "none" rather than an empty string, and Panama has to know + # that is not the name of a monitor. + mirror = optional(r'mirror\s*=\s*"([A-Za-z0-9_.-]*)"') + monitor["mirrorOf"] = mirror or "none" + if mirror and mirror in by_name: + monitor["x"] = by_name[mirror]["x"] + monitor["y"] = by_name[mirror]["y"] + + # vrr is deliberately not reflected here: the readback field is live + # adaptive-sync state, and nothing in the service may verify against it. + if wrong_y and name == "HDMI-A-1": monitor["y"] += 1 @@ -251,6 +322,133 @@ mv "$fixture/reconnected.json" "$monitor_state" run ipc --pid "$harness_pid" call displays-test refresh >/dev/null wait_for '.layout | length == 2' >/dev/null +# ── The extended record ────────────────────────────────────────────────────── +# +# Colour, bit depth, the SDR trim and mirroring ride the same keep-or-revert +# transaction as geometry. vrr cannot: `hyprctl -j monitors` reports whether +# adaptive sync is live at this instant, not what the config asked for, so it +# is applied and never verified -- and a display that follows the global gaming +# policy must not emit the key at all, because emitting it IS the override. + +b64() { printf '%s' "$1" | base64 -w0; } +apply_record() { + run ipc --pid "$harness_pid" call displays-test applyRecordFixture "$1" "$(b64 "$2")" +} +probe() { + run ipc --pid "$harness_pid" call displays-test "$1" "$(b64 "$2")" "$(b64 "$3")" +} +settle() { wait_for '.busy == false and .awaiting == false and .reverting == null' >/dev/null; } + +[[ "$(apply_record HDMI-A-1 '{"colorProfile":"srgb","bitdepth":10,"sdrBrightness":1.4,"sdrSaturation":1.0,"vrrMode":-1}')" == "true" ]] \ + || fail 'an extended display record was refused' +wait_for '.canConfirm == true' >/dev/null +color_payload="$(tail -1 "$eval_log")" +if rg -q 'vrr\s*=' <<<"$color_payload"; then + fail "a display following the global VRR policy emitted a vrr override anyway: $color_payload" +fi +# Neutral saturation is the absence of a request. A rule that names 1.0 pins +# the display to it, which is not the same thing as leaving it alone. +if rg -q 'sdrsaturation\s*=' <<<"$color_payload"; then + fail "neutral SDR saturation was written as a rule: $color_payload" +fi +rg -Fq 'cm = "srgb"' <<<"$color_payload" \ + && rg -q 'bitdepth\s*=\s*10' <<<"$color_payload" \ + && rg -q 'sdrbrightness\s*=\s*1\.4\b' <<<"$color_payload" \ + || fail "the colour fields never reached the compositor payload: $color_payload" +# The clobber this exists for: an apply that changes one field must carry the +# rest of the record, or monitors.lua's 10-bit request dies on the next apply. +rg -Fq 'output = "DP-2"' <<<"$color_payload" && rg -q 'bitdepth\s*=' <<<"$color_payload" \ + || fail "the untouched display lost its colour fields on somebody else's apply: $color_payload" + +[[ "$(run ipc --pid "$harness_pid" call displays-test confirmChange)" == "true" ]] \ + || fail 'a verified colour change could not be kept' +jq -e '.displays["HDMI-A-1"] | .colorProfile == "srgb" and .bitdepth == 10 + and .vrrMode == -1 and .mirrorOf == "" and (.sdrBrightness - 1.4 | fabs) < 0.001' \ + "$store" >/dev/null \ + || fail 'confirmation did not persist the extended record' + +# vrr applied, never verified: Keep must still become available. +[[ "$(apply_record HDMI-A-1 '{"vrrMode":2}')" == "true" ]] \ + || fail 'a VRR override was refused' +wait_for '.canConfirm == true' >/dev/null +rg -q 'vrr\s*=\s*2' <<<"$(tail -1 "$eval_log")" \ + || fail "an explicit VRR override did not emit the vrr key: $(tail -1 "$eval_log")" +run ipc --pid "$harness_pid" call displays-test revertChange >/dev/null +settle + +# A framebuffer format with no 8/10 mapping is not evidence against the +# request. Asserting it anyway would leave Keep permanently unavailable on +# hardware that reports a format Panama has never heard of. +touch "$fixture/odd-format" +[[ "$(apply_record HDMI-A-1 '{"bitdepth":8}')" == "true" ]] \ + || fail 'a bit depth change was refused' +wait_for '.canConfirm == true' >/dev/null +rm -f "$fixture/odd-format" +run ipc --pid "$harness_pid" call displays-test revertChange >/dev/null +settle + +# Mirroring: the compositor puts a mirrored output on top of its target and +# ignores the position the rule carried, so x/y are the two assertions that +# must be skipped for that record -- and only for that record. +[[ "$(apply_record HDMI-A-1 '{"mirrorOf":"DP-2"}')" == "true" ]] \ + || fail 'a valid mirror request was refused' +wait_for '.canConfirm == true' >/dev/null +mirror_payload="$(tail -1 "$eval_log")" +rg -Fq 'mirror = "DP-2"' <<<"$mirror_payload" \ + || fail "the mirror key never reached the compositor: $mirror_payload" +# The mirrored rule asks for "auto", not for the coordinates on file: the +# position it was stored with is not one the compositor will honour. +rg -q 'output = "HDMI-A-1", mode = "[^"]+", position = "auto"' <<<"$mirror_payload" \ + || fail "a mirrored display still asked for a position of its own: $mirror_payload" +jq -e '.[1].mirrorOf == "DP-2" and .[1].x == .[0].x and .[1].y == .[0].y' \ + "$monitor_state" >/dev/null \ + || fail 'the fixture compositor did not stack the mirrored output on its target' +run ipc --pid "$harness_pid" call displays-test revertChange >/dev/null +settle + +# The carve-out is exactly one record wide. Same readback, same layout, with +# the mirror flag removed: now the position disagreement is a real one. +# +# Both fixtures are in the shape the service holds after parsing a readback, +# not in hyprctl's own: this probes the comparison, not the parser. +mirrored_readback='[{"name":"DP-2","width":4500,"height":3000,"refreshRate":60,"scale":1.5,"transform":0,"x":0,"y":0,"primary":true,"colorPreset":"auto","bitdepth":8,"sdrBrightness":1.0,"sdrSaturation":1.0,"mirrorOf":""},{"name":"HDMI-A-1","width":2560,"height":1440,"refreshRate":60,"scale":1,"transform":0,"x":0,"y":0,"primary":false,"colorPreset":"auto","bitdepth":8,"sdrBrightness":1.0,"sdrSaturation":1.0,"mirrorOf":"DP-2"}]' +mirrored_request='[{"name":"DP-2","mode":"4500x3000@60.00","scale":1.5,"transform":0,"x":0,"y":0,"primary":true,"colorProfile":"auto","bitdepth":8,"sdrBrightness":1.0,"sdrSaturation":1.0,"mirrorOf":"","vrrMode":-1},{"name":"HDMI-A-1","mode":"2560x1440@60.00","scale":1,"transform":0,"x":3000,"y":100,"primary":false,"colorProfile":"auto","bitdepth":8,"sdrBrightness":1.0,"sdrSaturation":1.0,"mirrorOf":"DP-2","vrrMode":-1}]' +unmirror='(.[] | select(.name == "HDMI-A-1") | .mirrorOf) = ""' +[[ "$(probe layoutMatch "$mirrored_readback" "$mirrored_request")" == "true" ]] \ + || fail 'a mirrored output was held to the position the compositor overrode' +[[ "$(probe layoutMatch \ + "$(jq -c "$unmirror" <<<"$mirrored_readback")" \ + "$(jq -c "$unmirror" <<<"$mirrored_request")")" == "false" ]] \ + || fail 'the position carve-out leaked to a record that is not mirrored' + +# Anything the table does not allow is refused before the desktop moves. +while IFS='|' read -r output patch reason; do + [[ "$(apply_record "$output" "$patch")" == "false" ]] || fail "$reason" + [[ "$(transaction_status | jq -r .awaiting)" == "false" ]] \ + || fail "$reason (and it left a change pending)" +done <<'CASES' +HDMI-A-1|{"mirrorOf":"HDMI-A-1"}|a display was allowed to mirror itself +DP-2|{"mirrorOf":"HDMI-A-1"}|the primary display was allowed to mirror another +HDMI-A-1|{"mirrorOf":"NOPE-1"}|a display was allowed to mirror an output that is not connected +HDMI-A-1|{"vrrMode":7}|an out-of-range VRR mode was accepted +HDMI-A-1|{"colorProfile":"neon"}|an unknown colour profile was accepted +HDMI-A-1|{"bitdepth":12}|an unsupported bit depth was accepted +HDMI-A-1|{"sdrBrightness":4}|an out-of-range SDR brightness was accepted +CASES + +# Every machine that has this installed already has a settings.json with none +# of the new fields in it. Those entries must keep validating, or a docking +# station restores nothing on the next start. +old_shape='{"mode":"2560x1440@60.00","scale":1,"transform":0,"x":3000,"y":100,"primary":false}' +new_shape='{"mode":"2560x1440@60.00","scale":1,"transform":0,"x":3000,"y":100,"primary":false,"vrrMode":-1,"colorProfile":"srgb","bitdepth":10,"sdrBrightness":1.0,"sdrSaturation":1.0,"mirrorOf":""}' +bad_shape='{"mode":"2560x1440@60.00","scale":1,"transform":0,"x":3000,"y":100,"primary":false,"vrrMode":7}' +[[ "$(run ipc --pid "$harness_pid" call displays-test persistedEntryValid "$(b64 "$old_shape")")" == "true" ]] \ + || fail 'a settings.json written before the colour fields existed stopped validating' +[[ "$(run ipc --pid "$harness_pid" call displays-test persistedEntryValid "$(b64 "$new_shape")")" == "true" ]] \ + || fail 'a stored entry carrying the extended record was rejected' +[[ "$(run ipc --pid "$harness_pid" call displays-test persistedEntryValid "$(b64 "$bad_shape")")" == "false" ]] \ + || fail 'a stored entry with an impossible VRR mode was accepted' + # A revert that exits zero but reads back wrong remains an explicit manual # recovery error rather than pretending the desktop was restored. [[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 400)" == "true" ]] \ diff --git a/tests/quickshell/displays-contract b/tests/quickshell/displays-contract index 3746676..2e7d73a 100755 --- a/tests/quickshell/displays-contract +++ b/tests/quickshell/displays-contract @@ -48,6 +48,15 @@ for contract in \ 'primary: monitor.name === primaryName'; do rg -Fq "$contract" "$service" || fail "display service contract is missing: $contract" done +# The rest of the record rides the same transaction. Colour and mirroring are +# verified by readback like geometry; the VRR override is applied and never +# verified, because the readback field is live adaptive-sync state rather than +# the configured policy. Either way they are part of currentLayout, which is +# what stops one apply from clobbering another field's value. +for contract in 'colorProfile' 'bitdepth' 'sdrBrightness' 'mirrorOf' 'vrrMode'; do + rg -Fq "$contract" "$service" || fail "the extended display record is missing: $contract" +done + rg -Fq 'enabled: Displays.canConfirm' "$page" \ || fail 'Keep is enabled before the display change is verified' rg -Fq 'options: Displays.scalesForMode(' "$page" \ @@ -67,7 +76,8 @@ rg -Fq 'if (root.busy)' "$service" \ # Stored JSON is untyped at field level, so the Lua startup consumer is the # final validation boundary and must support every named output it accepts. -for contract in 'valid_mode' 'valid_scale' 'valid_transform' 'valid_position' 'valid_primary' 'pairs(displays)'; do +for contract in 'valid_mode' 'valid_scale' 'valid_transform' 'valid_position' 'valid_primary' \ + 'color_profile' 'bitdepth_value' 'vrr_value' 'sdr_value' 'mirror_value' 'pairs(displays)'; do rg -Fq "$contract" "$monitors_lua" || fail "monitor startup validation is missing: $contract" done @@ -93,6 +103,7 @@ package.preload["prefs"] = function() ["HDMI-A-1"] = { mode = "2560x1440@60", scale = 1, transform = 1, x = 3000, y = 0, primary = false, + vrrMode = -1, }, ["LEGACY-1"] = { mode = "1920x1080@60", scale = 1.5, transform = 0 }, ["PARTIAL-1"] = { @@ -107,6 +118,24 @@ package.preload["prefs"] = function() mode = "1920x1080@60", scale = 1, transform = 0, x = 4440, y = 0, primary = false, }, + -- The extended record, as Settings stores it. + ["DP-3"] = { + mode = "1920x1080@60", scale = 1, transform = 0, + x = 4440, y = 0, primary = false, + colorProfile = "srgb", bitdepth = 10, + sdrBrightness = 1.2, sdrSaturation = 1.0, + vrrMode = 2, mirrorOf = "DP-2", + }, + -- Every new field impossible at once. Geometry is fine, so the + -- display is still configured; the bad fields drop out one by + -- one exactly as an invalid position does. + ["DP-4"] = { + mode = "1920x1080@60", scale = 1, transform = 0, + x = 6360, y = 0, primary = false, + colorProfile = "neon", bitdepth = 12, + sdrBrightness = 9, sdrSaturation = -1, + vrrMode = 7, mirrorOf = "BAD OUTPUT", + }, } end, } @@ -139,9 +168,38 @@ assert(by_output["PARTIAL-1"] == nil) assert(by_output["BAD-PRIMARY"] == nil) assert(by_output["BAD OUTPUT"] == nil) assert(by_output[""] ~= nil) + +-- The extended record reaches the compositor under Hyprland's own key names. +assert(by_output["DP-3"].cm == "srgb") +assert(by_output["DP-3"].bitdepth == 10) +assert(by_output["DP-3"].sdrbrightness == 1.2) +assert(by_output["DP-3"].vrr == 2) +assert(by_output["DP-3"].mirror == "DP-2") +-- A mirrored output shows its target's picture in its target's place, so the +-- saved position is not ours to ask for. +assert(by_output["DP-3"].position == "auto") +-- Neutral SDR saturation is left out rather than written: a rule that names it +-- pins the display to it. +assert(by_output["DP-3"].sdrsaturation == nil) + +-- -1 means "follow the global policy", which is the absence of an override, +-- not an override with a special value. Writing a vrr key here would silently +-- take this display out of the gaming policy it is meant to follow. +assert(by_output["HDMI-A-1"].vrr == nil) + +-- Bad fields drop, the display survives. This is the same fallback invalid +-- geometry gets, and it matters more here: refusing the whole entry over an +-- unreadable colour profile would lose the resolution too. +assert(by_output["DP-4"] ~= nil) +assert(by_output["DP-4"].position == "6360x0") +assert(by_output["DP-4"].cm == nil) +assert(by_output["DP-4"].bitdepth == nil) +assert(by_output["DP-4"].sdrbrightness == nil) +assert(by_output["DP-4"].vrr == nil) +assert(by_output["DP-4"].mirror == nil) LUA -rg -Fq 'Resolution, scale, rotation, position, and primary display' \ +rg -Fq 'Resolution, scale, rotation, position, primary display, color, VRR override, and mirroring' \ "$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml" \ || fail 'the display preference does not document complete layout persistence'