brightnessctl drives the kernel backlight class, which laptop panels have and this desktop does not -- it reports only keyboard and NIC LEDs. So BrightnessControl removed itself and there was no way to dim the screen from Panama at all. DDC/CI is the channel the buttons on a monitor's bezel drive, and it is the only brightness an external display has. Both sources now render a row each, so a machine gets whichever it actually has, or none. Displays are enumerated from sysfs rather than `ddcutil detect`. The kernel publishes the connector-to-bus mapping as /sys/class/drm/<card>-<connector>/ddc along with whether anything is plugged in, which beats parsing detect's undocumented brief output, yields the connector name spelled exactly as Hyprland spells it, and probes only connectors with a monitor attached -- one bus on this machine rather than fourteen, where each empty bus costs a timeout. No model name is read: Hyprland already knows what every output is called, so the UI joins on the connector instead of keeping a second source of truth that could disagree with the Displays page. Writes are debounced, serial, and read back. Serial because DDC/CI has no arbitration and two ddcutil processes on one bus interleave their exchanges and both return garbage. Read back because a write is not a promise: panels clamp to their own range, ignore values while waking from standby, and drop writes that arrive too fast. Without the read the slider would show what Panama asked for rather than what the monitor did, which is the same class of lie as trusting `hyprctl keyword`. Brightness is deliberately not a stored preference. The monitor remembers it and the bezel buttons change it behind Panama's back, so persisting it would mean restoring a value the panel had moved past. The contract runs against fixtures with ddcutil stubbed and both sysfs roots redirected, so it never touches a real monitor. Its fixture reports a maximum of 200 rather than 100 on purpose -- at 100 the scaling arithmetic is the identity and a helper that ignored the reported maximum would pass everything. Verified it catches that, plus a dropped connection-status filter and an unstripped connector prefix. Not yet confirmed against hardware: this machine cannot open any I2C bus yet. ddcutil's udev rule grants that through uaccess but only to devices created after it was installed, so it needs one udevadm trigger. The helper detects exactly that case and returns the command as its error rather than reporting "no displays". 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 resolution and scale Panama ships"
|
||
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 Panama 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);
|
||
}
|
||
}
|