// Brightness, from whichever source this machine actually has. // // Two exist and they are not interchangeable: // // The kernel backlight class, driven by brightnessctl. Laptop panels have it; // this desktop does not -- brightnessctl reports only keyboard and NIC LEDs. // // DDC/CI, the channel the buttons on a monitor's bezel drive. That is the // only brightness an external display has, and it is per-monitor. // // A machine may have neither, either, or both, so this renders a row per source // found and removes itself entirely when there are none, rather than sitting // there as a dead control. // // Connector labels appear only when there is more than one row. A single // slider needs no explanation of which screen it dims. import QtQuick import Quickshell import Quickshell.Io import qs.widgets import qs.config import qs.services Item { id: root property bool backlightAvailable: false property real backlightValue: 0 readonly property int rowCount: (root.backlightAvailable ? 1 : 0) + Brightness.displays.length readonly property bool labelled: root.rowCount > 1 visible: root.rowCount > 0 implicitHeight: rows.implicitHeight // Probing I2C takes on the order of a second, so it waits until the panel // is actually on screen rather than running at shell startup. Monitors do // not come and go, so once is enough. onVisibleChanged: if (visible && !Brightness.scanned) Brightness.refresh() Component.onCompleted: if (root.visible && !Brightness.scanned) Brightness.refresh() // `-m` is the machine-readable form: name,class,current,percent,max Process { running: true command: ["brightnessctl", "-m", "-c", "backlight", "-l"] stdout: StdioCollector { onStreamFinished: root.parseDevices(this.text) } } function parseDevices(text: string): void { for (const line of text.trim().split("\n")) { const fields = line.split(","); if (fields.length < 5 || fields[1] !== "backlight") continue; root.backlightAvailable = true; root.backlightValue = parseInt(fields[3]) / 100; return; } } function applyBacklight(v: real): void { root.backlightValue = v; // Never go fully dark: a 0% backlight looks like a broken shell. Quickshell.execDetached(["brightnessctl", "-c", "backlight", "-q", "set", Math.max(1, Math.round(v * 100)) + "%"]); } Column { id: rows anchors.left: parent.left anchors.right: parent.right spacing: 4 BrightnessRow { width: rows.width visible: root.backlightAvailable label: "Built-in" value: root.backlightValue onMoved: v => root.applyBacklight(v) } Repeater { model: Brightness.displays BrightnessRow { required property var modelData width: rows.width // Hyprland already knows what each output is called, so the // name comes from there rather than from a second source that // could disagree with the Displays page. The connector is the // fallback, so a display is never an unlabelled slider. label: Displays.monitorNamed(modelData.connector)?.description || modelData.connector value: modelData.value / 100 onMoved: v => Brightness.set(modelData.bus, Math.round(v * 100)) } } } component BrightnessRow: Item { id: row property string label: "" property real value: 0 signal moved(real value) implicitHeight: caption.height + control.height Text { id: caption anchors.left: parent.left anchors.right: parent.right anchors.leftMargin: 6 anchors.rightMargin: 6 anchors.top: parent.top visible: root.labelled height: visible ? implicitHeight + 2 : 0 text: row.label color: Theme.fgDim font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall elide: Text.ElideRight } // The slider and its glyph share one strip so the two stay aligned // whether or not a caption sits above them. Anchoring the glyph to // both a caption and a centre line instead would conflict, and an // anchor set to undefined is not released. Item { id: control anchors.left: parent.left anchors.right: parent.right anchors.top: caption.bottom height: 32 // ValueSlider draws its own leading icon, but symbolic icons need // recolouring to be visible — see ThemedIcon. ThemedIcon { id: glyph anchors.left: parent.left anchors.leftMargin: 6 anchors.verticalCenter: parent.verticalCenter size: 17 icon: "display-brightness-symbolic" } ValueSlider { anchors.left: glyph.right anchors.leftMargin: 12 anchors.right: parent.right anchors.rightMargin: 32 anchors.verticalCenter: parent.verticalCenter value: row.value onMoved: v => row.moved(v) } } } }