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
@@ -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);
}
}