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