Files

706 lines
31 KiB
QML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 ─────────────────
SectionLabel { text: "All displays" }
// 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);
}
}