mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-04 12:38:31 -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:
@@ -758,6 +758,7 @@ Singleton {
|
|||||||
property int batteryLowNotificationType: 0
|
property int batteryLowNotificationType: 0
|
||||||
property int batteryCriticalNotificationType: 1
|
property int batteryCriticalNotificationType: 1
|
||||||
property bool batteryAutoPowerSaver: false
|
property bool batteryAutoPowerSaver: false
|
||||||
|
property bool lowerDisplayRefreshRateOnBattery: false
|
||||||
property bool showBatteryPercent: true
|
property bool showBatteryPercent: true
|
||||||
property bool showBatteryPercentOnlyOnBattery: false
|
property bool showBatteryPercentOnlyOnBattery: false
|
||||||
property bool showBatteryTime: false
|
property bool showBatteryTime: false
|
||||||
@@ -971,6 +972,8 @@ Singleton {
|
|||||||
property var hyprlandOutputSettings: ({})
|
property var hyprlandOutputSettings: ({})
|
||||||
property var displayProfiles: ({})
|
property var displayProfiles: ({})
|
||||||
property var activeDisplayProfile: ({})
|
property var activeDisplayProfile: ({})
|
||||||
|
property var activeDisplayProfileModes: ({})
|
||||||
|
property var displayPreviousRefreshModes: ({})
|
||||||
property bool displayProfileAutoSelect: false
|
property bool displayProfileAutoSelect: false
|
||||||
property bool displayShowDisconnected: false
|
property bool displayShowDisconnected: false
|
||||||
property bool displaySnapToEdge: true
|
property bool displaySnapToEdge: true
|
||||||
@@ -3558,6 +3561,27 @@ Singleton {
|
|||||||
saveSettings();
|
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 {
|
ListModel {
|
||||||
id: leftWidgetsModel
|
id: leftWidgetsModel
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -346,6 +346,7 @@ var SPEC = {
|
|||||||
batteryLowNotificationType: { def: 0 },
|
batteryLowNotificationType: { def: 0 },
|
||||||
batteryCriticalNotificationType: { def: 1 },
|
batteryCriticalNotificationType: { def: 1 },
|
||||||
batteryAutoPowerSaver: { def: false },
|
batteryAutoPowerSaver: { def: false },
|
||||||
|
lowerDisplayRefreshRateOnBattery: { def: false },
|
||||||
lockBeforeSuspend: { def: false },
|
lockBeforeSuspend: { def: false },
|
||||||
loginctlLockIntegration: { def: true },
|
loginctlLockIntegration: { def: true },
|
||||||
fadeToLockEnabled: { def: true },
|
fadeToLockEnabled: { def: true },
|
||||||
@@ -530,6 +531,8 @@ var SPEC = {
|
|||||||
hyprlandOutputSettings: { def: {} },
|
hyprlandOutputSettings: { def: {} },
|
||||||
displayProfiles: { def: {} },
|
displayProfiles: { def: {} },
|
||||||
activeDisplayProfile: { def: {} },
|
activeDisplayProfile: { def: {} },
|
||||||
|
activeDisplayProfileModes: { def: {} },
|
||||||
|
displayPreviousRefreshModes: { def: {} },
|
||||||
displayProfileAutoSelect: { def: false },
|
displayProfileAutoSelect: { def: false },
|
||||||
displayShowDisconnected: { def: false },
|
displayShowDisconnected: { def: false },
|
||||||
displaySnapToEdge: { def: true },
|
displaySnapToEdge: { def: true },
|
||||||
|
|||||||
@@ -264,6 +264,24 @@ Singleton {
|
|||||||
callback(true);
|
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() {
|
function generateProfileId() {
|
||||||
return "profile_" + Date.now() + "_" + Math.random().toString(36).slice(2, 9);
|
return "profile_" + Date.now() + "_" + Math.random().toString(36).slice(2, 9);
|
||||||
}
|
}
|
||||||
@@ -362,6 +380,7 @@ Singleton {
|
|||||||
};
|
};
|
||||||
validatedProfiles = updated;
|
validatedProfiles = updated;
|
||||||
matchedProfile = findMatchingProfile();
|
matchedProfile = findMatchingProfile();
|
||||||
|
publishActiveProfileModes();
|
||||||
profileSaved(profileId, profileName);
|
profileSaved(profileId, profileName);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -543,6 +562,7 @@ Singleton {
|
|||||||
};
|
};
|
||||||
const onWriteSuccess = () => {
|
const onWriteSuccess = () => {
|
||||||
SettingsData.setActiveDisplayProfile(CompositorService.compositor, configId);
|
SettingsData.setActiveDisplayProfile(CompositorService.compositor, configId);
|
||||||
|
publishActiveProfileModes();
|
||||||
if (isManual) {
|
if (isManual) {
|
||||||
profilesLoading = false;
|
profilesLoading = false;
|
||||||
profileActivated(configId, profileName);
|
profileActivated(configId, profileName);
|
||||||
@@ -586,6 +606,7 @@ Singleton {
|
|||||||
writeMonitorsJson(data, null);
|
writeMonitorsJson(data, null);
|
||||||
validatedProfiles = validated;
|
validatedProfiles = validated;
|
||||||
matchedProfile = findMatchingProfile();
|
matchedProfile = findMatchingProfile();
|
||||||
|
publishActiveProfileModes();
|
||||||
if (!profilesReady) {
|
if (!profilesReady) {
|
||||||
profilesReady = true;
|
profilesReady = true;
|
||||||
applyAutoConfig();
|
applyAutoConfig();
|
||||||
@@ -633,6 +654,7 @@ Singleton {
|
|||||||
currentOutputSet = buildCurrentOutputSet();
|
currentOutputSet = buildCurrentOutputSet();
|
||||||
matchedProfile = findMatchingProfile();
|
matchedProfile = findMatchingProfile();
|
||||||
SettingsData.setActiveDisplayProfile(CompositorService.compositor, id);
|
SettingsData.setActiveDisplayProfile(CompositorService.compositor, id);
|
||||||
|
publishActiveProfileModes();
|
||||||
profileSaved(id, profileName);
|
profileSaved(id, profileName);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -678,6 +700,7 @@ Singleton {
|
|||||||
delete updated[profileId];
|
delete updated[profileId];
|
||||||
validatedProfiles = updated;
|
validatedProfiles = updated;
|
||||||
matchedProfile = findMatchingProfile();
|
matchedProfile = findMatchingProfile();
|
||||||
|
publishActiveProfileModes();
|
||||||
profileDeleted(profileId);
|
profileDeleted(profileId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -890,6 +913,14 @@ Singleton {
|
|||||||
target: CompositorService
|
target: CompositorService
|
||||||
function onCompositorChanged() {
|
function onCompositorChanged() {
|
||||||
root.checkIncludeStatus();
|
root.checkIncludeStatus();
|
||||||
|
root.publishActiveProfileModes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: SettingsData
|
||||||
|
function onActiveDisplayProfileChanged() {
|
||||||
|
root.publishActiveProfileModes();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2251,7 +2282,16 @@ Singleton {
|
|||||||
"name": match.entry.name || "",
|
"name": match.entry.name || "",
|
||||||
"outputs": outputConfigs
|
"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();
|
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 {
|
Rectangle {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
height: 1
|
height: 1
|
||||||
|
|||||||
@@ -34,9 +34,74 @@ Singleton {
|
|||||||
property bool brightnessInitialized: false
|
property bool brightnessInitialized: false
|
||||||
property bool suppressOsd: true
|
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 brightnessChanged(bool showOsd)
|
||||||
signal deviceSwitched
|
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 nightModeActive: nightModeEnabled
|
||||||
|
|
||||||
property bool nightModeEnabled: false
|
property bool nightModeEnabled: false
|
||||||
@@ -56,6 +121,503 @@ Singleton {
|
|||||||
property int gammaLowTemp: gammaState?.config?.LowTemp ?? 0
|
property int gammaLowTemp: gammaState?.config?.LowTemp ?? 0
|
||||||
property int gammaHighTemp: gammaState?.config?.HighTemp ?? 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) {
|
function markDeviceUserControlled(deviceId) {
|
||||||
const newControlled = Object.assign({}, userControlledDevices);
|
const newControlled = Object.assign({}, userControlledDevices);
|
||||||
newControlled[deviceId] = Date.now();
|
newControlled[deviceId] = Date.now();
|
||||||
|
|||||||
@@ -7023,6 +7023,43 @@
|
|||||||
"timeout"
|
"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",
|
"section": "fadeToDpmsGracePeriod",
|
||||||
"label": "Monitor fade grace period",
|
"label": "Monitor fade grace period",
|
||||||
|
|||||||
Reference in New Issue
Block a user