482 lines
20 KiB
QML
482 lines
20 KiB
QML
// Power & Lock.
|
|
//
|
|
// The three idle timings used to be three sliders in a column, and the
|
|
// relationship between them -- blank, then lock, then sleep -- was left for you
|
|
// to assemble in your head. IdleTimeline draws that sequence; the sliders stay
|
|
// underneath it as the precise way to set a number. Same values, same store,
|
|
// two ways in.
|
|
//
|
|
// One idle card, not two. The wall-power and battery timings are the same three
|
|
// concepts, and hypridle holds one set at a time, so the card follows the power
|
|
// source and swaps its sliders rather than showing both sets at once and
|
|
// leaving you to work out which one is actually in effect.
|
|
//
|
|
// hypridle has no IPC for reconfiguration, so these values reach it by
|
|
// regenerating its config and restarting the daemon (services/IdleLock.qml).
|
|
// That only happens while Panama manages the daemon, and the Management card
|
|
// says plainly which state you are in rather than presenting controls that
|
|
// silently do nothing.
|
|
//
|
|
// What this page does NOT do is suspend, hibernate or power the machine off.
|
|
// Those live in the power menu, where destructive entries arm on the first
|
|
// press and fire on the second; a settings page that could do them on a single
|
|
// click would be a worse power menu with none of the safeguards.
|
|
|
|
import QtQuick
|
|
import Quickshell
|
|
import qs.config
|
|
import qs.services
|
|
|
|
SettingsPage {
|
|
id: root
|
|
|
|
title: "Power & Lock"
|
|
lede: "When this machine rests, locks, and how it spends its power."
|
|
|
|
// Reading the power daemon is a D-Bus round trip; the wake locks and the
|
|
// battery's wear figures each cost a subprocess. None of them belongs on a
|
|
// shell-startup path or on a polling timer, so they are asked for when the
|
|
// page opens. The page is built by a Loader and destroyed on navigation, so
|
|
// "when it opens" is exactly what Component.onCompleted means here.
|
|
Component.onCompleted: {
|
|
if (!PowerProfiles.scanned)
|
|
PowerProfiles.refresh();
|
|
IdleLock.refreshInhibitors();
|
|
Battery.refreshHealth();
|
|
}
|
|
|
|
// Which set of timings is in force is a live fact about the machine rather
|
|
// than a preference, so one card follows it instead of two cards each
|
|
// claiming half the truth. A desktop is never on battery -- see Battery.
|
|
readonly property bool onBattery: Battery.available && !Battery.acOnline
|
|
|
|
readonly property string powerState: {
|
|
if (Battery.charging)
|
|
return "charging";
|
|
if (Battery.status === "Full")
|
|
return "full";
|
|
if (Battery.acOnline)
|
|
return "plugged in, not charging";
|
|
return "discharging";
|
|
}
|
|
|
|
// ── Wake locks ───────────────────────────────────────────────────────────
|
|
//
|
|
// logind's inhibitor list, split by the distinction that decides what it
|
|
// means. A `block` is a thing actually keeping the machine awake. A `delay`
|
|
// holds sleep for a few seconds on the way down and nothing more --
|
|
// NetworkManager, UPower and hypridle itself hold delays permanently, so a
|
|
// row that counted them would report every machine as pinned awake forever
|
|
// and the row would be worth nothing.
|
|
readonly property var wakeLocks: {
|
|
const held = IdleLock.inhibitors;
|
|
return Array.isArray(held) ? held : [];
|
|
}
|
|
|
|
readonly property var wakeBlocks:
|
|
root.wakeLocks.filter(entry => entry.mode === "block")
|
|
|
|
readonly property int wakeDelays: root.wakeLocks.length - root.wakeBlocks.length
|
|
|
|
// A wake lock is taken and dropped by applications while you watch -- a
|
|
// video starts, an update finishes -- so a row claiming to say what is
|
|
// happening "right now" has to keep asking. Only while the page exists.
|
|
Timer {
|
|
interval: 8000
|
|
running: true
|
|
repeat: true
|
|
onTriggered: IdleLock.refreshInhibitors()
|
|
}
|
|
|
|
// Absolute paths are correct and unreadable. The home prefix is the part
|
|
// nobody needs to be told.
|
|
function shorten(path: string): string {
|
|
const home = Quickshell.env("HOME") ?? "";
|
|
return home !== "" && path.startsWith(home) ? "~" + path.slice(home.length) : path;
|
|
}
|
|
|
|
// A number and what it means, for the facts a battery reports about itself.
|
|
// Deliberately not rows: three short numbers side by side is a glance, and
|
|
// three rows of "Health ......... 89%" is a table to read.
|
|
component StatTile: Rectangle {
|
|
id: tile
|
|
|
|
property string value: ""
|
|
property string caption: ""
|
|
|
|
implicitHeight: tileBody.implicitHeight + 20
|
|
radius: Theme.cardRadius
|
|
color: Theme.alpha(Theme.bgDark, 0.55)
|
|
border.width: 1
|
|
border.color: Theme.alpha(Theme.fg, 0.08)
|
|
|
|
Column {
|
|
id: tileBody
|
|
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.top: parent.top
|
|
anchors.margins: 11
|
|
spacing: 2
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: tile.value
|
|
color: Theme.fg
|
|
font.family: Theme.fontFamily
|
|
font.features: Theme.tabularFigures
|
|
font.pixelSize: Theme.fontSizeLarge + 3
|
|
font.weight: Font.DemiBold
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: tile.caption
|
|
color: Theme.fgDim
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Power profile ────────────────────────────────────────────────────────
|
|
//
|
|
// Not a stored preference: the daemon owns it, it survives Panama restarts,
|
|
// and anything else on the system can change it, so a copy here would drift.
|
|
SettingsCard {
|
|
visible: PowerProfiles.available || PowerProfiles.lastError !== ""
|
|
title: "Power profile"
|
|
subtitle: {
|
|
if (!PowerProfiles.available)
|
|
return PowerProfiles.lastError;
|
|
if (PowerProfiles.degraded !== "")
|
|
return "Held back right now: " + PowerProfiles.degraded;
|
|
// Fedora serves this interface from tuned-ppd rather than
|
|
// power-profiles-daemon. Naming the daemon that is actually
|
|
// answering beats implying one that is not installed.
|
|
return "Applied by the system's profile daemon — tuned here — so it outlives Panama, and anything else on the machine can change it.";
|
|
}
|
|
|
|
PowerProfileTiles {
|
|
profiles: PowerProfiles.profiles
|
|
current: PowerProfiles.active
|
|
busy: PowerProfiles.busy
|
|
onPicked: profile => PowerProfiles.set(profile)
|
|
}
|
|
}
|
|
|
|
// ── Idle ─────────────────────────────────────────────────────────────────
|
|
SettingsCard {
|
|
title: Battery.available
|
|
? (root.onBattery ? "Idle — on battery" : "Idle — on wall power")
|
|
: "Idle"
|
|
subtitle: {
|
|
if (!IdleLock.managed)
|
|
return "hypridle is running its shipped configuration. These are a stored intention until you turn management on below.";
|
|
if (Battery.available)
|
|
return "What happens as the machine sits untouched. Drag the stops or use the sliders — the same numbers. hypridle holds one set of timings at a time, so these swap over when the charger does.";
|
|
return "What happens as the machine sits untouched. Drag the stops or use the sliders — the same numbers.";
|
|
}
|
|
|
|
IdleTimeline {
|
|
// The picture draws whichever set is in force, because hypridle can
|
|
// only be running one of them.
|
|
blankKey: root.onBattery ? "screenBlankMinutesBattery" : "screenBlankMinutes"
|
|
lockKey: root.onBattery ? "lockMinutesBattery" : "lockMinutes"
|
|
suspendKey: root.onBattery ? "suspendMinutesBattery" : "suspendMinutes"
|
|
|
|
// Stepped back while hypridle runs its own config: the numbers are
|
|
// still stored and still editable, they are just not what the
|
|
// machine is doing.
|
|
opacity: IdleLock.managed ? 1 : 0.6
|
|
}
|
|
|
|
Item {
|
|
width: parent.width
|
|
height: 14
|
|
}
|
|
|
|
// Both sets are declared and one is shown. Six sliders in a column
|
|
// would be the two cards this page just stopped having, stacked.
|
|
SliderRow {
|
|
visible: !root.onBattery
|
|
setting: "screenBlankMinutes"
|
|
zeroLabel: "Never"
|
|
}
|
|
SliderRow {
|
|
visible: !root.onBattery
|
|
setting: "lockMinutes"
|
|
zeroLabel: "Never"
|
|
}
|
|
SliderRow {
|
|
visible: !root.onBattery
|
|
setting: "suspendMinutes"
|
|
zeroLabel: "Never"
|
|
}
|
|
|
|
SliderRow {
|
|
visible: root.onBattery
|
|
setting: "screenBlankMinutesBattery"
|
|
zeroLabel: "Never"
|
|
}
|
|
SliderRow {
|
|
visible: root.onBattery
|
|
setting: "lockMinutesBattery"
|
|
zeroLabel: "Never"
|
|
}
|
|
SliderRow {
|
|
visible: root.onBattery
|
|
setting: "suspendMinutesBattery"
|
|
zeroLabel: "Never"
|
|
}
|
|
|
|
ToggleRow { setting: "lockOnSleep" }
|
|
|
|
// hypridle has no conditional listener -- it cannot be told "unless
|
|
// something is playing". What it does have is logind's inhibitors,
|
|
// which every well-behaved player already takes. Naming them is the
|
|
// honest substitute for a rule that cannot be written.
|
|
//
|
|
// "Nothing" is said only when logind has been asked and answered with
|
|
// no blocks. Not being able to ask is a different claim, and the row
|
|
// makes it rather than quietly reporting a clear machine.
|
|
SettingRow {
|
|
label: "Keeping the machine awake right now"
|
|
detail: {
|
|
if (!IdleLock.inhibitorsKnown)
|
|
return "Could not ask logind what is holding the machine awake";
|
|
const delays = root.wakeDelays > 0
|
|
? " · " + root.wakeDelays
|
|
+ (root.wakeDelays === 1 ? " service" : " services")
|
|
+ " briefly delay sleep on the way down, which is normal"
|
|
: "";
|
|
if (root.wakeBlocks.length === 0)
|
|
return "Nothing — no application holds a wake lock" + delays;
|
|
return "These hold sleep off entirely, so the timings above wait for them" + delays;
|
|
}
|
|
controlWidth: 0
|
|
divider: root.wakeBlocks.length > 0
|
|
}
|
|
|
|
Repeater {
|
|
model: root.wakeBlocks
|
|
|
|
TextRow {
|
|
required property var modelData
|
|
required property int index
|
|
|
|
width: parent.width
|
|
label: String(modelData.who ?? "Something")
|
|
detail: String(modelData.why ?? "")
|
|
value: String(modelData.what ?? "")
|
|
controlWidth: 120
|
|
divider: index < root.wakeBlocks.length - 1
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Battery ──────────────────────────────────────────────────────────────
|
|
//
|
|
// Absent on a desktop, in full. `available` is false until a battery has
|
|
// actually been read, so this is not an empty card claiming 0%.
|
|
SettingsCard {
|
|
visible: Battery.available
|
|
title: "Battery"
|
|
subtitle: Math.round(Battery.percent) + "% · " + root.powerState
|
|
|
|
// Each tile is drawn only where the firmware reported the number
|
|
// behind it. Plenty of packs report neither health nor cycles, and a
|
|
// tile reading "100% of design capacity" on a battery that never said
|
|
// is a confident wrong answer about whether the hardware is dying.
|
|
Item {
|
|
id: healthTiles
|
|
|
|
readonly property int count: (Battery.healthPercent > 0 ? 1 : 0)
|
|
+ (Battery.cycleCount > 0 ? 1 : 0)
|
|
+ (Battery.chargeLimitSupported ? 1 : 0)
|
|
readonly property real tileWidth:
|
|
(healthTiles.width - 10 * (healthTiles.count - 1))
|
|
/ Math.max(1, healthTiles.count)
|
|
|
|
width: parent.width
|
|
visible: healthTiles.count > 0
|
|
implicitHeight: tiles.implicitHeight + 12
|
|
|
|
Row {
|
|
id: tiles
|
|
|
|
width: parent.width
|
|
spacing: 10
|
|
|
|
StatTile {
|
|
width: healthTiles.tileWidth
|
|
visible: Battery.healthPercent > 0
|
|
value: Math.round(Battery.healthPercent) + "%"
|
|
caption: "Health — of its design capacity"
|
|
}
|
|
|
|
StatTile {
|
|
width: healthTiles.tileWidth
|
|
visible: Battery.cycleCount > 0
|
|
value: String(Battery.cycleCount)
|
|
caption: "Charge cycles"
|
|
}
|
|
|
|
// Read back from the firmware rather than from the setting: the
|
|
// slider below asks, and some firmware clamps or ignores the
|
|
// value. This tile is what the hardware actually did.
|
|
StatTile {
|
|
width: healthTiles.tileWidth
|
|
visible: Battery.chargeLimitSupported
|
|
value: Battery.chargeLimit + "%"
|
|
caption: "Charging stops at"
|
|
}
|
|
}
|
|
}
|
|
|
|
// Only where the firmware actually has a ceiling. A machine whose
|
|
// kernel exposes nothing gets no control at all, rather than one that
|
|
// would accept a value and change nothing.
|
|
SliderRow {
|
|
visible: Battery.chargeLimitSupported
|
|
setting: "batteryChargeLimit"
|
|
}
|
|
|
|
// The two points at which the desktop starts telling you.
|
|
SliderRow { setting: "batteryLowPercent" }
|
|
SliderRow { setting: "batteryCriticalPercent" }
|
|
ChoiceRow {
|
|
setting: "batteryCriticalAction"
|
|
divider: false
|
|
}
|
|
}
|
|
|
|
// ── The lid ──────────────────────────────────────────────────────────────
|
|
//
|
|
// Read-only by design. The decision follows what is connected (see
|
|
// services/LidPolicy.qml), and the deliberate absence of an override is
|
|
// part of that design: a lid switch set to "never suspend" is a laptop that
|
|
// cooks in a bag. Saying so here beats leaving the behavior to be
|
|
// discovered by closing it.
|
|
SettingsCard {
|
|
visible: Battery.available
|
|
title: "When the lid closes"
|
|
|
|
SettingRow {
|
|
label: LidPolicy.inhibited
|
|
? "Stays awake — an external display is connected"
|
|
: "Suspends — unless an external display is connected"
|
|
detail: "Docked, the lid is just a lid. Panama holds a systemd inhibitor while a second screen is attached and logind does the rest, so this is a fact about the session rather than a setting something else could quietly disagree with."
|
|
controlWidth: 160
|
|
divider: false
|
|
|
|
// The state, in the pill the whole settings app uses for one. It
|
|
// is a fact about the session, not a control, which is why there
|
|
// is a badge here and no switch.
|
|
StatusBadge {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: LidPolicy.inhibited ? "Docked · staying awake" : "Will suspend"
|
|
tone: LidPolicy.inhibited ? Theme.ok : Theme.fgMuted
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── The power button ─────────────────────────────────────────────────────
|
|
SettingsCard {
|
|
title: "The power button"
|
|
subtitle: "logind is told to ignore the key, so what a press does is Panama's decision rather than the system's — and changing it needs no root. Holding it down still cuts the power through the firmware, as ever."
|
|
|
|
OptionPickerRow {
|
|
setting: "powerButtonAction"
|
|
divider: false
|
|
}
|
|
}
|
|
|
|
// ── Session ──────────────────────────────────────────────────────────────
|
|
SettingsCard {
|
|
title: "Session"
|
|
|
|
ActionRow {
|
|
label: "Lock the screen now"
|
|
detail: "Same as the Super+L shortcut"
|
|
action: "Lock"
|
|
// Straight to logind, as the power menu and the keybind both do.
|
|
// Locking is the one session action with nothing to lose, which is
|
|
// why it is the only one this page offers.
|
|
onTriggered: Quickshell.execDetached(["loginctl", "lock-session"])
|
|
}
|
|
|
|
// The probe lives on IdleLock now, so the row states this machine's
|
|
// own answer. Before logind has answered, it claims nothing.
|
|
SettingRow {
|
|
label: "Hibernate"
|
|
detail: {
|
|
if (!IdleLock.canHibernateKnown)
|
|
return "Asking logind whether this machine can resume from disk…";
|
|
if (IdleLock.canHibernate)
|
|
return "Available — the power menu offers it, and logind says this machine can resume from disk.";
|
|
return "Unavailable on this machine — swap lives in compressed RAM (zram), which vanishes with the power, so suspend is the deepest rest it has.";
|
|
}
|
|
controlWidth: 0
|
|
}
|
|
|
|
SettingRow {
|
|
label: "Power menu"
|
|
detail: {
|
|
const action = DesktopPreferences.get("powerButtonAction");
|
|
if (action === "poweroff")
|
|
return "Ctrl+Alt+Delete, or the power button — which opens it with Power Off already armed, so a second press finishes the job";
|
|
if (action === "menu")
|
|
return "Ctrl+Alt+Delete, or the power button";
|
|
return "Ctrl+Alt+Delete";
|
|
}
|
|
controlWidth: 0
|
|
divider: false
|
|
}
|
|
}
|
|
|
|
// ── Management ───────────────────────────────────────────────────────────
|
|
SettingsCard {
|
|
title: "Management"
|
|
subtitle: "hypridle's configuration is generated into your state directory and the service is pointed at it with a systemd drop-in. ~/.config/hypr is a symlink into the configuration repository, so the shipped file cannot be rewritten in place."
|
|
|
|
SettingRow {
|
|
label: "Let Panama manage idle behavior"
|
|
detail: {
|
|
const state = IdleLock.serviceState === "active"
|
|
? "hypridle running"
|
|
: "hypridle " + IdleLock.serviceState;
|
|
if (IdleLock.generatedPath === "")
|
|
return state;
|
|
return "Generated at " + root.shorten(IdleLock.generatedPath) + " · " + state;
|
|
}
|
|
controlWidth: 48
|
|
divider: false
|
|
|
|
SettingsToggle {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
checked: IdleLock.managed
|
|
enabled: !IdleLock.busy
|
|
onToggled: value => IdleLock.setManaged(value)
|
|
}
|
|
}
|
|
}
|
|
|
|
SettingsCard {
|
|
visible: IdleLock.lastError !== ""
|
|
title: "Idle configuration problem"
|
|
subtitle: IdleLock.lastError
|
|
|
|
ActionRow {
|
|
label: "Read the idle configuration again"
|
|
action: "Retry"
|
|
divider: false
|
|
onTriggered: IdleLock.refresh()
|
|
}
|
|
}
|
|
}
|