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

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