Skip to content

Commit a62625a

Browse files
committed
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.
1 parent fbcc287 commit a62625a

6 files changed

Lines changed: 582 additions & 1 deletion

File tree

quickshell/Common/SettingsData.qml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,7 @@ Singleton {
540540
property string batteryProfileName: ""
541541
property int batteryPostLockMonitorTimeout: 0
542542
property int batteryChargeLimit: 100
543+
property bool lowerDisplayRefreshRateOnBattery: false
543544
property bool lockBeforeSuspend: false
544545
property bool loginctlLockIntegration: true
545546
property bool fadeToLockEnabled: true
@@ -735,6 +736,7 @@ Singleton {
735736
property var hyprlandOutputSettings: ({})
736737
property var displayProfiles: ({})
737738
property var activeDisplayProfile: ({})
739+
property var activeDisplayProfileModes: ({})
738740
property bool displayProfileAutoSelect: false
739741
property bool displayShowDisconnected: false
740742
property bool displaySnapToEdge: true

quickshell/Common/settings/SettingsSpec.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ var SPEC = {
272272
batteryProfileName: { def: "" },
273273
batteryPostLockMonitorTimeout: { def: 0 },
274274
batteryChargeLimit: { def: 100 },
275+
lowerDisplayRefreshRateOnBattery: { def: false },
275276
lockBeforeSuspend: { def: false },
276277
loginctlLockIntegration: { def: true },
277278
fadeToLockEnabled: { def: true },
@@ -446,6 +447,7 @@ var SPEC = {
446447
hyprlandOutputSettings: { def: {} },
447448
displayProfiles: { def: {} },
448449
activeDisplayProfile: { def: {} },
450+
activeDisplayProfileModes: { def: {} },
449451
displayProfileAutoSelect: { def: false },
450452
displayShowDisconnected: { def: false },
451453
displaySnapToEdge: { def: true },

quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,27 @@ Singleton {
258258
callback(true);
259259
}
260260

261+
function publishActiveProfileModes() {
262+
const compositor = CompositorService.compositor;
263+
const profileId = SettingsData.getActiveDisplayProfile(compositor);
264+
const profile = profileId ? validatedProfiles[profileId] : null;
265+
const outputs = profile?.outputs || {};
266+
const modes = {};
267+
268+
for (const outputId in outputs) {
269+
const mode = outputs[outputId]?.mode;
270+
if (mode)
271+
modes[outputId] = {
272+
"mode": mode
273+
};
274+
}
275+
276+
const updated = JSON.parse(JSON.stringify(SettingsData.activeDisplayProfileModes || {}));
277+
updated[compositor] = modes;
278+
SettingsData.activeDisplayProfileModes = updated;
279+
SettingsData.saveSettings();
280+
}
281+
261282
function generateProfileId() {
262283
return "profile_" + Date.now() + "_" + Math.random().toString(36).slice(2, 9);
263284
}
@@ -356,6 +377,7 @@ Singleton {
356377
};
357378
validatedProfiles = updated;
358379
matchedProfile = findMatchingProfile();
380+
publishActiveProfileModes();
359381
profileSaved(profileId, profileName);
360382
});
361383
});
@@ -495,6 +517,7 @@ Singleton {
495517
};
496518
const onWriteSuccess = () => {
497519
SettingsData.setActiveDisplayProfile(CompositorService.compositor, configId);
520+
publishActiveProfileModes();
498521
if (isManual) {
499522
profilesLoading = false;
500523
profileActivated(configId, profileName);
@@ -569,6 +592,7 @@ Singleton {
569592
writeMonitorsJson(data, null);
570593
validatedProfiles = validated;
571594
matchedProfile = findMatchingProfile();
595+
publishActiveProfileModes();
572596
if (!profilesReady) {
573597
profilesReady = true;
574598
applyAutoConfig();
@@ -616,6 +640,7 @@ Singleton {
616640
currentOutputSet = buildCurrentOutputSet();
617641
matchedProfile = findMatchingProfile();
618642
SettingsData.setActiveDisplayProfile(CompositorService.compositor, id);
643+
publishActiveProfileModes();
619644
profileSaved(id, profileName);
620645
});
621646
});
@@ -661,6 +686,7 @@ Singleton {
661686
delete updated[profileId];
662687
validatedProfiles = updated;
663688
matchedProfile = findMatchingProfile();
689+
publishActiveProfileModes();
664690
profileDeleted(profileId);
665691
});
666692
});
@@ -838,6 +864,14 @@ Singleton {
838864
target: CompositorService
839865
function onCompositorChanged() {
840866
root.checkIncludeStatus();
867+
root.publishActiveProfileModes();
868+
}
869+
}
870+
871+
Connections {
872+
target: SettingsData
873+
function onActiveDisplayProfileChanged() {
874+
root.publishActiveProfileModes();
841875
}
842876
}
843877

@@ -2031,6 +2065,13 @@ Singleton {
20312065
};
20322066
20332067
if (profileId) {
2068+
const updated = JSON.parse(JSON.stringify(validatedProfiles));
2069+
if (updated[profileId]) {
2070+
updated[profileId].outputs = outputConfigs;
2071+
validatedProfiles = updated;
2072+
publishActiveProfileModes();
2073+
}
2074+
20342075
readMonitorsJson(data => {
20352076
const match = findConfigEntryById(data, profileId);
20362077
if (match) {

quickshell/Modules/Settings/PowerSleepTab.qml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,16 @@ Item {
187187
}
188188
}
189189

190+
SettingsToggleRow {
191+
settingKey: "lowerDisplayRefreshRateOnBattery"
192+
tags: ["power", "battery", "display", "refresh", "rate", "60hz", "hz"]
193+
text: I18n.tr("Lower display refresh rate on battery")
194+
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.")
195+
checked: SettingsData.lowerDisplayRefreshRateOnBattery
196+
visible: BatteryService.batteryAvailable
197+
onToggled: checked => SettingsData.set("lowerDisplayRefreshRateOnBattery", checked)
198+
}
199+
190200
Rectangle {
191201
width: parent.width
192202
height: 1

quickshell/Services/BatteryService.qml

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ Singleton {
1919
interval: 500
2020
repeat: false
2121
running: true
22-
onTriggered: root.suppressSound = false
22+
onTriggered: {
23+
root.suppressSound = false;
24+
DisplayService.applyPowerSourceRefreshRate(root.isPluggedIn, true);
25+
}
2326
}
2427

2528
readonly property string preferredBatteryOverride: Quickshell.env("DMS_PREFERRED_BATTERY")
@@ -83,9 +86,40 @@ Singleton {
8386
}
8487
}
8588

89+
DisplayService.applyPowerSourceRefreshRate(isPluggedIn, isPluggedIn);
90+
8691
previousPluggedState = isPluggedIn;
8792
}
8893

94+
Connections {
95+
target: SettingsData
96+
function onLowerDisplayRefreshRateOnBatteryChanged() {
97+
DisplayService.applyPowerSourceRefreshRate(root.isPluggedIn, root.isPluggedIn);
98+
}
99+
100+
function onActiveDisplayProfileChanged() {
101+
DisplayService.applyPowerSourceRefreshRate(root.isPluggedIn, root.isPluggedIn);
102+
}
103+
104+
function onActiveDisplayProfileModesChanged() {
105+
DisplayService.applyPowerSourceRefreshRate(root.isPluggedIn, root.isPluggedIn);
106+
}
107+
}
108+
109+
Connections {
110+
target: NiriService
111+
function onOutputsChanged() {
112+
DisplayService.applyPowerSourceRefreshRate(root.isPluggedIn, root.isPluggedIn && DisplayService.configuredRestorePending);
113+
}
114+
}
115+
116+
Connections {
117+
target: WlrOutputService
118+
function onStateChanged() {
119+
DisplayService.applyPowerSourceRefreshRate(root.isPluggedIn, root.isPluggedIn && DisplayService.configuredRestorePending);
120+
}
121+
}
122+
89123
// Aggregated charge/discharge rate
90124
readonly property real changeRate: {
91125
if (!batteryAvailable)

0 commit comments

Comments
 (0)