// 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() } } }