diff --git a/Modules/Bar/Bar.qml b/Modules/Bar/Bar.qml index 3cadc23..a7db3ab 100644 --- a/Modules/Bar/Bar.qml +++ b/Modules/Bar/Bar.qml @@ -119,6 +119,10 @@ Variants { anchors.verticalCenter: parent.verticalCenter } + Brightness { + anchors.verticalCenter: parent.verticalCenter + } + Clock { anchors.verticalCenter: parent.verticalCenter } diff --git a/Modules/Bar/Brightness.qml b/Modules/Bar/Brightness.qml new file mode 100644 index 0000000..aa754b7 --- /dev/null +++ b/Modules/Bar/Brightness.qml @@ -0,0 +1,74 @@ +import QtQuick +import Quickshell +import qs.Modules.Settings +import qs.Services +import qs.Widgets + +Item { + id: root + + width: pill.width + height: pill.height + + // Used to avoid opening the pill on Quickshell startup + property bool firstBrightnessReceived: false + property real lastBrightness: -1 + + function getIcon() { + if (!BrightnessService.available) { + return "brightness_auto" + } + var brightness = BrightnessService.brightness + return brightness <= 0 ? "brightness_1" : + brightness < 33 ? "brightness_low" : + brightness < 66 ? "brightness_medium" : "brightness_high" + } + + // Connection used to open the pill when brightness changes + Connections { + target: BrightnessService.focusedMonitor + function onBrightnessUpdated() { + var currentBrightness = BrightnessService.brightness + + // Ignore if this is the first time or if brightness hasn't actually changed + if (!firstBrightnessReceived) { + firstBrightnessReceived = true + lastBrightness = currentBrightness + return + } + + // Only show pill if brightness actually changed (not just loaded from settings) + if (Math.abs(currentBrightness - lastBrightness) > 0.1) { + pill.show() + } + + lastBrightness = currentBrightness + } + } + + + + NPill { + id: pill + icon: getIcon() + iconCircleColor: Colors.mPrimary + collapsedIconColor: Colors.mOnSurface + autoHide: true + text: Math.round(BrightnessService.brightness) + "%" + tooltipText: "Brightness: " + Math.round(BrightnessService.brightness) + "%\nMethod: " + BrightnessService.currentMethod + "\nLeft click for advanced settings.\nScroll up/down to change brightness." + + onWheel: function (angle) { + if (!BrightnessService.available) return + + if (angle > 0) { + BrightnessService.increaseBrightness() + } else if (angle < 0) { + BrightnessService.decreaseBrightness() + } + } + onClicked: { + settingsPanel.requestedTab = SettingsPanel.Tab.Display + settingsPanel.isLoaded = true + } + } +} \ No newline at end of file diff --git a/Modules/Demo/DemoPanel.qml b/Modules/Demo/DemoPanel.qml index 0a5c748..fa238f1 100644 --- a/Modules/Demo/DemoPanel.qml +++ b/Modules/Demo/DemoPanel.qml @@ -301,6 +301,60 @@ NLoader { Layout.fillWidth: true } } + + // Brightness Control + ColumnLayout { + spacing: Style.marginMedium * scaling + NText { + text: "Brightness Control" + color: Colors.mSecondary + font.weight: Style.fontWeightBold + } + + NText { + text: `Brightness: ${Math.round(Brightness.brightness)}%` + Layout.alignment: Qt.AlignVCenter + } + + RowLayout { + spacing: Style.marginSmall * scaling + NIconButton { + icon: "brightness_low" + fontPointSize: Style.fontSizeLarge * scaling + onClicked: { + Brightness.decreaseBrightness() + } + } + NSlider { + from: 0 + to: 100 + stepSize: 1 + value: Brightness.brightness + implicitWidth: bgRect.width * 0.5 + onMoved: { + Brightness.setBrightnessDebounced(value) + } + } + NIconButton { + icon: "brightness_high" + fontPointSize: Style.fontSizeLarge * scaling + onClicked: { + Brightness.increaseBrightness() + } + } + } + + NText { + text: `Method: ${Brightness.currentMethod} | Available: ${Brightness.available}` + color: Colors.mOnSurfaceVariant + font.pointSize: Style.fontSizeSmall * scaling + Layout.alignment: Qt.AlignHCenter + } + + NDivider { + Layout.fillWidth: true + } + } } } } diff --git a/Modules/Settings/Tabs/DisplayTab.qml b/Modules/Settings/Tabs/DisplayTab.qml index 3234699..c4694bd 100644 --- a/Modules/Settings/Tabs/DisplayTab.qml +++ b/Modules/Settings/Tabs/DisplayTab.qml @@ -47,6 +47,55 @@ Item { color: Colors.mOnSurface } + // Brightness Section + ColumnLayout { + spacing: Style.marginSmall * scaling + Layout.fillWidth: true + Layout.topMargin: Style.marginSmall * scaling + + NText { + text: "Brightness Step Size" + font.weight: Style.fontWeightBold + color: Colors.mOnSurface + } + + NText { + text: "Adjust the step size for brightness changes (scroll wheel, ipc bind)" + font.pointSize: Style.fontSizeSmall * scaling + color: Colors.mOnSurface + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginMedium * scaling + + NSlider { + Layout.fillWidth: true + from: 1 + to: 50 + value: Settings.data.brightness.brightnessStep + stepSize: 1 + onMoved: { + Settings.data.brightness.brightnessStep = value + } + } + + NText { + text: Settings.data.brightness.brightnessStep + "%" + Layout.alignment: Qt.AlignVCenter + color: Colors.mOnSurface + } + } + } + + NDivider { + Layout.fillWidth: true + Layout.topMargin: Style.marginLarge * 2 * scaling + Layout.bottomMargin: Style.marginLarge * scaling + } + Repeater { model: Quickshell.screens || [] delegate: Rectangle { diff --git a/Services/BrightnessService.qml b/Services/BrightnessService.qml new file mode 100644 index 0000000..ce9e130 --- /dev/null +++ b/Services/BrightnessService.qml @@ -0,0 +1,289 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Io + +Singleton { + id: root + + property list ddcMonitors: [] + readonly property list monitors: variants.instances + property bool appleDisplayPresent: false + + // Public properties for backward compatibility + readonly property real brightness: focusedMonitor ? focusedMonitor.brightness * 100 : Settings.data.brightness.lastBrightness + readonly property bool available: focusedMonitor !== null + readonly property string currentMethod: focusedMonitor ? focusedMonitor.method : Settings.data.brightness.lastMethod + readonly property var detectedDisplays: monitors.map(m => ({ + name: m.modelData.name, + type: m.isDdc ? "external" : "internal", + method: m.method, + index: m.busNum + })) + + // Get the currently focused monitor + readonly property Monitor focusedMonitor: { + if (monitors.length === 0) return null + // For now, return the first monitor. Could be enhanced to detect focused monitor + return monitors[0] + } + + function getMonitorForScreen(screen: ShellScreen): var { + return monitors.find(m => m.modelData === screen) + } + + function increaseBrightness(step = null): void { + if (focusedMonitor) { + var stepSize = step !== null ? step : Settings.data.brightness.brightnessStep + focusedMonitor.setBrightness(focusedMonitor.brightness + (stepSize / 100)) + } + } + + function decreaseBrightness(step = null): void { + if (focusedMonitor) { + var stepSize = step !== null ? step : Settings.data.brightness.brightnessStep + focusedMonitor.setBrightness(focusedMonitor.brightness - (stepSize / 100)) + } + } + + function setBrightness(newBrightness: real): void { + if (focusedMonitor) { + focusedMonitor.setBrightness(newBrightness / 100) + } + } + + function setBrightnessDebounced(newBrightness: real): void { + if (focusedMonitor) { + focusedMonitor.setBrightnessDebounced(newBrightness / 100) + } + } + + // Backward compatibility functions + function updateBrightness(): void { + // No longer needed with the new architecture + } + + function setDisplay(displayIndex: int): bool { + // No longer needed with the new architecture + return true + } + + function getDisplayInfo(): var { + return focusedMonitor ? { + name: focusedMonitor.modelData.name, + type: focusedMonitor.isDdc ? "external" : "internal", + method: focusedMonitor.method, + index: focusedMonitor.busNum + } : null + } + + function getAvailableMethods(): list { + var methods = [] + if (monitors.some(m => m.isDdc)) methods.push("ddcutil") + if (monitors.some(m => !m.isDdc)) methods.push("internal") + if (appleDisplayPresent) methods.push("apple") + return methods + } + + function getDetectedDisplays(): list { + return detectedDisplays + } + + reloadableId: "brightness" + + onMonitorsChanged: { + ddcMonitors = [] + ddcProc.running = true + } + + Variants { + id: variants + model: Quickshell.screens + Monitor {} + } + + // Check for Apple Display support + Process { + running: true + command: ["sh", "-c", "which asdbctl >/dev/null 2>&1 && asdbctl get || echo ''"] + stdout: StdioCollector { + onStreamFinished: root.appleDisplayPresent = text.trim().length > 0 + } + } + + // Detect DDC monitors + Process { + id: ddcProc + command: ["ddcutil", "detect", "--brief"] + stdout: StdioCollector { + onStreamFinished: { + var displays = text.trim().split("\n\n").filter(d => d.startsWith("Display ")) + root.ddcMonitors = displays.map(d => { + var modelMatch = d.match(/Monitor:.*:(.*):.*/) + var busMatch = d.match(/I2C bus:[ ]*\/dev\/i2c-([0-9]+)/) + return { + model: modelMatch ? modelMatch[1] : "", + busNum: busMatch ? busMatch[1] : "" + } + }) + } + } + } + + + + component Monitor: QtObject { + id: monitor + + required property ShellScreen modelData + readonly property bool isDdc: root.ddcMonitors.some(m => m.model === modelData.model) + readonly property string busNum: root.ddcMonitors.find(m => m.model === modelData.model)?.busNum ?? "" + readonly property bool isAppleDisplay: root.appleDisplayPresent && modelData.model.startsWith("StudioDisplay") + readonly property string method: isAppleDisplay ? "apple" : (isDdc ? "ddcutil" : "internal") + + property real brightness: getStoredBrightness() + property real queuedBrightness: NaN + + // Signal for brightness changes + signal brightnessUpdated(real newBrightness) + + // Initialize brightness + readonly property Process initProc: Process { + stdout: StdioCollector { + onStreamFinished: { + console.log("[BrightnessService] Raw brightness data for", monitor.modelData.name + ":", text.trim()) + + if (monitor.isAppleDisplay) { + var val = parseInt(text.trim()) + if (!isNaN(val)) { + monitor.brightness = val / 101 + console.log("[BrightnessService] Apple display brightness:", monitor.brightness) + } + } else if (monitor.isDdc) { + var parts = text.trim().split(" ") + if (parts.length >= 2) { + var current = parseInt(parts[0]) + var max = parseInt(parts[1]) + if (!isNaN(current) && !isNaN(max) && max > 0) { + monitor.brightness = current / max + console.log("[BrightnessService] DDC brightness:", current + "/" + max + " =", monitor.brightness) + } + } + } else { + // Internal backlight + var parts = text.trim().split(" ") + if (parts.length >= 2) { + var current = parseInt(parts[0]) + var max = parseInt(parts[1]) + if (!isNaN(current) && !isNaN(max) && max > 0) { + monitor.brightness = current / max + console.log("[BrightnessService] Internal brightness:", current + "/" + max + " =", monitor.brightness) + } + } + } + + if (monitor.brightness > 0) { + // Save the detected brightness to settings + monitor.saveBrightness(monitor.brightness) + monitor.brightnessUpdated(monitor.brightness) + } + } + } + } + + // Timer for debouncing rapid changes + readonly property Timer timer: Timer { + interval: 200 + onTriggered: { + if (!isNaN(monitor.queuedBrightness)) { + monitor.setBrightness(monitor.queuedBrightness) + monitor.queuedBrightness = NaN + } + } + } + + function getStoredBrightness(): real { + // Try to get stored brightness for this specific monitor + var stored = Settings.data.brightness.monitorBrightness.find(m => m.name === modelData.name) + if (stored) { + return stored.brightness / 100 + } + // Fallback to general last brightness + return Settings.data.brightness.lastBrightness / 100 + } + + function saveBrightness(value: real): void { + var brightnessPercent = Math.round(value * 100) + + // Update general last brightness + Settings.data.brightness.lastBrightness = brightnessPercent + Settings.data.brightness.lastMethod = method + + // Update monitor-specific brightness + var monitorIndex = Settings.data.brightness.monitorBrightness.findIndex(m => m.name === modelData.name) + var monitorData = { + name: modelData.name, + brightness: brightnessPercent, + method: method + } + + if (monitorIndex >= 0) { + Settings.data.brightness.monitorBrightness[monitorIndex] = monitorData + } else { + Settings.data.brightness.monitorBrightness.push(monitorData) + } + } + + function setBrightness(value: real): void { + value = Math.max(0, Math.min(1, value)) + var rounded = Math.round(value * 100) + + if (Math.round(brightness * 100) === rounded) return + + if (isDdc && timer.running) { + queuedBrightness = value + return + } + + brightness = value + brightnessUpdated(brightness) + + // Save to settings + saveBrightness(value) + + if (isAppleDisplay) { + Quickshell.execDetached(["asdbctl", "set", rounded]) + } else if (isDdc) { + Quickshell.execDetached(["ddcutil", "-b", busNum, "setvcp", "10", rounded]) + } else { + Quickshell.execDetached(["brightnessctl", "s", rounded + "%"]) + } + + if (isDdc) { + timer.restart() + } + } + + function setBrightnessDebounced(value: real): void { + queuedBrightness = value + timer.restart() + } + + function initBrightness(): void { + if (isAppleDisplay) { + initProc.command = ["asdbctl", "get"] + } else if (isDdc) { + initProc.command = ["ddcutil", "-b", busNum, "getvcp", "10", "--brief"] + } else { + // Internal backlight - try to find the first available backlight device + initProc.command = ["sh", "-c", "for dev in /sys/class/backlight/*; do if [ -f \"$dev/brightness\" ] && [ -f \"$dev/max_brightness\" ]; then echo \"$(cat $dev/brightness) $(cat $dev/max_brightness)\"; break; fi; done"] + } + initProc.running = true + } + + onBusNumChanged: initBrightness() + Component.onCompleted: initBrightness() + } +} \ No newline at end of file diff --git a/Services/IPCManager.qml b/Services/IPCManager.qml index 07f195b..745c529 100644 --- a/Services/IPCManager.qml +++ b/Services/IPCManager.qml @@ -48,4 +48,16 @@ Item { lockScreen.locked = !lockScreen.locked } } + + IpcHandler { + target: "brightness" + + function increase() { + BrightnessService.increaseBrightness() + } + + function decrease() { + BrightnessService.decreaseBrightness() + } + } } diff --git a/Services/Settings.qml b/Services/Settings.qml index 5f79da6..bc4568c 100644 --- a/Services/Settings.qml +++ b/Services/Settings.qml @@ -177,6 +177,16 @@ Singleton { property string fontFamily: "Roboto" // Family for all text property list monitorsScale: [] } + + // brightness + property JsonObject brightness + + brightness: JsonObject { + property real lastBrightness: 50.0 + property string lastMethod: "internal" + property list monitorBrightness: [] + property int brightnessStep: 5 + } } } }