Skip to content

Commit 7983d87

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 7983d87

6 files changed

Lines changed: 457 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: 1 addition & 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 },

quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,26 @@ 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+
}
280+
261281
function generateProfileId() {
262282
return "profile_" + Date.now() + "_" + Math.random().toString(36).slice(2, 9);
263283
}
@@ -356,6 +376,7 @@ Singleton {
356376
};
357377
validatedProfiles = updated;
358378
matchedProfile = findMatchingProfile();
379+
publishActiveProfileModes();
359380
profileSaved(profileId, profileName);
360381
});
361382
});
@@ -495,6 +516,7 @@ Singleton {
495516
};
496517
const onWriteSuccess = () => {
497518
SettingsData.setActiveDisplayProfile(CompositorService.compositor, configId);
519+
publishActiveProfileModes();
498520
if (isManual) {
499521
profilesLoading = false;
500522
profileActivated(configId, profileName);
@@ -569,6 +591,7 @@ Singleton {
569591
writeMonitorsJson(data, null);
570592
validatedProfiles = validated;
571593
matchedProfile = findMatchingProfile();
594+
publishActiveProfileModes();
572595
if (!profilesReady) {
573596
profilesReady = true;
574597
applyAutoConfig();
@@ -616,6 +639,7 @@ Singleton {
616639
currentOutputSet = buildCurrentOutputSet();
617640
matchedProfile = findMatchingProfile();
618641
SettingsData.setActiveDisplayProfile(CompositorService.compositor, id);
642+
publishActiveProfileModes();
619643
profileSaved(id, profileName);
620644
});
621645
});
@@ -661,6 +685,7 @@ Singleton {
661685
delete updated[profileId];
662686
validatedProfiles = updated;
663687
matchedProfile = findMatchingProfile();
688+
publishActiveProfileModes();
664689
profileDeleted(profileId);
665690
});
666691
});
@@ -838,6 +863,14 @@ Singleton {
838863
target: CompositorService
839864
function onCompositorChanged() {
840865
root.checkIncludeStatus();
866+
root.publishActiveProfileModes();
867+
}
868+
}
869+
870+
Connections {
871+
target: SettingsData
872+
function onActiveDisplayProfileChanged() {
873+
root.publishActiveProfileModes();
841874
}
842875
}
843876

@@ -2031,6 +2064,13 @@ Singleton {
20312064
};
20322065
20332066
if (profileId) {
2067+
const updated = JSON.parse(JSON.stringify(validatedProfiles));
2068+
if (updated[profileId]) {
2069+
updated[profileId].outputs = outputConfigs;
2070+
validatedProfiles = updated;
2071+
publishActiveProfileModes();
2072+
}
2073+
20342074
readMonitorsJson(data => {
20352075
const match = findConfigEntryById(data, profileId);
20362076
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.syncRefreshRates(root.isPluggedIn, "startup");
25+
}
2326
}
2427

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

89+
DisplayService.syncRefreshRates(root.isPluggedIn, "power-change");
90+
8691
previousPluggedState = isPluggedIn;
8792
}
8893

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

0 commit comments

Comments
 (0)