pragma Singleton pragma ComponentBehavior: Bound // From https://github.com/caelestia-dots/shell with modifications. // License: GPLv3 import qs.modules.common import qs.modules.common.functions import Quickshell import Quickshell.Io import Quickshell.Hyprland import QtQuick /** * For managing brightness of monitors. Supports both brightnessctl and ddcutil. */ Singleton { id: root signal brightnessChanged() property var ddcMonitors: [] readonly property list monitors: Quickshell.screens.map(screen => monitorComp.createObject(root, { screen })) function getMonitorForScreen(screen: ShellScreen): var { return monitors.find(m => m.screen === screen); } function increaseBrightness(): void { const focusedName = Hyprland.focusedMonitor.name; const monitor = monitors.find(m => focusedName === m.screen.name); if (monitor) monitor.setBrightness(monitor.brightness + 0.05); } function decreaseBrightness(): void { const focusedName = Hyprland.focusedMonitor.name; const monitor = monitors.find(m => focusedName === m.screen.name); if (monitor) monitor.setBrightness(monitor.brightness - 0.05); } reloadableId: "brightness" onMonitorsChanged: { ddcMonitors = []; ddcProc.running = true; } Process { id: ddcProc command: ["ddcutil", "detect", "--brief"] stdout: SplitParser { splitMarker: "\n\n" onRead: data => { if (data.startsWith("Display ")) { const lines = data.split("\n").map(l => l.trim()); root.ddcMonitors.push({ model: lines.find(l => l.startsWith("Monitor:")).split(":")[2], busNum: lines.find(l => l.startsWith("I2C bus:")).split("/dev/i2c-")[1] }); } } } onExited: root.ddcMonitorsChanged() } Process { id: setProc } component BrightnessMonitor: QtObject { id: monitor required property ShellScreen screen readonly property bool isDdc: { const match = root.ddcMonitors.find(m => m.model === screen.model && !root.monitors.slice(0, root.monitors.indexOf(this)).some(mon => mon.busNum === m.busNum)); return !!match; } readonly property string busNum: { const match = root.ddcMonitors.find(m => m.model === screen.model && !root.monitors.slice(0, root.monitors.indexOf(this)).some(mon => mon.busNum === m.busNum)); return match?.busNum ?? ""; } property int rawMaxBrightness: 100 property real brightness property real brightnessMultiplier: 1.0 property real multipliedBrightness: Math.max(0, Math.min(1, brightness * (Config.options.light.antiFlashbang.enable ? brightnessMultiplier : 1))) property bool ready: false property bool animateChanges: !monitor.isDdc onBrightnessChanged: { if (!monitor.ready) return; root.brightnessChanged(); } Behavior on multipliedBrightness { enabled: monitor.animateChanges NumberAnimation { duration: 200 easing.type: Easing.BezierSpline easing.bezierCurve: Appearance.animationCurves.expressiveEffects } } onMultipliedBrightnessChanged: { if (monitor.animationEnabled) syncBrightness(); else setTimer.restart(); } function initialize() { monitor.ready = false; initProc.command = isDdc ? ["ddcutil", "-b", busNum, "getvcp", "10", "--brief"] : ["sh", "-c", `echo "a b c $(brightnessctl g) $(brightnessctl m)"`]; initProc.running = true; } readonly property Process initProc: Process { stdout: SplitParser { onRead: data => { const [, , , current, max] = data.split(" "); monitor.rawMaxBrightness = parseInt(max); monitor.brightness = parseInt(current) / monitor.rawMaxBrightness; monitor.ready = true; } } } // We need a delay for DDC monitors because they can be quite slow and might act weird with rapid changes property var setTimer: Timer { id: setTimer interval: monitor.isDdc ? 300 : 0 onTriggered: { syncBrightness(); } } function syncBrightness() { const brightnessValue = Math.max(monitor.multipliedBrightness, 0); if (isDdc) { const rawValueRounded = Math.max(Math.floor(brightnessValue * monitor.rawMaxBrightness), 1); setProc.command = ["ddcutil", "-b", busNum, "setvcp", "10", rawValueRounded]; setProc.startDetached(); } else { const valuePercentNumber = Math.floor(brightnessValue * 100); let valuePercent = `${valuePercentNumber}%`; if (valuePercentNumber == 0) valuePercent = "1"; // Prevent fully black setProc.command = ["brightnessctl", "--class", "backlight", "s", valuePercent, "--quiet"]; setProc.startDetached(); } } function setBrightness(value: real): void { value = Math.max(0, Math.min(1, value)); monitor.brightness = value; } function setBrightnessMultiplier(value: real): void { monitor.brightnessMultiplier = value; } Component.onCompleted: { initialize(); } onBusNumChanged: { initialize(); } } Component { id: monitorComp BrightnessMonitor {} } // Anti-flashbang property int workspaceAnimationDelay: 500 property int contentSwitchDelay: 30 property string screenshotDir: "/tmp/quickshell/brightness/antiflashbang" function brightnessMultiplierForLightness(x: real): real { // I hand picked some values and fitted an exponential curve for this // 6.600135 + 216.360356 * e^(-0.0811129189x) // Division by 100 is to normalize to [0, 1] return (6.600135 + 216.360356 * Math.pow(Math.E, -0.0811129189 * x)) / 100.0; } Variants { model: Quickshell.screens Scope { id: screenScope required property var modelData property string screenName: modelData.name property string screenshotPath: `${root.screenshotDir}/screenshot-${screenName}.png` Connections { enabled: Config.options.light.antiFlashbang.enable && Appearance.m3colors.darkmode target: Hyprland function onRawEvent(event) { if (["activewindowv2", "windowtitlev2"].includes(event.name)) { screenshotTimer.interval = root.contentSwitchDelay; screenshotTimer.restart(); } else if (["workspacev2"].includes(event.name)) { screenshotTimer.interval = root.workspaceAnimationDelay; screenshotTimer.restart(); } } } Timer { id: screenshotTimer interval: 700 // This is what I have for a Hyprland ws anim onTriggered: { screenshotProc.running = false; screenshotProc.running = true; } } Process { id: screenshotProc command: ["bash", "-c", `mkdir -p '${StringUtils.shellSingleQuoteEscape(root.screenshotDir)}'` + ` && grim -o '${StringUtils.shellSingleQuoteEscape(screenScope.screenName)}' -` + ` | magick png:- -colorspace Gray -format "%[fx:mean*100]" info:` ] stdout: StdioCollector { id: lightnessCollector onStreamFinished: { Quickshell.execDetached(["rm", screenScope.screenshotPath]); // Cleanup const lightness = lightnessCollector.text const newMultiplier = root.brightnessMultiplierForLightness(parseFloat(lightness)) Brightness.getMonitorForScreen(screenScope.modelData).setBrightnessMultiplier(newMultiplier) } } } } } // External trigger points IpcHandler { target: "brightness" function increment() { onPressed: root.increaseBrightness() } function decrement() { onPressed: root.decreaseBrightness() } } GlobalShortcut { name: "brightnessIncrease" description: "Increase brightness" onPressed: root.increaseBrightness() } GlobalShortcut { name: "brightnessDecrease" description: "Decrease brightness" onPressed: root.decreaseBrightness() } }