Appearance was six cards deep and Light/Dark was the third of them, below the wallpaper grid and the entire lock screen -- so the control reached most often was the last one you got to. It is five tabs now, Theme first. The mock showed four; the page turned out to have eleven cards, so Titlebars and Windows became Windows, and Clock and vitals became Shell, rather than pretending four would hold them. Region, Date & Time and Displays each rendered a complete dataset as rows: every installed locale, the whole tz database, every mode the monitor advertises. The chooser was never the problem -- SearchPicker already existed and worked. It was simply rendered always-expanded, so the one line saying what is currently set sat under hundreds that were not. PickerRow collapses each behind its current value and closes again once something is picked. The avatar never appeared to change because accountsservice writes every picture to the same path, leaving the URL byte-identical while Qt served its cached image. cache:false was already set and could not have helped: an unchanged source is never re-read at all. avatarUrl now carries a revision fragment, bumped only when a write actually succeeds. Pictures are cropped before they are set, in the picture's own pixel coordinates so the result does not depend on the size it happened to be displayed at, and written out at 512x512 through GdkPixbuf -- already a dependency here, so nothing new is required. Snapshots listed nothing. The timeline and its Delete buttons existed the whole time, behind a row labelled "Browse...", a word that promises a file browser. The three most recent points are shown inline now, with the rest one press away. qmldir-registration-contract exists because an unregistered component is not a quiet problem: Quickshell fails the entire configuration on it, so the settings window dies and the bar and dock go with it. That happened twice while writing this, both times on a machine somebody was using. It is pure file inspection, so it runs before a change ever reaches the running shell. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
308 lines
12 KiB
QML
308 lines
12 KiB
QML
// Displays.
|
||
//
|
||
// Resolution, refresh rate, scale, and rotation, plus panel brightness and the
|
||
// gaming display policy that was already here.
|
||
//
|
||
// 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.
|
||
|
||
import QtQuick
|
||
import qs.config
|
||
import qs.services
|
||
import qs.widgets
|
||
|
||
SettingsPage {
|
||
id: root
|
||
|
||
title: "Displays"
|
||
lede: SystemSettings.monitorDescription || "Reading the active display…"
|
||
|
||
property string selectedOutput: ""
|
||
readonly property var monitor: Displays.monitorNamed(root.selectedOutput)
|
||
?? (Displays.monitors.length > 0 ? Displays.monitors[0] : null)
|
||
readonly property string currentMode: root.monitor
|
||
? root.monitor.mode
|
||
: ""
|
||
|
||
function syncSelectedOutput(): void {
|
||
if (!Displays.monitorNamed(root.selectedOutput))
|
||
root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[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, because while it is counting down
|
||
// it is the only thing that matters on this page.
|
||
header: Component {
|
||
Rectangle {
|
||
visible: Displays.awaitingConfirmation
|
||
implicitHeight: visible ? confirmRow.implicitHeight + 28 : 0
|
||
radius: Theme.cardRadius
|
||
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
|
||
|
||
Column {
|
||
width: parent.width - keepButton.width - revertButton.width - 28
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
spacing: 3
|
||
|
||
Text {
|
||
width: parent.width
|
||
text: "Keep this display setting?"
|
||
color: Theme.fg
|
||
font.family: Theme.fontFamily
|
||
font.pixelSize: Theme.fontSize
|
||
font.weight: Font.DemiBold
|
||
}
|
||
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."
|
||
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"
|
||
enabled: Displays.canConfirm
|
||
onClicked: Displays.confirm()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
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.monitors.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)
|
||
}
|
||
}
|
||
|
||
SettingsCard {
|
||
visible: root.monitor !== null
|
||
title: "Resolution"
|
||
subtitle: "Applied straight away, then reverted automatically unless you confirm."
|
||
|
||
PickerRow {
|
||
id: modePicker
|
||
|
||
label: "Resolution"
|
||
detail: root.monitor
|
||
? root.monitor.width + " × " + root.monitor.height + " native"
|
||
: "No display selected"
|
||
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()
|
||
}
|
||
}
|
||
}
|
||
|
||
SettingsCard {
|
||
visible: root.monitor !== null
|
||
title: "Scale and rotation"
|
||
|
||
ChoiceGrid {
|
||
width: parent.width
|
||
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."
|
||
options: Displays.scalesForMode(root.currentMode)
|
||
.map(scale => ({ value: scale, label: scale.toFixed(2) + "×" }))
|
||
current: root.monitor ? root.monitor.scale : 1
|
||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||
onPicked: value => root.applyWith({ scale: value })
|
||
}
|
||
|
||
ChoiceGrid {
|
||
width: parent.width
|
||
label: "Rotation"
|
||
options: Displays.transforms
|
||
current: root.monitor ? root.monitor.transform : 0
|
||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||
divider: false
|
||
onPicked: value => root.applyWith({ transform: value })
|
||
}
|
||
}
|
||
|
||
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."
|
||
|
||
ToggleRow { setting: "nightLightEnabled" }
|
||
ToggleRow { setting: "nightLightAutomatic" }
|
||
TimeOfDayRow { setting: "nightLightFrom" }
|
||
TimeOfDayRow { setting: "nightLightTo" }
|
||
SliderRow { setting: "nightLightTemperature"; divider: false }
|
||
}
|
||
|
||
// 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.
|
||
SettingsCard {
|
||
visible: Brightness.available || Brightness.lastError !== ""
|
||
title: "Brightness"
|
||
subtitle: Brightness.available
|
||
? "Sent to the monitor over DDC/CI, the same channel its buttons use."
|
||
: Brightness.lastError
|
||
|
||
Repeater {
|
||
model: Brightness.displays
|
||
|
||
SettingRow {
|
||
id: brightnessRow
|
||
required property var modelData
|
||
required property int index
|
||
|
||
label: Displays.monitorNamed(modelData.connector)?.description || modelData.connector
|
||
detail: modelData.connector ? modelData.connector + " · " + modelData.value + "%"
|
||
: modelData.value + "%"
|
||
divider: brightnessRow.index < Brightness.displays.length - 1
|
||
controlWidth: 190
|
||
|
||
ValueSlider {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
anchors.right: parent.right
|
||
width: parent.width
|
||
value: brightnessRow.modelData.value / 100
|
||
onMoved: v => Brightness.set(brightnessRow.modelData.bus, Math.round(v * 100))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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.
|
||
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);
|
||
}
|
||
}
|