Files
Panama/config/dot/quickshell/modules/quicksettings/BrightnessControl.qml
Gabriel Brown 7541a45e77 Add external monitor brightness over DDC/CI
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
2026-08-18 08:01:49 -04:00

159 lines
5.4 KiB
QML

// 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)
}
}
}
}