mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-01 19:18: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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user