716 lines
31 KiB
QML
716 lines
31 KiB
QML
// Displays.
|
||
//
|
||
// 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 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.widgets
|
||
|
||
SettingsPage {
|
||
id: root
|
||
|
||
title: "Displays"
|
||
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)
|
||
?? (Displays.primaryFirstMonitors.length > 0 ? Displays.primaryFirstMonitors[0] : null)
|
||
readonly property string currentMode: root.monitor
|
||
? 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.
|
||
//
|
||
// With one exception, and it is the whole reason this is not a one-liner.
|
||
// While the Keep-or-revert banner is up, nothing has been stored yet --
|
||
// confirm() is what writes -- so currentLayout() still answers vrrMode from
|
||
// the PREVIOUS stored record. The variable-refresh picker therefore snapped
|
||
// back to its old value the instant the change was applied, while the
|
||
// banner beside it asked whether to keep a change the page had just stopped
|
||
// showing. For the length of that window the requested layout is what the
|
||
// pickers must render: it is what was asked for, and it is what keeping
|
||
// will store.
|
||
readonly property var record: {
|
||
const name = root.monitor ? root.monitor.name : "";
|
||
if (name === "" || Displays.monitors.length === 0)
|
||
return null;
|
||
if (Displays.awaitingConfirmation && Displays.pendingRequestedLayout) {
|
||
const requested = Displays.pendingRequestedLayout.find(entry => entry.name === name);
|
||
if (requested)
|
||
return requested;
|
||
}
|
||
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: {
|
||
if (!root.monitor || !root.monitor.modes)
|
||
return [];
|
||
return root.monitor.modes.filter(mode => mode.width === root.monitor.width
|
||
&& 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
|
||
? Displays.primaryFirstMonitors[0].name : "";
|
||
}
|
||
|
||
// Probing I2C for DDC-capable monitors takes on the order of a second, so
|
||
// it runs when this page is opened rather than at shell startup. Monitors
|
||
// do not appear while you are looking at a settings page, so once is enough.
|
||
Component.onCompleted: {
|
||
root.syncSelectedOutput();
|
||
if (!Brightness.scanned)
|
||
Brightness.refresh();
|
||
}
|
||
Connections {
|
||
target: Displays
|
||
function onMonitorsChanged(): void { root.syncSelectedOutput(); }
|
||
}
|
||
|
||
// 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 ? 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)
|
||
|
||
Row {
|
||
id: confirmRow
|
||
anchors.left: parent.left
|
||
anchors.right: parent.right
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
anchors.margins: 16
|
||
spacing: 14
|
||
|
||
CountdownRing {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
secondsLeft: Displays.secondsLeft
|
||
totalSeconds: Displays.confirmSeconds
|
||
}
|
||
|
||
Column {
|
||
width: Math.max(140, parent.width - 40 - keepButton.width
|
||
- revertButton.width - 42)
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
spacing: 3
|
||
|
||
Text {
|
||
width: parent.width
|
||
text: "Keep these display settings?"
|
||
color: Theme.fg
|
||
font.family: Theme.fontFamily
|
||
font.pixelSize: Theme.fontSize
|
||
font.weight: Font.DemiBold
|
||
}
|
||
Text {
|
||
width: parent.width
|
||
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
|
||
font.pixelSize: Theme.fontSizeSmall
|
||
wrapMode: Text.WordWrap
|
||
}
|
||
}
|
||
|
||
SettingsButton {
|
||
id: revertButton
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: "Revert now"
|
||
onClicked: Displays.revert()
|
||
}
|
||
SettingsButton {
|
||
id: keepButton
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: "Keep"
|
||
tone: "accent"
|
||
enabled: Displays.canConfirm
|
||
onClicked: Displays.confirm()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── The canvas ──────────────────────────────────────────────────────────
|
||
DisplayArrangement {
|
||
width: parent.width
|
||
displayService: Displays
|
||
selectedOutput: root.selectedOutput
|
||
interactionEnabled: !Displays.awaitingConfirmation && !Displays.busy
|
||
onSelectionRequested: output => root.selectedOutput = output
|
||
}
|
||
|
||
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
|
||
|
||
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: "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
|
||
|
||
DisplayModePicker {
|
||
width: parent.width
|
||
monitor: root.monitor
|
||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||
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 -- so changing only the
|
||
// rate meant going back through the resolution you already had.
|
||
SettingRow {
|
||
label: "Refresh rate"
|
||
detail: root.monitor
|
||
? "Rates this panel offers at " + root.monitor.width + " × " + root.monitor.height
|
||
: ""
|
||
visible: root.ratesForCurrentResolution.length > 1
|
||
controlWidth: Math.max(150, root.ratesForCurrentResolution.length * 76)
|
||
|
||
Row {
|
||
anchors.right: parent.right
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
spacing: 6
|
||
opacity: !Displays.awaitingConfirmation && !Displays.busy ? 1 : 0.45
|
||
|
||
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 })
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
SegmentRow {
|
||
label: "Scale"
|
||
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: Math.round(scale * 100) + "%" }))
|
||
value: root.monitor ? root.monitor.scale : 1
|
||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||
onSelected: value => root.applyWith({ scale: value })
|
||
}
|
||
|
||
RotationRow {
|
||
label: "Rotation"
|
||
options: Displays.transforms
|
||
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({ mirrorOf: value })
|
||
}
|
||
}
|
||
|
||
// ── Color ───────────────────────────────────────────────────────────────
|
||
SettingsCard {
|
||
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."
|
||
|
||
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 })
|
||
}
|
||
}
|
||
|
||
// ── 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
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Only with something to spread across. On one screen the choice has no
|
||
// meaning, the same way the Touchpad card stays hidden without a touchpad.
|
||
SettingsCard {
|
||
visible: Displays.monitors.length >= 2
|
||
title: "Workspaces"
|
||
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"
|
||
detail: Workspaces.applied
|
||
? (Workspaces.primaryOnly
|
||
? "Workspaces 1 to 10 are on the primary display."
|
||
: "Each display has its own workspaces.")
|
||
: "Chosen, but not in effect yet — the compositor has to reload."
|
||
options: [
|
||
{ value: false, label: "All displays" },
|
||
{ value: true, label: "Primary only" }
|
||
]
|
||
value: Workspaces.primaryOnly
|
||
enabled: !Workspaces.reloading
|
||
divider: !Workspaces.applied
|
||
onSelected: value => Workspaces.choose(value === true)
|
||
}
|
||
|
||
// Appears only when the compositor and the preference disagree, which
|
||
// is also how it disappears: applying makes its own reason to exist go
|
||
// away. A reload is a whole-session event, so it is asked for rather
|
||
// than done quietly the moment the switch moves.
|
||
ActionRow {
|
||
visible: !Workspaces.applied
|
||
label: Workspaces.reloading ? "Reloading…" : "Reload to apply"
|
||
detail: "Re-reads the compositor's configuration. Windows and workspaces stay where they are."
|
||
action: "Reload"
|
||
enabled: !Workspaces.reloading
|
||
divider: false
|
||
onTriggered: Workspaces.apply()
|
||
}
|
||
}
|
||
|
||
SettingsCard {
|
||
visible: Workspaces.lastError !== ""
|
||
title: "Workspace problem"
|
||
subtitle: Workspaces.lastError
|
||
}
|
||
|
||
SettingsCard {
|
||
visible: Displays.lastError !== ""
|
||
title: "Display problem"
|
||
subtitle: Displays.lastError
|
||
}
|
||
|
||
// 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 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);
|
||
}
|
||
}
|