1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-08-02 03:28:28 -04:00

feat(power): lower refresh rate on battery (#2428)

* feat(power): lower refresh rate on battery (#1203)

Add a Power & Sleep setting that lowers eligible displays to 60 Hz on battery and restores their previous mode on AC power.

Skip displays without an available 60 Hz mode, displays with a single refresh-rate option, current modes at or below 60 Hz, and VRR-enabled outputs.
Apply niri changes at runtime through output commands and use wlr-output-management as fallback.

* fix(quickshell): debounce display refresh sync

* chore: regenerate settings_search_index.json for pre-commit CI fix

* fix: translator context is missing for lowerDisplayRefreshRateOnBattery

* fix: activeDisplayProfileModes is missing from SettingsSpec

* feat: added guard to syncRefreshRates function

* refactor(display): move refresh sync listeners to display service

* fix(display): publish active profile modes after config write

* feat(display): persist previous refresh modes

* fix(display): route refresh mode writes through compositor backends

* fix(display): detect preferred niri refresh modes

* some small cleanups

---------

Co-authored-by: bbedward <bbedward@gmail.com>
This commit is contained in:
Artem Timofeev
2026-07-17 15:41:08 +02:00
committed by GitHub
parent 95e1d6b72e
commit adbbd9b112
6 changed files with 677 additions and 1 deletions
+24
View File
@@ -758,6 +758,7 @@ Singleton {
property int batteryLowNotificationType: 0
property int batteryCriticalNotificationType: 1
property bool batteryAutoPowerSaver: false
property bool lowerDisplayRefreshRateOnBattery: false
property bool showBatteryPercent: true
property bool showBatteryPercentOnlyOnBattery: false
property bool showBatteryTime: false
@@ -971,6 +972,8 @@ Singleton {
property var hyprlandOutputSettings: ({})
property var displayProfiles: ({})
property var activeDisplayProfile: ({})
property var activeDisplayProfileModes: ({})
property var displayPreviousRefreshModes: ({})
property bool displayProfileAutoSelect: false
property bool displayShowDisconnected: false
property bool displaySnapToEdge: true
@@ -3558,6 +3561,27 @@ Singleton {
saveSettings();
}
function setActiveDisplayProfileModes(compositor, modes) {
if (JSON.stringify(activeDisplayProfileModes[compositor] || {}) === JSON.stringify(modes || {}))
return;
const updated = JSON.parse(JSON.stringify(activeDisplayProfileModes));
updated[compositor] = modes;
activeDisplayProfileModes = updated;
saveSettings();
}
function setDisplayPreviousRefreshModes(compositor, modes) {
if (JSON.stringify(displayPreviousRefreshModes[compositor] || {}) === JSON.stringify(modes || {}))
return;
const updated = JSON.parse(JSON.stringify(displayPreviousRefreshModes));
if (Object.keys(modes || {}).length > 0)
updated[compositor] = modes;
else
delete updated[compositor];
displayPreviousRefreshModes = updated;
saveSettings();
}
ListModel {
id: leftWidgetsModel
}
@@ -346,6 +346,7 @@ var SPEC = {
batteryLowNotificationType: { def: 0 },
batteryCriticalNotificationType: { def: 1 },
batteryAutoPowerSaver: { def: false },
lowerDisplayRefreshRateOnBattery: { def: false },
lockBeforeSuspend: { def: false },
loginctlLockIntegration: { def: true },
fadeToLockEnabled: { def: true },
@@ -530,6 +531,8 @@ var SPEC = {
hyprlandOutputSettings: { def: {} },
displayProfiles: { def: {} },
activeDisplayProfile: { def: {} },
activeDisplayProfileModes: { def: {} },
displayPreviousRefreshModes: { def: {} },
displayProfileAutoSelect: { def: false },
displayShowDisconnected: { def: false },
displaySnapToEdge: { def: true },
@@ -264,6 +264,24 @@ 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
};
}
SettingsData.setActiveDisplayProfileModes(compositor, modes);
}
function generateProfileId() {
return "profile_" + Date.now() + "_" + Math.random().toString(36).slice(2, 9);
}
@@ -362,6 +380,7 @@ Singleton {
};
validatedProfiles = updated;
matchedProfile = findMatchingProfile();
publishActiveProfileModes();
profileSaved(profileId, profileName);
});
});
@@ -543,6 +562,7 @@ Singleton {
};
const onWriteSuccess = () => {
SettingsData.setActiveDisplayProfile(CompositorService.compositor, configId);
publishActiveProfileModes();
if (isManual) {
profilesLoading = false;
profileActivated(configId, profileName);
@@ -586,6 +606,7 @@ Singleton {
writeMonitorsJson(data, null);
validatedProfiles = validated;
matchedProfile = findMatchingProfile();
publishActiveProfileModes();
if (!profilesReady) {
profilesReady = true;
applyAutoConfig();
@@ -633,6 +654,7 @@ Singleton {
currentOutputSet = buildCurrentOutputSet();
matchedProfile = findMatchingProfile();
SettingsData.setActiveDisplayProfile(CompositorService.compositor, id);
publishActiveProfileModes();
profileSaved(id, profileName);
});
});
@@ -678,6 +700,7 @@ Singleton {
delete updated[profileId];
validatedProfiles = updated;
matchedProfile = findMatchingProfile();
publishActiveProfileModes();
profileDeleted(profileId);
});
});
@@ -890,6 +913,14 @@ Singleton {
target: CompositorService
function onCompositorChanged() {
root.checkIncludeStatus();
root.publishActiveProfileModes();
}
}
Connections {
target: SettingsData
function onActiveDisplayProfileChanged() {
root.publishActiveProfileModes();
}
}
@@ -2251,7 +2282,16 @@ Singleton {
"name": match.entry.name || "",
"outputs": outputConfigs
};
writeMonitorsJson(data, null);
writeMonitorsJson(data, success => {
if (!success || !profileId)
return;
const updated = JSON.parse(JSON.stringify(validatedProfiles));
if (updated[profileId]) {
updated[profileId].outputs = outputConfigs;
validatedProfiles = updated;
publishActiveProfileModes();
}
});
});
clearPendingChanges();
@@ -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", "setting title under power & sleep tab")
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
+562
View File
@@ -34,9 +34,74 @@ 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 = ({})
}
property string _pendingReason: ""
Timer {
id: syncDebounce
interval: 300
repeat: false
onTriggered: root._runSync()
}
Timer {
id: startupRefreshRateSync
interval: 500
repeat: false
running: true
onTriggered: root.requestSync("startup")
}
signal brightnessChanged(bool showOsd)
signal deviceSwitched
Connections {
target: BatteryService
function onIsPluggedInChanged() {
root.requestSync("power-change");
}
}
Connections {
target: SettingsData
function onLowerDisplayRefreshRateOnBatteryChanged() {
root.requestSync("setting-change");
}
function onActiveDisplayProfileChanged() {
root.requestSync("profile-change");
}
function onActiveDisplayProfileModesChanged() {
root.requestSync("profile-change");
}
}
Connections {
target: NiriService
function onOutputsChanged() {
root.requestSync("output-change");
}
}
Connections {
target: WlrOutputService
function onStateChanged() {
root.requestSync("output-change");
}
}
property bool nightModeActive: nightModeEnabled
property bool nightModeEnabled: false
@@ -56,6 +121,503 @@ Singleton {
property int gammaLowTemp: gammaState?.config?.LowTemp ?? 0
property int gammaHighTemp: gammaState?.config?.HighTemp ?? 0
function syncRefreshRates(isPluggedIn, reason) {
if (!SettingsData.lowerDisplayRefreshRateOnBattery) {
if (reason === "setting-change")
applyConfiguredTargets("disabled", reason);
return;
}
if (!isPluggedIn) {
applyBatteryTargets(reason);
return;
}
applyConfiguredTargets("AC", reason);
}
function requestSync(reason) {
_pendingReason = reason || _pendingReason;
syncDebounce.restart();
}
function _runSync() {
syncRefreshRates(BatteryService.isPluggedIn, _pendingReason || "sync");
_pendingReason = "";
}
function outputIdentifiers(outputName, output) {
const identifiers = [outputName];
const make = output?.make || "";
const model = output?.model || "";
if (make && model) {
const serial = output.serial || output.serialNumber || "Unknown";
const modelId = make + " " + model;
const serialId = modelId + " " + serial;
const descId = ("desc:" + [make, model, output.serial || output.serialNumber].filter(p => p).join(" ")).replace(/,/g, "");
if (!identifiers.includes(serialId))
identifiers.push(serialId);
if (!identifiers.includes(modelId))
identifiers.push(modelId);
if (descId !== "desc:" && !identifiers.includes(descId))
identifiers.push(descId);
}
return identifiers;
}
function previousRefreshModes() {
return SettingsData.displayPreviousRefreshModes?.[CompositorService.compositor] || {};
}
function findPreviousRefreshMode(outputName, output) {
const modes = previousRefreshModes();
for (const identifier of outputIdentifiers(outputName, output)) {
const entry = modes[identifier];
const mode = typeof entry === "string" ? entry : entry?.mode;
if (mode)
return mode;
}
return "";
}
function storePreviousRefreshMode(outputName, output, mode) {
const modeString = formatModeString(mode);
if (!modeString)
return;
const modes = JSON.parse(JSON.stringify(previousRefreshModes()));
for (const identifier of outputIdentifiers(outputName, output)) {
modes[identifier] = {
"mode": modeString
};
}
SettingsData.setDisplayPreviousRefreshModes(CompositorService.compositor, modes);
}
function clearPreviousRefreshMode(outputName, output) {
const modes = JSON.parse(JSON.stringify(previousRefreshModes()));
let changed = false;
for (const identifier of outputIdentifiers(outputName, output)) {
if (modes[identifier] === undefined)
continue;
delete modes[identifier];
changed = true;
}
if (changed)
SettingsData.setDisplayPreviousRefreshModes(CompositorService.compositor, modes);
}
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], "niri");
if (!target || !target.value)
continue;
if (isModeAlreadyCurrent(currentMode, target.mode)) {
if (target.source === "previous")
clearPreviousRefreshMode(name, outputs[name]);
continue;
}
if (root._lastAppliedTargets[name] === target.value)
continue;
root._lastAppliedTargets[name] = target.value;
cascadeGuard.restart();
NiriService.applyOutputConfig(name, {
"mode": target.value
}, success => {
if (success && target.source === "previous")
clearPreviousRefreshMode(name, outputs[name]);
});
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 restoredOutputs = [];
const applied = [];
for (const output of outputs) {
const target = computeTargetMode(output.name, output, "wlr");
if (!target || !target.value)
continue;
if (isModeAlreadyCurrent(output.currentMode, target.mode)) {
if (target.source === "previous")
clearPreviousRefreshMode(output.name, output);
continue;
}
if (root._lastAppliedTargets[output.name] === target.value)
continue;
root._lastAppliedTargets[output.name] = target.value;
cascadeGuard.restart();
modeOverrides[output.name] = target;
if (target.source === "previous")
restoredOutputs.push(output);
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, ")");
applyWlrModeOverrides(modeOverrides, success => {
if (!success)
return;
for (const output of restoredOutputs)
clearPreviousRefreshMode(output.name, output);
});
}
}
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);
storePreviousRefreshMode(name, output, currentMode);
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;
storePreviousRefreshMode(output.name, output, currentMode);
if (root._lastAppliedTargets[output.name] === target.id)
continue;
root._lastAppliedTargets[output.name] = target.id;
cascadeGuard.restart();
modeOverrides[output.name] = {
"value": target.id,
"mode": target,
"source": "battery"
};
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)");
applyWlrModeOverrides(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].value : 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 applyWlrModeOverrides(modeOverrides, callback) {
if (CompositorService.isHyprland) {
HyprlandService.generateOutputsConfig(buildWlrOutputsData(modeOverrides), SettingsData.hyprlandOutputSettings, callback);
return;
}
if (CompositorService.isMango) {
MangoService.generateOutputsConfig(buildWlrOutputsData(modeOverrides), callback);
return;
}
WlrOutputService.applyConfiguration(buildWlrHeads(modeOverrides), callback);
}
function buildWlrOutputsData(modeOverrides) {
const outputs = WlrOutputService.outputs || [];
const data = ({});
for (const output of outputs) {
const target = modeOverrides[output.name]?.mode || output.currentMode;
const normalizedModes = (output.modes || []).map(mode => ({
"id": mode.id,
"width": getModeWidth(mode),
"height": getModeHeight(mode),
"refresh_rate": getModeRefresh(mode),
"preferred": mode.preferred === true || mode.is_preferred === true
}));
const currentMode = target ? normalizedModes.findIndex(mode => getModeWidth(mode) === getModeWidth(target) && getModeHeight(mode) === getModeHeight(target) && Math.abs(getModeRefresh(mode) - getModeRefresh(target)) <= batteryRefreshRateTolerance) : -1;
data[output.name] = {
"name": output.name,
"enabled": output.enabled !== false,
"make": output.make || "",
"model": output.model || "",
"serial": output.serial || output.serialNumber || "",
"modes": normalizedModes,
"current_mode": currentMode,
"configured_mode": target ? formatModeString(target) : "",
"vrr_supported": output.adaptiveSyncSupported ?? output.vrr_supported ?? false,
"vrr_enabled": isOutputVrrEnabled(output),
"logical": {
"x": output.x ?? 0,
"y": output.y ?? 0,
"width": getModeWidth(target || output.currentMode) || 1920,
"height": getModeHeight(target || output.currentMode) || 1080,
"scale": output.scale ?? 1.0,
"transform": mapWlrTransform(output.transform)
}
};
}
return data;
}
function mapWlrTransform(transform) {
switch (transform) {
case 1:
return "90";
case 2:
return "180";
case 3:
return "270";
case 4:
return "Flipped";
case 5:
return "Flipped90";
case 6:
return "Flipped180";
case 7:
return "Flipped270";
default:
return "Normal";
}
}
function findBatteryRefreshMode(output, currentMode, backend) {
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;
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, 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 previousMode = findPreviousRefreshMode(outputName, output);
if (previousMode) {
const mode = findModeByString(output?.modes || [], previousMode);
if (mode) {
return {
"value": formatRestoreModeValue(mode, backend),
"mode": mode,
"source": "previous"
};
}
}
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 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();
@@ -7023,6 +7023,43 @@
"timeout"
]
},
{
"section": "lowerDisplayRefreshRateOnBattery",
"label": "Lower display refresh rate on battery",
"tabIndex": 21,
"category": "Power & Sleep",
"keywords": [
"60hz",
"available",
"battery",
"charge",
"charging",
"display",
"displays",
"enabled",
"energy",
"hz",
"lower",
"mode",
"monitor",
"monitors",
"output",
"outputs",
"power",
"previous",
"rate",
"refresh",
"restore",
"screen",
"screens",
"shutdown",
"skips",
"sleep",
"suspend",
"switch"
],
"description": "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."
},
{
"section": "fadeToDpmsGracePeriod",
"label": "Monitor fade grace period",