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
@@ -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