Files
Panama/config/dot/quickshell/modules/settings/DisplaysPage.qml
T
Gabriel Brown 7e1c85b094 Group the long pages by what you are trying to do
Notifications repeated both lock-screen switch labels for every application, so
twenty apps meant sixty rows of the same two sentences and the page could not be
scanned at all. Each app is one row now, carrying what its switches add up to --
"On, lock screen shows the sender only", "On, hidden on the lock screen",
"Notifications off" -- with the switches behind it, one app open at a time. The
identifier only appears while an app is open, which is the only time it
disambiguates anything, and the content switch dims when the app cannot reach
the lock screen at all, because there it means nothing.

Shortcuts were already grouped; the problem was that "Windows" caught focus,
movement, splitting, resizing and window state alike and held 43 of the 93
binds. A section that long is a list, not a grouping. They are separated by
intent now -- Focus, Move & split, Size, Window state -- and the split was
checked against the binds this machine actually has rather than trusted from the
keywords. Order matters in two places worth naming: "Next window splits down" is
about splitting rather than focus, and "Focus session" is quiet mode bound to a
workspace rather than window focus, so both are settled before the general
checks.

Refresh rate gets its own row. That need was created by collapsing the
resolution list: the rates for a resolution were only ever reachable by opening
it, so changing nothing but the rate meant going through the mode you already
had. It appears only when the current resolution offers more than one.

Default-application rows carry a chevron, having previously opened a chooser
while looking completely inert.

The notification contract asserted the literal Notifs.appRule(app.id).enabled,
which moved when the rows collapsed. The rule is still read through a binding on
Notifs.appRule, so a rule changed elsewhere still reaches the row -- the
assertion now requires that, rather than requiring one particular spelling of
it.

The power profile rows were left alone. A three-way choice in three rows looks
wasteful until you notice each row explains what the profile does, and that page
has empty space to spare; a segmented control would trade information for space
that is not scarce.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 22:54:51 -04:00

353 lines
14 KiB
QML
Raw 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.
//
// 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
: ""
// 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);
}
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()
}
}
// 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
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" : ""
visible: root.ratesForCurrentResolution.length > 1
enabled: !Displays.awaitingConfirmation && !Displays.busy
divider: false
Repeater {
model: root.ratesForCurrentResolution
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();
}
}
}
}
}
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);
}
}