diff --git a/quickshell/Modules/ControlCenter/Details/BrightnessDetail.qml b/quickshell/Modules/ControlCenter/Details/BrightnessDetail.qml index 18e441aff..ac94e197e 100644 --- a/quickshell/Modules/ControlCenter/Details/BrightnessDetail.qml +++ b/quickshell/Modules/ControlCenter/Details/BrightnessDetail.qml @@ -7,6 +7,7 @@ import qs.Modules.ControlCenter import qs.Modules.ControlCenter.Widgets import qs.Services import qs.DCommon.Widgets +import "../../../Common/QmlUtils.js" as QmlUtils Item { id: root @@ -27,6 +28,17 @@ Item { readonly property bool multipleDevices: devices.length > 1 readonly property bool showPins: screenName.length > 0 && multipleDevices + readonly property Item headerActions: DActionButton { + visible: root.showPins && QmlUtils.normalizePinList((CacheData.brightnessDevicePins || {})[root.getScreenPinKey()]).length > 0 + buttonSize: CcMetrics.headerActionSize + iconSize: CcMetrics.headerActionIconSize + iconName: "keep_off" + iconColor: Theme.surfaceText + tooltipText: I18n.tr("Clear All") + Accessible.name: I18n.tr("Clear All") + onClicked: root.clearScreenPins() + } + signal deviceNameChanged(string newDeviceName) function getScreenPinKey() { @@ -43,10 +55,9 @@ Item { function resolveCurrentDevice() { if (!BrightnessService.brightnessAvailable || devices.length === 0) return ""; - const pinKey = getScreenPinKey(); - const pinnedDevice = pinKey ? (CacheData.brightnessDevicePins || {})[pinKey] : ""; - if (pinnedDevice && devices.find(d => d.name === pinnedDevice)) - return pinnedDevice; + const pinned = BrightnessService.pinnedDevices(getScreenPinKey()); + if (pinned.length > 0) + return pinned[0]; if (instanceId) { const widget = (SettingsData.controlCenterWidgets || []).find(w => w.id === "brightnessSlider" && w.instanceId === instanceId); if (widget?.deviceName && devices.find(d => d.name === widget.deviceName)) @@ -68,15 +79,9 @@ Item { function selectDevice(deviceName) { if (!deviceName || deviceName === currentDeviceName) return; - const pinKey = getScreenPinKey(); - if (pinKey) { - const pins = CacheData.brightnessDevicePins || {}; - if (pins[pinKey] && pins[pinKey] !== deviceName) { - const next = JSON.parse(JSON.stringify(pins)); - delete next[pinKey]; - CacheData.set("brightnessDevicePins", next); - } - } + const pinned = QmlUtils.normalizePinList((CacheData.brightnessDevicePins || {})[getScreenPinKey()]); + if (pinned.length > 0 && !pinned.includes(deviceName)) + clearScreenPins(); currentDeviceName = deviceName; BrightnessService.setCurrentDevice(deviceName, true); deviceNameChanged(deviceName); @@ -86,18 +91,19 @@ Item { const pinKey = getScreenPinKey(); if (!pinKey || !deviceName) return false; - return (CacheData.brightnessDevicePins || {})[pinKey] === deviceName; + return QmlUtils.normalizePinList((CacheData.brightnessDevicePins || {})[pinKey]).includes(deviceName); } function togglePinForDevice(deviceName) { const pinKey = getScreenPinKey(); if (!pinKey || !deviceName) return; + CacheData.set("brightnessDevicePins", QmlUtils.togglePinEntry(CacheData.brightnessDevicePins, pinKey, deviceName, Infinity)); + } + + function clearScreenPins() { const pins = JSON.parse(JSON.stringify(CacheData.brightnessDevicePins || {})); - if (pins[pinKey] === deviceName) - delete pins[pinKey]; - else - pins[pinKey] = deviceName; + delete pins[getScreenPinKey()]; CacheData.set("brightnessDevicePins", pins); } diff --git a/quickshell/Modules/ControlCenter/Widgets/BrightnessSliderRow.qml b/quickshell/Modules/ControlCenter/Widgets/BrightnessSliderRow.qml index dd37b2871..686567a46 100644 --- a/quickshell/Modules/ControlCenter/Widgets/BrightnessSliderRow.qml +++ b/quickshell/Modules/ControlCenter/Widgets/BrightnessSliderRow.qml @@ -9,16 +9,18 @@ CcSliderRow { property string deviceName: widgetData.deviceName || "" property string screenName: host?.screenName ?? "" + readonly property var pinnedDevices: { + if (!screenName) + return []; + const screen = Quickshell.screens.find(s => s.name === screenName); + return BrightnessService.pinnedDevices(screen ? SettingsData.getScreenDisplayName(screen) : screenName); + } + property string targetDeviceName: { if (!BrightnessService.brightnessAvailable || !BrightnessService.devices || BrightnessService.devices.length === 0) return ""; - if (screenName) { - const screen = Quickshell.screens.find(s => s.name === screenName); - const pinKey = screen ? SettingsData.getScreenDisplayName(screen) : screenName; - const pinnedDevice = (CacheData.brightnessDevicePins || {})[pinKey]; - if (pinnedDevice && BrightnessService.devices.find(dev => dev.name === pinnedDevice)) - return pinnedDevice; - } + if (pinnedDevices.length > 0) + return pinnedDevices[0]; if (deviceName && BrightnessService.devices.find(dev => dev.name === deviceName)) return deviceName; const currentDeviceName = BrightnessService.currentDevice; @@ -55,7 +57,7 @@ CcSliderRow { onSliderValueChanged: newValue => { if (!BrightnessService.brightnessAvailable || !targetDeviceName) return; - BrightnessService.setBrightness(newValue, targetDeviceName, true); + BrightnessService.setGroupBrightness(newValue, pinnedDevices.length > 0 ? pinnedDevices : [targetDeviceName], true); } Binding { diff --git a/quickshell/Modules/DBar/Widgets/ControlCenterButton.qml b/quickshell/Modules/DBar/Widgets/ControlCenterButton.qml index 4c9ec7394..0b1197b75 100644 --- a/quickshell/Modules/DBar/Widgets/ControlCenterButton.qml +++ b/quickshell/Modules/DBar/Widgets/ControlCenterButton.qml @@ -188,16 +188,13 @@ BasePill { return root.screenName; } - function getPinnedBrightnessDevice() { - const pinKey = getScreenPinKey(); - if (!pinKey) - return ""; - const pins = CacheData.brightnessDevicePins || {}; - return pins[pinKey] || ""; + function getBrightnessTargets() { + const pinned = BrightnessService.pinnedDevices(getScreenPinKey()); + return pinned.length > 0 ? pinned : [BrightnessService.getDefaultDevice()]; } function getEffectiveBrightnessDevice() { - return getPinnedBrightnessDevice() || BrightnessService.getDefaultDevice(); + return getBrightnessTargets()[0]; } function handleVolumeWheel(delta) { @@ -267,7 +264,7 @@ BasePill { const currentBrightness = BrightnessService.getDeviceBrightness(deviceName); const newBrightness = delta > 0 ? Math.min(100, currentBrightness + step) : Math.max(1, currentBrightness - step); - BrightnessService.setBrightness(newBrightness, deviceName); + BrightnessService.setGroupBrightness(newBrightness, getBrightnessTargets()); } function getBrightness() { diff --git a/quickshell/Modules/DIsland/Activities/HomeCompact.qml b/quickshell/Modules/DIsland/Activities/HomeCompact.qml index 395e8eb31..d44d656c9 100644 --- a/quickshell/Modules/DIsland/Activities/HomeCompact.qml +++ b/quickshell/Modules/DIsland/Activities/HomeCompact.qml @@ -284,7 +284,7 @@ Item { return; SessionData.suppressOSDTemporarily(); const next = Math.max(BrightnessService.brightnessMinimum(root.brightnessDevice), Math.min(root.brightnessMaximum, BrightnessService.brightnessLevel + direction * 5)); - BrightnessService.setBrightness(next, root.brightnessDevice.id, true); + BrightnessService.setGroupBrightness(next, BrightnessService.lastIpcTargets(), true); } function connectivityIconName(type) { diff --git a/quickshell/Modules/DIsland/IslandSystemSource.qml b/quickshell/Modules/DIsland/IslandSystemSource.qml index 7a60fbe99..6bb801215 100644 --- a/quickshell/Modules/DIsland/IslandSystemSource.qml +++ b/quickshell/Modules/DIsland/IslandSystemSource.qml @@ -141,7 +141,7 @@ QtObject { AudioService.setMicVolume(Math.round(clampedRatio * maximum)); return; case "brightness": - BrightnessService.setBrightness(Math.round(clampedRatio * maximum), BrightnessService.lastIpcDevice, true); + BrightnessService.setGroupBrightness(Math.round(clampedRatio * maximum), BrightnessService.lastIpcTargets(), true); return; } } diff --git a/quickshell/Modules/OSD/BrightnessOSD.qml b/quickshell/Modules/OSD/BrightnessOSD.qml index 375b395c3..d4e43bf31 100644 --- a/quickshell/Modules/OSD/BrightnessOSD.qml +++ b/quickshell/Modules/OSD/BrightnessOSD.qml @@ -42,7 +42,7 @@ LevelOSD { onLevelRequested: level => { displayLevel = level; - BrightnessService.setBrightness(level, BrightnessService.lastIpcDevice, true); + BrightnessService.setGroupBrightness(level, BrightnessService.lastIpcTargets(), true); } Connections { diff --git a/quickshell/Services/BrightnessService.qml b/quickshell/Services/BrightnessService.qml index b7f5cd999..a89d35e9c 100644 --- a/quickshell/Services/BrightnessService.qml +++ b/quickshell/Services/BrightnessService.qml @@ -6,6 +6,7 @@ import Quickshell import Quickshell.Io import qs.Common import qs.Services +import "../Common/QmlUtils.js" as QmlUtils Singleton { id: root @@ -21,6 +22,7 @@ Singleton { property int brightnessVersion: 0 property string currentDevice: "" property string lastIpcDevice: "" + property var lastIpcGroup: [] property int brightnessLevel: { brightnessVersion; const deviceToUse = lastIpcDevice === "" ? getDefaultDevice() : (lastIpcDevice || currentDevice); @@ -235,6 +237,8 @@ Singleton { if (actualDevice !== lastIpcDevice) { lastIpcDevice = actualDevice; } + if (lastIpcGroup.length > 0) + lastIpcGroup = []; const deviceInfo = getCurrentDeviceInfoByName(actualDevice); const isExponential = deviceInfo?.class !== "software" && SessionData.getBrightnessExponential(actualDevice); @@ -296,6 +300,25 @@ Singleton { }); } + function setGroupBrightness(percentage, deviceIds, suppressOsd) { + const lead = getCurrentDeviceInfoByName(deviceIds[0]); + const leadMin = brightnessMinimum(lead); + const leadMax = brightnessMaximum(lead); + const ratio = leadMax > leadMin ? Math.max(0, Math.min(1, (percentage - leadMin) / (leadMax - leadMin))) : 1; + + for (const id of deviceIds.slice(1)) { + const info = getCurrentDeviceInfoByName(id); + const min = brightnessMinimum(info); + setBrightness(Math.round(min + ratio * (brightnessMaximum(info) - min)), id, true); + } + setBrightness(percentage, deviceIds[0], suppressOsd); + lastIpcGroup = deviceIds; + } + + function lastIpcTargets() { + return lastIpcGroup.length > 1 && lastIpcGroup[0] === lastIpcDevice ? lastIpcGroup : [lastIpcDevice]; + } + function setCurrentDevice(deviceName, saveToSession = false) { if (currentDevice === deviceName) { return; @@ -354,33 +377,17 @@ Singleton { return devices.length > 0 ? devices[0].id : ""; } - function getPinnedDeviceForFocusedScreen() { - const focusedScreen = CompositorService.getFocusedScreen(); - if (!focusedScreen) - return ""; - - const pins = CacheData.brightnessDevicePins || {}; - const screenKey = SettingsData.getScreenDisplayName(focusedScreen); - if (!screenKey) - return ""; - - const pinnedDevice = pins[screenKey]; - if (!pinnedDevice) - return ""; - - const deviceExists = devices.some(d => d.id === pinnedDevice); - if (!deviceExists) - return ""; - - return pinnedDevice; + function pinnedDevices(pinKey) { + if (!pinKey) + return []; + const pinned = QmlUtils.normalizePinList((CacheData.brightnessDevicePins || {})[pinKey]); + return pinned.filter(id => devices.some(d => d.id === id)); } - function getPreferredDevice() { - const pinned = getPinnedDeviceForFocusedScreen(); - if (pinned) - return pinned; - - return getDefaultDevice(); + function getPreferredDevices() { + const focusedScreen = CompositorService.getFocusedScreen(); + const pinned = focusedScreen ? pinnedDevices(SettingsData.getScreenDisplayName(focusedScreen)) : []; + return pinned.length > 0 ? pinned : [getDefaultDevice()]; } function getCurrentDeviceInfo() { @@ -595,7 +602,8 @@ Singleton { if (isNaN(value)) return "Invalid brightness value: " + percentage; - const actualDevice = device || root.getPreferredDevice(); + const targets = device ? [device] : root.getPreferredDevices(); + const actualDevice = targets[0]; if (actualDevice && !root.devices.some(d => d.id === actualDevice)) return "Device not found: " + actualDevice; @@ -608,7 +616,7 @@ Singleton { if (actualDevice && actualDevice !== root.currentDevice) root.setCurrentDevice(actualDevice, false); - root.setBrightness(clampedValue, actualDevice); + root.setGroupBrightness(clampedValue, targets); return actualDevice ? "Brightness set to " + clampedValue + "% on " + actualDevice : "Brightness set to " + clampedValue + "%"; } @@ -617,7 +625,8 @@ Singleton { if (!root.brightnessAvailable) return "Brightness control not available"; - const actualDevice = device || root.getPreferredDevice(); + const targets = device ? [device] : root.getPreferredDevices(); + const actualDevice = targets[0]; if (actualDevice && !root.devices.some(d => d.id === actualDevice)) return "Device not found: " + actualDevice; @@ -633,7 +642,7 @@ Singleton { const maxValue = root.brightnessMaximum(deviceInfo); const newBrightness = Math.min(maxValue, currentBrightness + stepValue); - root.setBrightness(newBrightness, actualDevice); + root.setGroupBrightness(newBrightness, targets); return "Brightness increased by " + stepValue + "%" + (device ? " on " + actualDevice : ""); } @@ -642,7 +651,8 @@ Singleton { if (!root.brightnessAvailable) return "Brightness control not available"; - const actualDevice = device || root.getPreferredDevice(); + const targets = device ? [device] : root.getPreferredDevices(); + const actualDevice = targets[0]; if (actualDevice && !root.devices.some(d => d.id === actualDevice)) return "Device not found: " + actualDevice; @@ -659,7 +669,7 @@ Singleton { const newBrightness = Math.max(minValue, currentBrightness - stepValue); - root.setBrightness(newBrightness, actualDevice); + root.setGroupBrightness(newBrightness, targets); return "Brightness decreased by " + stepValue + "%" + (device ? " on " + actualDevice : ""); } diff --git a/quickshell/tests/brightness-groups.test.mjs b/quickshell/tests/brightness-groups.test.mjs new file mode 100644 index 000000000..614eeedcb --- /dev/null +++ b/quickshell/tests/brightness-groups.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; +import { loadScript } from "./qml-script.mjs"; + +const SERVICE = "../Services/BrightnessService.qml"; +const plain = value => JSON.parse(JSON.stringify(value)); + +function functionSource(file, name) { + const source = readFileSync(new URL(file, import.meta.url), "utf8"); + const found = source.match(new RegExp(`^ function ${name}\\([^)]*\\) \\{\\n[\\s\\S]*?^ \\}`, "m")); + if (!found) + throw new Error(`${name} not found in ${file}`); + return found[0]; +} + +function service(globals) { + const context = vm.createContext(Object.assign({ + QmlUtils: loadScript(new URL("../Common/QmlUtils.js", import.meta.url)), + lastIpcGroup: [], + lastIpcDevice: "" + }, globals)); + for (const name of ["setGroupBrightness", "lastIpcTargets", "pinnedDevices"]) + vm.runInContext(functionSource(SERVICE, name), context); + return context; +} + +function recordingService(ranges) { + const calls = []; + const context = service({ + getCurrentDeviceInfoByName: id => ranges[id], + brightnessMinimum: info => info?.min ?? 1, + brightnessMaximum: info => info?.max ?? 100, + setBrightness(value, id, suppressOsd) { + calls.push([value, id, suppressOsd]); + context.lastIpcDevice = id; + context.lastIpcGroup = []; + } + }); + return { context, calls }; +} + +test("a group maps the lead's position onto each member's range and sets the lead last", () => { + const { context, calls } = recordingService({ + "backlight:a": { min: 1, max: 100 }, + "ddc:i2c-11": { min: 1, max: 100 }, + "ddc:i2c-12": { min: 0, max: 50 } + }); + + context.setGroupBrightness(51, ["ddc:i2c-11", "backlight:a", "ddc:i2c-12"], false); + + assert.deepEqual(calls, [[51, "backlight:a", true], [25, "ddc:i2c-12", true], [51, "ddc:i2c-11", false]]); + assert.deepEqual(plain(context.lastIpcTargets()), ["ddc:i2c-11", "backlight:a", "ddc:i2c-12"]); +}); + +test("a single or default target is set once and never becomes a group", () => { + const { context, calls } = recordingService({}); + + context.setGroupBrightness(40, [""], false); + + assert.deepEqual(calls, [[40, "", false]]); + assert.deepEqual(plain(context.lastIpcTargets()), [""]); +}); + +test("follow-up controls drop the group once another device became the last target", () => { + const context = service({ lastIpcGroup: ["ddc:i2c-11", "ddc:i2c-12"], lastIpcDevice: "backlight:a" }); + + assert.deepEqual(plain(context.lastIpcTargets()), ["backlight:a"]); +}); + +test("pins read legacy single-device strings and skip unplugged devices", () => { + const devices = [{ id: "backlight:a" }, { id: "ddc:i2c-11" }]; + const context = service({ + devices, + CacheData: { brightnessDevicePins: { "eDP-1": "backlight:a", "DP-1": ["ddc:i2c-12", "ddc:i2c-11", "backlight:a"] } } + }); + + assert.deepEqual(plain(context.pinnedDevices("eDP-1")), ["backlight:a"]); + assert.deepEqual(plain(context.pinnedDevices("DP-1")), ["ddc:i2c-11", "backlight:a"]); + assert.deepEqual(plain(context.pinnedDevices("")), []); +});