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
+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" },