Skip to content

Run the built-in panel slower on battery - #8304

Open
sterre-g wants to merge 2 commits into
omacom:quattrofrom
sterre-g:feat-refresh-rate-power
Open

sterre-g wants to merge 2 commits into
omacom:quattrofrom
sterre-g:feat-refresh-rate-power

Conversation

@sterre-g

@sterre-g sterre-g commented Aug 25, 2026

Copy link
Copy Markdown

A laptop panel that advertises 165 Hz runs at 165 Hz on battery too, and nothing in the shell offers to trade that back for runtime. Setting the rate by hand does not survive either: it lives in monitors.lua, so the next reload puts the configured one back.

This adds a service that picks the rate from the power state. On mains it uses the fastest rate the panel advertises for the resolution it is already on. Otherwise it uses the slowest.

Scope, and what it will not touch

Only rates at the current resolution are considered, so the mode cannot change underneath the user. Only internal outputs are driven, matched with the same eDP|LVDS|DSI test the monitor helpers in bin/ already use, so external displays are never touched. A panel advertising a single rate is left alone.

It is off unless asked for. Changing what someone's display does on battery is not a thing to start doing to them on upgrade.

"refreshRate": { "enabled": true, "threshold": 50 }

threshold is the charge below which the slow rate applies even on mains, defaulting to 50. A battery that cannot be read only relaxes that threshold rather than forcing the slow rate, so a laptop with a dead gauge still runs fast while plugged in instead of being pinned low forever.

The service follows UPower.onBattery from Quickshell.Services.UPower for the mains transition, rather than introducing a second source of power state, and re-checks on a minute timer, because charge crossing the threshold is not something UPower raises an event for.

Relationship to #6402

#6402 adds manual refresh-rate control to the Display panel. This is the automatic policy, and they are complementary rather than competing: that PR gives you the picker, this one decides when nobody has picked.

They do interact, and it is worth deciding deliberately rather than discovering it. If both land and this service is enabled, a rate chosen by hand from the panel is overwritten on this service's next tick. The clean resolution is for a manual pick to suppress the automatic policy until it is handed back, which is what I do locally, but that belongs in whichever of the two lands second rather than being guessed at here.

#8595 is the same shape applied to idle timeouts, using separate values on AC and battery. If a general "what changes on battery" grouping is wanted in shell.json rather than a key per feature, those two are the cases to design it against.

#8695 caps the screensaver's frame rate at the panel's refresh rate. It reads the rate this service changes, so with both in place the screensaver follows the panel down on battery, which is the intended direction and needs nothing from either side.

Tests

test/shell.d/refresh-rate-test.sh, 23 assertions against the pure decision in RefreshRateModel.js: the internal-output test, rate extraction at the current resolution only, the threshold being inclusive, the mains requirement holding at any charge, an unreadable battery relaxing rather than forcing, the rounding Hyprland's reported rate needs (165.02000 against the 165.02 that was asked for), refusing an output name that would have to be quoted into Lua, and the whole decision over a two-display laptop.

Checked that they are load-bearing rather than decorative: removing the internal-output filter fails the two-display assertion, and dropping the mains requirement fails the on-battery one.

Also exercised live rather than only in unit tests: loaded as a plugin against a running shell on a two-display laptop, with the threshold set above the current charge it dropped the panel from 165.02 to 60.01 and left the external on 143.91, and with the threshold below it the panel went back to 165.02.

2026-09-01

Rebased onto current quattro (b71dcad) with no conflicts.

@sterre-g
sterre-g marked this pull request as ready for review August 25, 2026 23:38
Copilot AI balanced review requested due to automatic review settings August 25, 2026 23:38

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

A laptop panel that advertises 165 Hz runs at 165 Hz on battery too, and
nothing in the shell offers to trade that back for runtime. Setting it by hand
does not survive, since the rate lives in monitors.lua and any reload puts the
configured one back.

This adds a service that picks the rate from the power state: the fastest rate
the panel advertises for the resolution it is already on while running on
mains, and the slowest one otherwise. Only rates at the current resolution are
considered, so the mode cannot change underneath the user, and only internal
outputs are touched, matched with the eDP/LVDS/DSI test the monitor helpers in
bin/ already use. A panel offering a single rate is left alone.

It is off unless shell.json turns it on, because changing what someone's
display does on battery is not a thing to start doing on upgrade.
`refreshRate.threshold` is the charge below which the slow rate applies even on
mains, defaulting to 50. A battery that cannot be read only relaxes that
threshold rather than forcing the slow rate, so a laptop with a dead gauge
still runs fast while plugged in.

The decision is a pure function in RefreshRateModel.js so it is covered by
test/shell.d/refresh-rate-test.sh: 23 assertions over the internal-output test,
rate extraction, the threshold, the mains requirement, the rounding Hyprland's
reported rate needs, and rejecting an output name that would have to be quoted
into Lua.
The service applied each spec and dropped the result. Nothing about a refused
eval reached the user or the log: the panel simply stayed on the rate it had,
which is indistinguishable from the policy deciding not to change it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants