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(); } } } }