It is the system settings app for this desktop, so it is named the way one is. The window is "Settings", the titlebar is "Settings", the wordmark above the search field is gone, the sidebar's last row is "About", and the status line reads "Desktop is healthy". Prose followed the same rule. Forty-odd strings explained what "Panama" does -- "Panama never animates while idle", "Restore Panama defaults", "Panama looks in ~/Pictures/Wallpapers" -- which is how a product describes itself, not how a settings panel describes a setting. They now say what happens. No user-visible "Panama" remains anywhere in the app. Two consequences worth naming: The SUPER+I shortcut's description is user-visible, because the Shortcuts page is generated from it, so that is renamed too. Rebindings are keyed by shipped chord rather than description, so no existing override is orphaned by this. Three contracts matched the window by title and one matched that shortcut by description; all four are updated. There are no Hyprland window rules keyed on the title, so nothing about the window's placement changes. The desktop entry is now Name=Settings, but the FILE keeps its panama-settings name, as does the icon: the dock pins applications by desktop id, and renaming the file would silently unpin it. dock-pins-contract covers exactly that. The dated design docs under docs/superpowers keep the old name. They are a record of what was decided when, and editing them to agree with the present would make them lie about the past. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
279 lines
11 KiB
QML
279 lines
11 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: "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."
|
||
|
||
DisplayModePicker {
|
||
width: parent.width
|
||
monitor: root.monitor
|
||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|