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
164 lines
6.1 KiB
QML
164 lines
6.1 KiB
QML
pragma Singleton
|
|
|
|
// Panel brightness for external monitors, over DDC/CI.
|
|
//
|
|
// brightnessctl covers laptop panels through the kernel's backlight class. A
|
|
// desktop driving a DisplayPort monitor has no such device, so it has no
|
|
// brightness control at all -- the only way to dim the screen is the buttons on
|
|
// the bezel. DDC/CI is the channel those buttons drive, and monitors expose it
|
|
// over the same I2C lines that carry EDID.
|
|
//
|
|
// Two things shape everything here:
|
|
//
|
|
// Detection is slow. Probing every I2C bus takes on the order of a second,
|
|
// which is far too slow to sit in front of a settings page opening. It runs
|
|
// once, on demand, and afterwards each display is addressed by its bus number
|
|
// directly.
|
|
//
|
|
// Writes are slow AND rate-limited by the monitor's firmware. A slider drag
|
|
// emits values continuously; sending each one produces a queue the panel
|
|
// works through seconds after the user let go, and some monitors drop or
|
|
// garble writes that arrive too fast. So `value` updates immediately for the
|
|
// UI and the hardware write is debounced, with only the latest value sent.
|
|
//
|
|
// Displays are keyed by DRM connector name (DP-2) so they line up with what
|
|
// Hyprland, the Displays page, and the monitor list already call them.
|
|
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import QtQuick
|
|
|
|
Singleton {
|
|
id: root
|
|
|
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-brightness"
|
|
|
|
// [{ bus, connector, model, value }] where value is 0..100.
|
|
property var displays: []
|
|
property bool scanning: false
|
|
|
|
// Empty when everything is fine. Carries the helper's explanation
|
|
// otherwise -- most usefully the udev command that grants I2C access,
|
|
// which is the difference between "brightness is unavailable" and
|
|
// "brightness is one command away".
|
|
property string lastError: ""
|
|
|
|
readonly property bool available: root.displays.length > 0
|
|
|
|
// True once a scan has completed, however it went. Lets the UI tell "not
|
|
// looked yet" apart from "looked and found nothing", which otherwise render
|
|
// identically and leave a permanently empty panel with no explanation.
|
|
property bool scanned: false
|
|
|
|
// Pending writes, keyed by bus. A monitor being dragged accumulates exactly
|
|
// one entry no matter how many values the slider emits.
|
|
property var pending: ({})
|
|
|
|
function refresh(): void {
|
|
if (root.scanning)
|
|
return;
|
|
root.scanning = true;
|
|
scan.running = true;
|
|
}
|
|
|
|
function displayFor(connector: string): var {
|
|
return root.displays.find(display => display.connector === connector) ?? null;
|
|
}
|
|
|
|
// Sets brightness for one display. The stored value moves at once so the
|
|
// slider tracks the pointer; the hardware follows when the drag settles.
|
|
function set(bus: int, percent: int): void {
|
|
const clamped = Math.max(0, Math.min(100, Math.round(percent)));
|
|
|
|
root.displays = root.displays.map(display =>
|
|
display.bus === bus ? Object.assign({}, display, { value: clamped }) : display);
|
|
|
|
const next = Object.assign({}, root.pending);
|
|
next[String(bus)] = clamped;
|
|
root.pending = next;
|
|
writeDebounce.restart();
|
|
}
|
|
|
|
Process {
|
|
id: scan
|
|
command: [root.helperPath, "list"]
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
try {
|
|
const parsed = JSON.parse(this.text);
|
|
root.displays = Array.isArray(parsed.displays) ? parsed.displays : [];
|
|
root.lastError = String(parsed.error ?? "");
|
|
} catch (error) {
|
|
root.displays = [];
|
|
root.lastError = "Could not read the brightness helper's output.";
|
|
console.warn("Brightness: could not parse helper output:", error);
|
|
}
|
|
root.scanning = false;
|
|
root.scanned = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Long enough that a drag produces one write rather than dozens, short
|
|
// enough that a single click still feels immediate.
|
|
Timer {
|
|
id: writeDebounce
|
|
interval: 120
|
|
onTriggered: root.pump()
|
|
}
|
|
|
|
// Writes run one at a time, and each is read back.
|
|
//
|
|
// Serial because DDC/CI is a bus protocol with no arbitration: two ddcutil
|
|
// processes talking to the same monitor interleave their exchanges and both
|
|
// can come back with 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 quickly. Without the read the slider shows
|
|
// what Panama asked for rather than what the monitor did, which is the same
|
|
// class of lie as trusting `hyprctl keyword` to have applied something.
|
|
property int writingBus: -1
|
|
|
|
function pump(): void {
|
|
if (writer.running || reader.running)
|
|
return;
|
|
|
|
for (const bus in root.pending) {
|
|
const value = root.pending[bus];
|
|
const remaining = Object.assign({}, root.pending);
|
|
delete remaining[bus];
|
|
root.pending = remaining;
|
|
|
|
root.writingBus = parseInt(bus);
|
|
writer.command = [root.helperPath, "set", bus, String(value)];
|
|
writer.running = true;
|
|
return;
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: writer
|
|
onExited: {
|
|
reader.command = [root.helperPath, "get", String(root.writingBus)];
|
|
reader.running = true;
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: reader
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
const actual = parseInt(this.text.trim());
|
|
if (!isNaN(actual)) {
|
|
root.displays = root.displays.map(display =>
|
|
display.bus === root.writingBus
|
|
? Object.assign({}, display, { value: actual })
|
|
: display);
|
|
}
|
|
root.writingBus = -1;
|
|
// Anything queued while this write was in flight goes now.
|
|
root.pump();
|
|
}
|
|
}
|
|
}
|
|
}
|