diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 10c4bd0a6..512f35ddd 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -540,6 +540,7 @@ Singleton { property string batteryProfileName: "" property int batteryPostLockMonitorTimeout: 0 property int batteryChargeLimit: 100 + property bool lowerDisplayRefreshRateOnBattery: false property bool lockBeforeSuspend: false property bool loginctlLockIntegration: true property bool fadeToLockEnabled: true @@ -735,6 +736,7 @@ Singleton { property var hyprlandOutputSettings: ({}) property var displayProfiles: ({}) property var activeDisplayProfile: ({}) + property var activeDisplayProfileModes: ({}) property bool displayProfileAutoSelect: false property bool displayShowDisconnected: false property bool displaySnapToEdge: true diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index d919e6a77..f755361b4 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -272,6 +272,7 @@ var SPEC = { batteryProfileName: { def: "" }, batteryPostLockMonitorTimeout: { def: 0 }, batteryChargeLimit: { def: 100 }, + lowerDisplayRefreshRateOnBattery: { def: false }, lockBeforeSuspend: { def: false }, loginctlLockIntegration: { def: true }, fadeToLockEnabled: { def: true }, diff --git a/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml b/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml index 03defefd2..2a14f044b 100644 --- a/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml +++ b/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml @@ -258,6 +258,26 @@ Singleton { callback(true); } + function publishActiveProfileModes() { + const compositor = CompositorService.compositor; + const profileId = SettingsData.getActiveDisplayProfile(compositor); + const profile = profileId ? validatedProfiles[profileId] : null; + const outputs = profile?.outputs || {}; + const modes = {}; + + for (const outputId in outputs) { + const mode = outputs[outputId]?.mode; + if (mode) + modes[outputId] = { + "mode": mode + }; + } + + const updated = JSON.parse(JSON.stringify(SettingsData.activeDisplayProfileModes || {})); + updated[compositor] = modes; + SettingsData.activeDisplayProfileModes = updated; + } + function generateProfileId() { return "profile_" + Date.now() + "_" + Math.random().toString(36).slice(2, 9); } @@ -356,6 +376,7 @@ Singleton { }; validatedProfiles = updated; matchedProfile = findMatchingProfile(); + publishActiveProfileModes(); profileSaved(profileId, profileName); }); }); @@ -495,6 +516,7 @@ Singleton { }; const onWriteSuccess = () => { SettingsData.setActiveDisplayProfile(CompositorService.compositor, configId); + publishActiveProfileModes(); if (isManual) { profilesLoading = false; profileActivated(configId, profileName); @@ -569,6 +591,7 @@ Singleton { writeMonitorsJson(data, null); validatedProfiles = validated; matchedProfile = findMatchingProfile(); + publishActiveProfileModes(); if (!profilesReady) { profilesReady = true; applyAutoConfig(); @@ -616,6 +639,7 @@ Singleton { currentOutputSet = buildCurrentOutputSet(); matchedProfile = findMatchingProfile(); SettingsData.setActiveDisplayProfile(CompositorService.compositor, id); + publishActiveProfileModes(); profileSaved(id, profileName); }); }); @@ -661,6 +685,7 @@ Singleton { delete updated[profileId]; validatedProfiles = updated; matchedProfile = findMatchingProfile(); + publishActiveProfileModes(); profileDeleted(profileId); }); }); @@ -838,6 +863,14 @@ Singleton { target: CompositorService function onCompositorChanged() { root.checkIncludeStatus(); + root.publishActiveProfileModes(); + } + } + + Connections { + target: SettingsData + function onActiveDisplayProfileChanged() { + root.publishActiveProfileModes(); } } @@ -2031,6 +2064,13 @@ Singleton { }; if (profileId) { + const updated = JSON.parse(JSON.stringify(validatedProfiles)); + if (updated[profileId]) { + updated[profileId].outputs = outputConfigs; + validatedProfiles = updated; + publishActiveProfileModes(); + } + readMonitorsJson(data => { const match = findConfigEntryById(data, profileId); if (match) { diff --git a/quickshell/Modules/Settings/PowerSleepTab.qml b/quickshell/Modules/Settings/PowerSleepTab.qml index b56569f14..f9ce2c2e8 100644 --- a/quickshell/Modules/Settings/PowerSleepTab.qml +++ b/quickshell/Modules/Settings/PowerSleepTab.qml @@ -187,6 +187,16 @@ Item { } } + SettingsToggleRow { + settingKey: "lowerDisplayRefreshRateOnBattery" + tags: ["power", "battery", "display", "refresh", "rate", "60hz", "hz"] + text: I18n.tr("Lower display refresh rate on battery") + description: I18n.tr("Switch displays with an available 60 Hz mode to 60 Hz on battery and restore the previous mode on AC. Skips displays with VRR enabled.") + checked: SettingsData.lowerDisplayRefreshRateOnBattery + visible: BatteryService.batteryAvailable + onToggled: checked => SettingsData.set("lowerDisplayRefreshRateOnBattery", checked) + } + Rectangle { width: parent.width height: 1 diff --git a/quickshell/Services/BatteryService.qml b/quickshell/Services/BatteryService.qml index e7cea566d..4a4d2d00e 100644 --- a/quickshell/Services/BatteryService.qml +++ b/quickshell/Services/BatteryService.qml @@ -19,7 +19,10 @@ Singleton { interval: 500 repeat: false running: true - onTriggered: root.suppressSound = false + onTriggered: { + root.suppressSound = false; + DisplayService.syncRefreshRates(root.isPluggedIn, "startup"); + } } readonly property string preferredBatteryOverride: Quickshell.env("DMS_PREFERRED_BATTERY") @@ -83,9 +86,40 @@ Singleton { } } + DisplayService.syncRefreshRates(root.isPluggedIn, "power-change"); + previousPluggedState = isPluggedIn; } + Connections { + target: SettingsData + function onLowerDisplayRefreshRateOnBatteryChanged() { + DisplayService.syncRefreshRates(root.isPluggedIn, "setting-change"); + } + + function onActiveDisplayProfileChanged() { + DisplayService.syncRefreshRates(root.isPluggedIn, "profile-change"); + } + + function onActiveDisplayProfileModesChanged() { + DisplayService.syncRefreshRates(root.isPluggedIn, "profile-change"); + } + } + + Connections { + target: NiriService + function onOutputsChanged() { + DisplayService.syncRefreshRates(root.isPluggedIn, "output-change"); + } + } + + Connections { + target: WlrOutputService + function onStateChanged() { + DisplayService.syncRefreshRates(root.isPluggedIn, "output-change"); + } + } + // Aggregated charge/discharge rate readonly property real changeRate: { if (!batteryAvailable) diff --git a/quickshell/Services/DisplayService.qml b/quickshell/Services/DisplayService.qml index 7f9794c83..ecc7b00fa 100644 --- a/quickshell/Services/DisplayService.qml +++ b/quickshell/Services/DisplayService.qml @@ -34,6 +34,18 @@ Singleton { property bool brightnessInitialized: false property bool suppressOsd: true + readonly property int batteryRefreshRateTarget: 60000 + readonly property int batteryRefreshRateTolerance: 1000 + + property var _lastAppliedTargets: ({}) + + Timer { + id: cascadeGuard + interval: 3000 + repeat: false + onTriggered: root._lastAppliedTargets = ({}) + } + signal brightnessChanged(bool showOsd) signal deviceSwitched @@ -56,6 +68,363 @@ Singleton { property int gammaLowTemp: gammaState?.config?.LowTemp ?? 0 property int gammaHighTemp: gammaState?.config?.HighTemp ?? 0 + function syncRefreshRates(isPluggedIn, reason) { + if (isPluggedIn) { + applyConfiguredTargets("AC", reason); + } else if (SettingsData.lowerDisplayRefreshRateOnBattery) { + applyBatteryTargets(reason); + } + } + + function applyConfiguredTargets(context, reason) { + if (CompositorService.isNiri) { + const outputs = NiriService.outputs || {}; + const applied = []; + for (const name in outputs) { + const currentMode = getNiriCurrentMode(outputs[name]); + const target = computeTargetMode(name, outputs[name], currentMode, "niri"); + if (!target || !target.value || isModeAlreadyCurrent(currentMode, target.mode)) + continue; + if (root._lastAppliedTargets[name] === target.value) + continue; + root._lastAppliedTargets[name] = target.value; + cascadeGuard.restart(); + NiriService.applyOutputConfig(name, { + "mode": target.value + }); + applied.push(name + " " + (getModeRefresh(target.mode) / 1000).toFixed(0) + "Hz (" + target.source + ")"); + } + if (applied.length > 0) + log.info("Updated display refresh rate: ", applied.join(", "), " (", context, ")"); + return; + } + + if (!WlrOutputService.wlrOutputAvailable) + return; + + const outputs = WlrOutputService.outputs || []; + const modeOverrides = ({}); + const applied = []; + + for (const output of outputs) { + const target = computeTargetMode(output.name, output, output.currentMode, "wlr"); + if (!target || !target.value || isModeAlreadyCurrent(output.currentMode, target.mode)) + continue; + if (root._lastAppliedTargets[output.name] === target.value) + continue; + root._lastAppliedTargets[output.name] = target.value; + cascadeGuard.restart(); + modeOverrides[output.name] = target.value; + applied.push(output.name + " " + (getModeRefresh(target.mode) / 1000).toFixed(0) + "Hz (" + target.source + ")"); + } + + if (applied.length > 0) { + log.info("Updated display refresh rate: ", applied.join(", "), " (", context, ")"); + WlrOutputService.applyConfiguration(buildWlrHeads(modeOverrides)); + } + } + + function applyBatteryTargets(reason) { + if (CompositorService.isNiri) { + const outputs = NiriService.outputs || {}; + const applied = []; + for (const name in outputs) { + const output = outputs[name]; + const currentMode = getNiriCurrentMode(output); + if (!currentMode) + continue; + const currentRefresh = getModeRefresh(currentMode); + if (currentRefresh <= batteryRefreshRateTarget + batteryRefreshRateTolerance) + continue; + const target = findBatteryRefreshMode(output, currentMode, "niri"); + if (!target) + continue; + const targetValue = formatNiriMode(target); + if (root._lastAppliedTargets[name] === targetValue) + continue; + root._lastAppliedTargets[name] = targetValue; + cascadeGuard.restart(); + NiriService.applyOutputConfig(name, { + "mode": targetValue + }); + applied.push(name + " " + (currentRefresh / 1000).toFixed(0) + "\u2192" + (getModeRefresh(target) / 1000).toFixed(0) + "Hz"); + } + if (applied.length > 0) + log.info("Updated display refresh rate: ", applied.join(", "), " (battery)"); + return; + } + + if (!WlrOutputService.wlrOutputAvailable) + return; + + const outputs = WlrOutputService.outputs || []; + const modeOverrides = ({}); + const applied = []; + + for (const output of outputs) { + const currentMode = output.currentMode; + if (!currentMode) + continue; + const currentRefresh = getModeRefresh(currentMode); + if (currentRefresh <= batteryRefreshRateTarget + batteryRefreshRateTolerance) + continue; + const target = findBatteryRefreshMode(output, currentMode, "wlr"); + if (!target) + continue; + if (root._lastAppliedTargets[output.name] === target.id) + continue; + root._lastAppliedTargets[output.name] = target.id; + cascadeGuard.restart(); + modeOverrides[output.name] = target.id; + applied.push(output.name + " " + (currentRefresh / 1000).toFixed(0) + "\u2192" + (getModeRefresh(target) / 1000).toFixed(0) + "Hz"); + } + + if (applied.length > 0) { + log.info("Updated display refresh rate: ", applied.join(", "), " (battery)"); + WlrOutputService.applyConfiguration(buildWlrHeads(modeOverrides)); + } + } + + function buildWlrHeads(modeOverrides) { + const outputs = WlrOutputService.outputs || []; + const heads = []; + + for (const output of outputs) { + const enabled = output.enabled !== false; + const head = { + "name": output.name, + "enabled": enabled + }; + + if (enabled) { + const modeId = modeOverrides[output.name] !== undefined ? modeOverrides[output.name] : output.currentMode?.id; + if (modeId !== undefined) + head.modeId = modeId; + + head.position = { + "x": output.x ?? 0, + "y": output.y ?? 0 + }; + head.scale = output.scale ?? 1.0; + head.transform = output.transform ?? 0; + + if (output.adaptiveSyncSupported) + head.adaptiveSync = output.adaptiveSync ?? 0; + } + + heads.push(head); + } + + return heads; + } + + function findBatteryRefreshMode(output, currentMode, backend, allowCurrentAtTarget) { + if (!output || !currentMode || (backend === "wlr" && !output.enabled)) + return null; + + if (isOutputVrrEnabled(output)) + return null; + + const modes = output.modes || []; + const sameResolutionModes = modes.filter(m => getModeWidth(m) === getModeWidth(currentMode) && getModeHeight(m) === getModeHeight(currentMode)); + const uniqueRefreshRates = []; + for (const mode of sameResolutionModes) { + const refresh = getModeRefresh(mode); + if (!uniqueRefreshRates.some(r => Math.abs(r - refresh) <= batteryRefreshRateTolerance)) + uniqueRefreshRates.push(refresh); + } + + if (uniqueRefreshRates.length <= 1) + return null; + + const currentRefresh = getModeRefresh(currentMode); + if (!allowCurrentAtTarget && currentRefresh <= batteryRefreshRateTarget + batteryRefreshRateTolerance) + return null; + + let bestMode = null; + let bestDiff = Infinity; + for (const mode of sameResolutionModes) { + const diff = Math.abs(getModeRefresh(mode) - batteryRefreshRateTarget); + if (diff < bestDiff) { + bestMode = mode; + bestDiff = diff; + } + } + + if (!bestMode || bestDiff > batteryRefreshRateTolerance) + return null; + + return bestMode; + } + + function computeTargetMode(outputName, output, currentMode, backend) { + const profileMode = findActiveProfileMode(outputName, output); + if (profileMode) { + const mode = findModeByString(output?.modes || [], profileMode); + if (mode) { + return { + "value": formatRestoreModeValue(mode, backend), + "mode": mode, + "source": "profile" + }; + } + } + + const best = findBestSameResolutionMode(output, currentMode); + if (best.preferred) { + return { + "value": formatRestoreModeValue(best.preferred, backend), + "mode": best.preferred, + "source": "preferred" + }; + } + + if (best.highest && !isModeAlreadyCurrent(currentMode, best.highest)) { + return { + "value": formatRestoreModeValue(best.highest, backend), + "mode": best.highest, + "source": "highestRefresh" + }; + } + + return { + "value": null, + "mode": null, + "source": "none" + }; + } + + function findActiveProfileMode(outputName, output) { + const compositor = CompositorService.compositor; + const profileModes = SettingsData.activeDisplayProfileModes?.[compositor] || {}; + if (Object.keys(profileModes).length === 0) + return ""; + + const identifiers = [outputName]; + if (output?.make && output?.model) { + const modelId = output.make + " " + output.model; + const serial = output.serial || output.serialNumber || "Unknown"; + const serialId = modelId + " " + serial; + if (!identifiers.includes(serialId)) + identifiers.push(serialId); + if (!identifiers.includes(modelId)) + identifiers.push(modelId); + } + + for (const identifier of identifiers) { + const mode = profileModes[identifier]?.mode; + if (mode) + return mode; + } + + return ""; + } + + function findModeByString(modes, modeString) { + for (const mode of modes) { + if (formatModeString(mode) === modeString) + return mode; + } + + const parsed = parseModeString(modeString); + if (!parsed) + return null; + + for (const mode of modes) { + if (getModeWidth(mode) === parsed.width && getModeHeight(mode) === parsed.height && Math.abs(getModeRefresh(mode) - parsed.refresh) <= batteryRefreshRateTolerance) + return mode; + } + + return null; + } + + function parseModeString(modeString) { + const match = (modeString || "").match(/^(\d+)x(\d+)@([\d.]+)$/); + if (!match) + return null; + return { + "width": parseInt(match[1]), + "height": parseInt(match[2]), + "refresh": Math.round(parseFloat(match[3]) * 1000) + }; + } + + function isModeAlreadyCurrent(currentMode, targetMode) { + if (!currentMode || !targetMode) + return true; + return getModeWidth(currentMode) === getModeWidth(targetMode) && getModeHeight(currentMode) === getModeHeight(targetMode) && Math.abs(getModeRefresh(currentMode) - getModeRefresh(targetMode)) <= batteryRefreshRateTolerance; + } + + function findBestSameResolutionMode(output, currentMode) { + if (!output || !currentMode) + return { + "preferred": null, + "highest": null + }; + + const modes = output.modes || []; + const sameResolutionModes = modes.filter(m => getModeWidth(m) === getModeWidth(currentMode) && getModeHeight(m) === getModeHeight(currentMode)); + + let preferred = null; + let highest = null; + let highestRefresh = 0; + + for (const mode of sameResolutionModes) { + const refresh = getModeRefresh(mode); + if (mode.preferred === true) + preferred = mode; + if (refresh > highestRefresh) { + highestRefresh = refresh; + highest = mode; + } + } + + return { + "preferred": preferred, + "highest": highest + }; + } + + function formatRestoreModeValue(mode, backend) { + if (!mode) + return null; + if (backend === "wlr") + return mode.id ?? null; + return formatNiriMode(mode); + } + + function isOutputVrrEnabled(output) { + return output.vrr_enabled === true || output.adaptiveSync === 1; + } + + function getNiriCurrentMode(output) { + if (!output || !output.modes || output.current_mode === undefined) + return null; + return output.modes[output.current_mode] || null; + } + + function getModeWidth(mode) { + return mode?.width ?? 0; + } + + function getModeHeight(mode) { + return mode?.height ?? 0; + } + + function getModeRefresh(mode) { + return mode?.refresh_rate ?? mode?.refresh ?? 0; + } + + function formatModeString(mode) { + if (!mode) + return ""; + return getModeWidth(mode) + "x" + getModeHeight(mode) + "@" + (getModeRefresh(mode) / 1000).toFixed(3); + } + + function formatNiriMode(mode) { + return mode.width + "x" + mode.height + "@" + (getModeRefresh(mode) / 1000).toFixed(3); + } + function markDeviceUserControlled(deviceId) { const newControlled = Object.assign({}, userControlledDevices); newControlled[deviceId] = Date.now();