brightness: move every device pinned on a screen together (#3939)

port 2.0
This commit is contained in:
Spyros Psarras authored and GitHub committed 2026-10-28 10:23:15 -04:00
1 parent 4a9ab29515
commit 5d53fdb684
8 files changed
+165 -68

No files matched your search

@@ -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);
}
@@ -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 {
@@ -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() {
@@ -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) {
@@ -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;
}
}
+1 -1
View File
@@ -42,7 +42,7 @@ LevelOSD {
onLevelRequested: level => {
displayLevel = level;
BrightnessService.setBrightness(level, BrightnessService.lastIpcDevice, true);
BrightnessService.setGroupBrightness(level, BrightnessService.lastIpcTargets(), true);
}
Connections {
+41 -31
View File
@@ -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 : "");
}
@@ -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("")), []);
});