Draw idle as one timeline, and let the power button answer to its owner

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 20:33:44 -04:00
parent 6f0ce639d9
commit e1ff25fc66
23 changed files with 2655 additions and 174 deletions
@@ -1253,6 +1253,27 @@ Singleton {
detail: "On battery, sleeping is what makes the charge last"
},
// ── Power button ────────────────────────────────────────────────────
// logind is told to ignore the power key -- config/copy ships the
// drop-in -- so what a press does is the compositor's decision rather
// than the system's, and changing it needs no root.
//
// No `hypr` block: this is not a compositor option, it is read by
// config/dot/hypr/keybinds.lua the way the workspace rules are. The
// bind evaluates it AT PRESS TIME rather than at config time, so a
// change here applies to the very next press and no reload is needed.
{
key: "powerButtonAction", type: "enum", def: "menu", group: "power",
label: "Pressing the power button",
detail: "The system ignores the key; Panama decides — so a bumped button never yanks the plug",
options: [
{ value: "menu", label: "Shows the power menu" },
{ value: "suspend", label: "Suspends" },
{ value: "poweroff", label: "Powers off (two-press)" },
{ value: "nothing", label: "Does nothing" }
]
},
// ── Night light schedule ────────────────────────────────────────────
// Hours as decimals, so 17.5 is half past five. Wrapping past midnight
// is normal here and is what the shipped values do: on at 17:00, off at
@@ -63,26 +63,34 @@ PanelWindow {
// withdraw itself (hibernate on a machine with no resume swap).
readonly property var entries: allEntries.filter(entry => entry.available !== false)
// `entryId` is the stable name outside code can address an entry by --
// the power-button bind asks for "poweroff" and gets Power Off wherever it
// happens to sit. Labels are copy and hibernate comes and goes, so neither
// is something an IPC call can be built on.
readonly property var allEntries: [
{
entryId: "lock",
glyph: "󰌾",
label: "Lock",
destructive: false,
cmd: ["loginctl", "lock-session"]
},
{
entryId: "logout",
glyph: "󰗼",
label: "Log Out",
destructive: true,
cmd: ["sh", "-c", win.logoutScript]
},
{
entryId: "suspend",
glyph: "󰒲",
label: "Suspend",
destructive: false,
cmd: ["systemctl", "suspend"]
},
{
entryId: "hibernate",
glyph: "󰋊",
label: "Hibernate",
destructive: false,
@@ -90,12 +98,14 @@ PanelWindow {
cmd: ["systemctl", "hibernate"]
},
{
entryId: "restart",
glyph: "󰜉",
label: "Restart",
destructive: true,
cmd: ["systemctl", "reboot"]
},
{
entryId: "poweroff",
glyph: "󰐥",
label: "Power Off",
destructive: true,
@@ -103,14 +113,70 @@ PanelWindow {
}
]
onVisibleChanged: {
if (!win.visible)
// The entry to arm once the menu is on screen, set by preselect() before
// opening. Cleared as soon as it is applied, and again on close, so an
// interrupted open can never arm something on the next unrelated one.
property string armOnOpen: ""
function indexOfEntry(entryId: string): int {
for (let i = 0; i < win.entries.length; i++) {
if (win.entries[i].entryId === entryId)
return i;
}
return -1;
}
// Open the menu with one entry pre-armed, addressed by its id.
//
// This is what the power button's "Powers off" setting binds to: the first
// press opens the menu with Power Off selected and armed, and the second
// press is the confirm the menu already asks for. Nothing here goes around
// that confirm -- it calls the same trigger() a click calls, so a
// destructive entry still takes two presses and a harmless one still takes
// one. Pre-arming only removes the reach for the mouse, not the question.
function preselect(entryId: string): void {
const index = win.indexOfEntry(entryId);
if (index < 0) {
// An id this machine has no entry for -- hibernate without a
// resume swap. Open the menu rather than doing nothing at all.
win.armOnOpen = "";
ShellState.open("powermenu");
return;
}
if (!win.visible) {
win.armOnOpen = entryId;
ShellState.open("powermenu");
return;
}
// Already open: this press is the next one in the sequence.
win.currentIndex = index;
const button = rep.itemAt(index);
if (button)
button.trigger();
}
onVisibleChanged: {
if (!win.visible) {
win.armOnOpen = "";
return;
}
// Never reopen with a destructive button still armed from last time.
win.currentIndex = 0;
for (let i = 0; i < rep.count; i++)
rep.itemAt(i).disarm();
keys.forceActiveFocus();
const wanted = win.armOnOpen;
win.armOnOpen = "";
if (wanted === "")
return;
const index = win.indexOfEntry(wanted);
if (index < 0)
return;
win.currentIndex = index;
const button = rep.itemAt(index);
if (button)
button.trigger();
}
function run(index: int): void {
@@ -0,0 +1,485 @@
// One picture of what the machine does while it sits untouched.
//
// The Power page used to show three sliders stacked in a column and leave you
// to hold the relationship between them in your head: blank at 5, lock at 10,
// suspend never. The numbers were never the problem -- what was missing is
// that they are one sequence, and that some orderings of that sequence are
// nonsense. The sliders stay, because they are still the precise way to set a
// number; this draws the same three values as the sequence they actually are,
// and puts the ordering warnings on the picture instead of in a card further
// down that nobody reads.
//
// ── The scale ────────────────────────────────────────────────────────────────
//
// The axis is a FIXED piecewise-linear time scale, not one derived from the
// current values. That distinction matters more than it sounds. A scale
// computed from the stops means dragging a stop moves the scale, which moves
// the stop, under a pointer that has not moved -- the drag chases itself and
// snapping becomes unpredictable. Fixing the breakpoints costs a little
// proportionality at the long end and buys a drag that behaves.
//
// Within each band the mapping is exactly proportional. The first quarter of
// an hour gets nearly half the track because that is where every timing anyone
// actually sets lives; the eight-hour tail gets what is left.
//
// ── "Never" ──────────────────────────────────────────────────────────────────
//
// Never is not zero minutes on that scale -- it is the absence of the event, so
// putting it at the origin would draw "the screen never blanks" as "the screen
// blanks immediately". Never stops park on a reserved shelf past the end of the
// scale, drawn muted, which is also what makes dragging a stop off the right
// end mean "stop doing this" and dragging it back mean "start again".
//
// ── Delegate lifetime ────────────────────────────────────────────────────────
//
// There is deliberately no Repeater here. The Displays arrangement canvas
// learned the hard way that a model rebuilt by the drag itself destroys the
// delegate under the pointer on its first millimetre of travel. There are
// exactly three stops and they are three declared instances, so the item being
// dragged cannot be replaced mid-gesture by anything.
import QtQuick
import qs.config
import qs.services
Item {
id: root
// Which schema keys this timeline reads and writes. The page swaps them for
// the battery set when the charger comes out, so the picture always draws
// the timings that are actually in force.
property string blankKey: "screenBlankMinutes"
property string lockKey: "lockMinutes"
property string suspendKey: "suspendMinutes"
// Minutes to fraction of the track. Monotone by construction, and inverted
// below so a pointer position can be turned back into minutes.
readonly property var bands: [
{ minutes: 15, frac: 0.46 },
{ minutes: 60, frac: 0.74 },
{ minutes: 480, frac: 0.88 }
]
// Everything to the right of this is the Never shelf.
readonly property real neverStart: 0.88
// Room for a knob to sit on either end without being clipped. The track's
// geometry lives on the root rather than being read off the Rectangle's id,
// because the stop below is an inline component and reaching sideways into
// a sibling id from one is not something to rely on.
readonly property real trackLeft: 10
readonly property real trackSpan: Math.max(1, root.width - 2 * root.trackLeft)
readonly property real trackTop: 20
readonly property real trackThickness: 8
// Roughly how much room a stop's label wants. Used only to decide whether
// two labels would collide and one should drop to the next line.
readonly property real labelSpan: 80
// ── Drag state ───────────────────────────────────────────────────────────
//
// One stop at a time. `dragMinutes` is the snapped value the drag is
// proposing; `dragFrac` is the raw pointer position, used only to keep a
// knob that has landed on the Never shelf under the finger instead of
// jumping to the shelf slot it will occupy once the drag ends.
property string dragKey: ""
property real dragMinutes: 0
property real dragFrac: 0
readonly property bool dragging: root.dragKey !== ""
width: parent ? parent.width : 620
implicitHeight: 36 + root.labelRows * 32 + (warnings.visible ? warnings.implicitHeight + 6 : 0)
// ── Scale ────────────────────────────────────────────────────────────────
function fracFor(minutes: real): real {
const value = Math.max(0, Math.min(480, minutes));
let prevMinutes = 0;
let prevFrac = 0;
for (const band of root.bands) {
if (value <= band.minutes) {
const span = band.minutes - prevMinutes;
const ratio = span > 0 ? (value - prevMinutes) / span : 0;
return prevFrac + ratio * (band.frac - prevFrac);
}
prevMinutes = band.minutes;
prevFrac = band.frac;
}
return root.neverStart;
}
function minutesFor(frac: real): real {
const value = Math.max(0, Math.min(root.neverStart, frac));
let prevMinutes = 0;
let prevFrac = 0;
for (const band of root.bands) {
if (value <= band.frac) {
const span = band.frac - prevFrac;
const ratio = span > 0 ? (value - prevFrac) / span : 0;
return prevMinutes + ratio * (band.minutes - prevMinutes);
}
prevMinutes = band.minutes;
prevFrac = band.frac;
}
return 480;
}
// The schema owns the range and the step, so a drag can never propose a
// value the store would refuse.
function snap(key: string, minutes: real): real {
const spec = PreferenceSchema.spec(key);
const step = spec && spec.step ? spec.step : 1;
const lower = spec && spec.min !== undefined ? spec.min : 0;
const upper = spec && spec.max !== undefined ? spec.max : 480;
return Math.max(lower, Math.min(upper, Math.round(minutes / step) * step));
}
function stepOf(key: string): real {
const spec = PreferenceSchema.spec(key);
return spec && spec.step ? spec.step : 1;
}
// What the timeline should draw for a key: the drag's proposal while one is
// in flight, the stored value otherwise.
function minutesOf(key: string): real {
if (key === root.dragKey)
return root.dragMinutes;
const stored = DesktopPreferences.get(key);
return typeof stored === "number" ? stored : 0;
}
function caption(minutes: real): string {
return minutes <= 0 ? "Never" : minutes + " min";
}
// ── Layout ───────────────────────────────────────────────────────────────
//
// Three entries in event order, each carrying where its stop sits and which
// label line it belongs on. Recomputed whenever a value or the width
// changes; nothing here owns any state.
readonly property var stops: {
const span = root.trackSpan;
const entries = [
{ key: root.blankKey, title: "Screen off" },
{ key: root.lockKey, title: "Lock" },
{ key: root.suspendKey, title: "Suspend" }
].map(entry => {
const minutes = root.minutesOf(entry.key);
return { key: entry.key, title: entry.title, minutes: minutes, never: minutes <= 0 };
});
// Never stops share the shelf, spread across it in event order so two
// of them do not land on the same pixel.
const shelf = entries.filter(entry => entry.never).length;
let taken = 0;
for (const entry of entries) {
if (entry.never) {
entry.frac = root.neverStart
+ (taken + 0.5) / Math.max(1, shelf) * (1 - root.neverStart);
taken += 1;
} else {
entry.frac = root.fracFor(entry.minutes);
}
}
// A label drops to the next line only when it would otherwise overlap
// the last one placed on this one, so the ordinary case stays a single
// row and the degenerate one -- three stops crowded onto the shelf --
// stays readable instead of printing over itself.
const ordered = entries.slice().sort((a, b) => a.frac - b.frac);
let lastByRow = [-root.labelSpan, -root.labelSpan, -root.labelSpan];
for (const entry of ordered) {
const x = entry.frac * span;
let row = 0;
while (row < 2 && (x - lastByRow[row]) < root.labelSpan)
row += 1;
entry.row = row;
lastByRow[row] = x;
}
return entries;
}
readonly property int labelRows:
1 + root.stops.reduce((deepest, entry) => Math.max(deepest, entry.row), 0)
// ── Orderings that do not mean what they look like ───────────────────────
readonly property real blankMinutes: root.minutesOf(root.blankKey)
readonly property real lockMinutes: root.minutesOf(root.lockKey)
readonly property real suspendMinutes: root.minutesOf(root.suspendKey)
readonly property bool lockBeforeBlank: root.lockMinutes > 0
&& root.blankMinutes > 0
&& root.lockMinutes < root.blankMinutes
readonly property bool suspendBeforeLock: root.suspendMinutes > 0
&& root.lockMinutes > 0
&& root.suspendMinutes < root.lockMinutes
// ── Writing ──────────────────────────────────────────────────────────────
//
// Same shape as SliderRow: the picture follows the pointer immediately, the
// store is written after a short quiet period, and the drag value is handed
// back a moment after that so a refused write snaps the stop to what is
// really set rather than leaving it where the pointer left it.
function propose(key: string, minutes: real, frac: real): void {
root.dragKey = key;
root.dragMinutes = root.snap(key, minutes);
root.dragFrac = Math.max(0, Math.min(1, frac));
release.stop();
commit.restart();
}
function settle(): void {
if (root.dragKey === "")
return;
commit.stop();
SystemSettings.commitPreference(root.dragKey, root.dragMinutes);
release.restart();
}
Timer {
id: commit
interval: 140
onTriggered: {
if (root.dragKey !== "")
SystemSettings.commitPreference(root.dragKey, root.dragMinutes);
}
}
Timer {
id: release
interval: 160
onTriggered: root.dragKey = ""
}
// ── The picture ──────────────────────────────────────────────────────────
Text {
x: root.trackLeft
y: 0
text: "Active"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
font.weight: Font.DemiBold
font.capitalization: Font.AllUppercase
font.letterSpacing: 0.7
}
Rectangle {
id: track
x: root.trackLeft
y: root.trackTop
width: root.trackSpan
height: root.trackThickness
radius: root.trackThickness / 2
border.width: 0
// Blue leads into orchid and fades out toward the far end, so the
// track reads as "awake, then less so" rather than as a progress bar.
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.42) }
GradientStop { position: 0.6; color: Theme.alpha(Theme.accentSecondary, 0.34) }
GradientStop { position: 1.0; color: Theme.alpha(Theme.fgMuted, 0.26) }
}
// Where the scale stops and the Never shelf begins.
Rectangle {
x: root.neverStart * track.width - width / 2
anchors.verticalCenter: parent.verticalCenter
width: 2
height: 16
radius: 1
border.width: 0
color: Theme.alpha(Theme.fg, 0.18)
}
}
// A stop and its label. Declared three times rather than repeated, so a
// value change cannot destroy the one under the pointer.
component Stop: Item {
id: stop
required property int slot
readonly property var entry: root.stops[stop.slot]
readonly property bool never: stop.entry ? stop.entry.never : true
readonly property bool held: root.dragging && !!stop.entry && root.dragKey === stop.entry.key
// While a knob is being dragged onto the shelf its position comes from
// the pointer, because the shelf slot it will end up in is decided by
// how many other stops are already there.
readonly property real frac: {
if (!stop.entry)
return 0;
if (stop.held && stop.never)
return Math.max(root.neverStart, Math.min(1, root.dragFrac));
return stop.entry.frac;
}
readonly property real centre: root.trackLeft + stop.frac * root.trackSpan
anchors.fill: parent
function nudge(direction: int): void {
if (!stop.entry)
return;
const step = root.stepOf(stop.entry.key);
const next = stop.entry.minutes + direction * step;
root.propose(stop.entry.key, Math.max(0, next), root.fracFor(Math.max(0, next)));
root.settle();
}
Rectangle {
id: knob
x: stop.centre - width / 2
y: root.trackTop + root.trackThickness / 2 - height / 2
width: 18
height: 18
radius: 9
color: stop.never ? Theme.fgMuted : Theme.fg
border.width: 3
border.color: Theme.bg
scale: stop.held || knobHover.hovered ? 1.12 : 1
activeFocusOnTab: true
Accessible.role: Accessible.Slider
Accessible.name: (stop.entry ? stop.entry.title : "")
+ ", " + root.caption(stop.entry ? stop.entry.minutes : 0)
Behavior on scale {
NumberAnimation { duration: Theme.durFast; easing.type: Easing.OutQuad }
}
Rectangle {
anchors.centerIn: parent
width: 26
height: 26
radius: 13
border.width: 2
border.color: Theme.accentSecondary
color: "transparent"
visible: knob.activeFocus
}
Keys.onLeftPressed: stop.nudge(-1)
Keys.onRightPressed: stop.nudge(1)
HoverHandler {
id: knobHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: knob.forceActiveFocus()
}
// target: null and explicit translation maths, the same shape the
// display arrangement uses: letting the handler move the item would
// fight the binding that puts the knob where the value says.
DragHandler {
id: drag
target: null
yAxis.enabled: false
property real startFrac: 0
onActiveChanged: {
if (active) {
drag.startFrac = stop.frac;
knob.forceActiveFocus();
} else {
root.settle();
}
}
onTranslationChanged: {
if (!drag.active || !stop.entry || root.trackSpan <= 0)
return;
const frac = Math.max(0, Math.min(1,
drag.startFrac + drag.translation.x / root.trackSpan));
// Past the end of the scale is not "eight hours and a bit"
// -- it is the event being switched off.
const minutes = frac >= root.neverStart ? 0 : root.minutesFor(frac);
root.propose(stop.entry.key, minutes, frac);
}
}
}
Column {
id: label
readonly property int row: stop.entry ? stop.entry.row : 0
x: Math.max(0, Math.min(root.width - width, stop.centre - width / 2))
y: 36 + label.row * 32
width: root.labelSpan
spacing: 1
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
text: stop.entry ? stop.entry.title : ""
color: stop.never ? Theme.fgMuted : Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
text: root.caption(stop.entry ? stop.entry.minutes : 0)
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
elide: Text.ElideRight
}
}
}
Stop { slot: 0 }
Stop { slot: 1 }
Stop { slot: 2 }
// ── Warnings, on the picture rather than beside it ───────────────────────
Column {
id: warnings
x: root.trackLeft
y: 36 + root.labelRows * 32
width: Math.max(1, root.width - 2 * root.trackLeft)
spacing: 3
visible: root.lockBeforeBlank || root.suspendBeforeLock
Text {
width: parent.width
visible: root.lockBeforeBlank
text: "Locks at " + root.caption(root.lockMinutes) + ", before the screen turns off at "
+ root.caption(root.blankMinutes) + " — it works, but the lock screen stays lit for the difference."
color: Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
Text {
width: parent.width
visible: root.suspendBeforeLock
text: "Suspends at " + root.caption(root.suspendMinutes) + ", before the lock at "
+ root.caption(root.lockMinutes) + " — the idle lock never fires, so locking depends on “Lock before sleeping” below."
color: Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
}
@@ -5,22 +5,50 @@
// ones and falls apart at four with sentences under them -- and on this page
// two cards sit side by side, so a row has half the width it used to.
//
// Not schema-bound. Some of these choices are Panama settings and some are
// display state that has to go through a keep-or-revert transaction, so the
// caller says what to do with the value rather than this writing it.
// Schema-bound when you name a key, hand-fed when you do not:
//
// OptionPickerRow { setting: "powerButtonAction" }
//
// gets its label, explanation, option list and current value from
// PreferenceSchema and commits the pick for you, exactly as ToggleRow and
// ChoiceRow do. Leaving `setting` empty keeps the original behavior, which is
// what the display rows need: some of these choices are not preferences at all
// but compositor state going through a keep-or-revert transaction, so the
// caller says what a pick means rather than this writing it.
import QtQuick
import qs.config
import qs.services
PickerRow {
id: root
// Empty means "not a stored preference"; see above.
property string setting: ""
readonly property var spec:
root.setting === "" ? null : PreferenceSchema.spec(root.setting)
// [{ value, label, detail }]
property var options: []
property var current: null
property var options: root.spec && root.spec.options ? root.spec.options : []
property var current: root.setting === "" ? null : DesktopPreferences.get(root.setting)
label: root.spec ? root.spec.label : ""
detail: root.spec ? root.spec.detail : ""
signal picked(var value)
// Connected rather than handled inline: a caller that declares its own
// `onPicked` replaces a handler written in this file, and a schema-bound
// row that silently stopped writing would be a bad way to find that out.
Connections {
target: root
function onPicked(value: var): void {
if (root.setting !== "")
SystemSettings.commitPreference(root.setting, value);
}
}
readonly property var currentOption:
root.options.find(option => option.value === root.current) ?? null
@@ -1,10 +1,26 @@
// 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 when Panama manages the daemon, and the card below says
// plainly which state you are in rather than presenting sliders that silently
// do nothing.
// 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
@@ -14,171 +30,431 @@ import qs.services
SettingsPage {
id: root
// Probing the power daemon is a D-Bus round trip, so it happens when the
// page opens rather than at shell startup.
Component.onCompleted: if (!PowerProfiles.scanned) PowerProfiles.refresh()
title: "Power & Lock"
lede: "When the screen turns off, when the session locks, and whether it ever sleeps."
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: Battery.acOnline
? "On wall power."
: "On battery. The timings below switch to their battery values automatically."
subtitle: Math.round(Battery.percent) + "% · " + root.powerState
TextRow {
label: "Charge"
value: Math.round(Battery.percent) + "%"
}
// 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
TextRow {
label: "State"
value: {
if (Battery.charging)
return "Charging";
if (Battery.status === "Full")
return "Full";
if (Battery.acOnline)
return "Plugged in, not charging";
return "On battery";
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"
}
}
}
// The two points at which the desktop starts telling you. These were
// in the schema and reachable from settings search long before any
// page rendered them -- search delivered people to this card and the
// controls were not here.
SliderRow { setting: "batteryLowPercent" }
SliderRow { setting: "batteryCriticalPercent" }
ChoiceRow {
setting: "batteryCriticalAction"
divider: Battery.chargeLimitSupported
}
// 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 same profiles GNOME's Power panel offers. 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.
// ── 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: PowerProfiles.available || PowerProfiles.lastError !== ""
title: "Power profile"
subtitle: PowerProfiles.degraded !== ""
? "Performance is limited right now: " + PowerProfiles.degraded
: (PowerProfiles.available
? "Applies to the whole system and persists across sessions."
: PowerProfiles.lastError)
visible: Battery.available
title: "When the lid closes"
Repeater {
model: PowerProfiles.profiles
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
SettingRow {
id: profileRow
required property var modelData
required property int index
label: PowerProfiles.label(profileRow.modelData)
detail: PowerProfiles.detail(profileRow.modelData)
value: profileRow.modelData === PowerProfiles.active ? "Active" : ""
divider: profileRow.index < PowerProfiles.profiles.length - 1
activatable: profileRow.modelData !== PowerProfiles.active && !PowerProfiles.busy
onActivated: PowerProfiles.set(profileRow.modelData)
// SoundBadge is the pill Panama already has for "how this thing is
// attached, or why it is not here"; the name is where it was first
// needed rather than what it is.
SoundBadge {
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 {
// Named for the power source only on a machine that has two of them.
// On a desktop this is simply "Idle behavior", as it always was.
title: Battery.available ? "Idle behavior on wall power" : "Idle behavior"
subtitle: IdleLock.managed
? (Battery.available && !Battery.acOnline
? "Managed here. These apply when the charger is connected; the battery timings below are what is in effect right now."
: "Idle timings are managed here. Changes take effect immediately.")
: "hypridle is running its shipped configuration. Turn on management below to make these adjustable."
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."
SliderRow { setting: "screenBlankMinutes"; zeroLabel: "Never" }
SliderRow { setting: "lockMinutes"; zeroLabel: "Never" }
SliderRow { setting: "suspendMinutes"; zeroLabel: "Never" }
ToggleRow { setting: "lockOnSleep"; divider: false }
}
// Absent on a desktop. hypridle holds one set of timeouts at a time, so
// these do not layer on top of the card above -- they replace it whenever
// the charger is unplugged, and panama-idle rebuilds the config at that
// moment.
SettingsCard {
visible: Battery.available
title: "Idle behavior on battery"
subtitle: Battery.acOnline
? "What will apply once the charger is unplugged."
: "In effect right now."
SliderRow { setting: "screenBlankMinutesBattery"; zeroLabel: "Never" }
SliderRow { setting: "lockMinutesBattery"; zeroLabel: "Never" }
SliderRow { setting: "suspendMinutesBattery"; zeroLabel: "Never"; divider: false }
}
// What the lid does. Informational by design: the decision follows what
// is connected (see services/LidPolicy.qml), and the deliberate absence
// of an override is part of the design -- a lid switch set to "never
// suspend" is a laptop that cooks in a bag. Saying so here beats leaving
// the behavior undiscoverable.
SettingsCard {
visible: Battery.available
title: "When the lid closes"
subtitle: "Decided by what is connected rather than by a setting: with an external display attached the machine is docked and keeps running; on its own it suspends, locking on the way down."
TextRow {
label: "Right now"
value: LidPolicy.inhibited
? "Stays awake — an external display is connected"
: "Suspends"
OptionPickerRow {
setting: "powerButtonAction"
divider: false
}
}
// Informational like the lid card above it: the behavior is a logind
// drop-in plus a compositor bind (see keybinds.lua), not a preference,
// and saying what a physical button does beats leaving it to be
// discovered by pressing it.
// ── Session ──────────────────────────────────────────────────────────────
SettingsCard {
title: "The power button"
subtitle: "A press opens the power menu instead of powering off immediately. Holding it still forces power off through the firmware, as ever."
}
title: "Session"
// Only shown when the numbers are actually contradictory, rather than as a
// permanent warning nobody reads.
SettingsCard {
visible: IdleLock.lockBeforeBlank
title: "Lock happens before the screen turns off"
subtitle: "The session will lock at " + IdleLock.lockMinutes
+ " minutes and the display will not blank until " + IdleLock.blankMinutes
+ ". That works, but the screen stays lit on the lock screen for the difference."
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: "Manage idle timings here"
detail: IdleLock.serviceState === "active"
? "hypridle is running"
: "hypridle is " + IdleLock.serviceState
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
@@ -188,14 +464,6 @@ SettingsPage {
onToggled: value => IdleLock.setManaged(value)
}
}
ActionRow {
label: "Lock the screen now"
detail: "Same as the Super+L shortcut"
action: "Lock"
divider: false
onTriggered: Quickshell.execDetached(["loginctl", "lock-session"])
}
}
SettingsCard {
@@ -0,0 +1,135 @@
// The system power profiles as tiles rather than a stack of rows.
//
// Three rows with the word "Active" in the trailing column is a list you have
// to read to find out what is set. Three tiles with one lit answers that
// without reading anything -- and the profile is the one control on the Power
// page a person changes on purpose rather than tunes once and forgets.
//
// Presentation only, the same shape ColorProfileTiles has: the page passes in
// what the daemon says and decides what a pick means, so this cannot offer a
// profile the daemon would refuse. Names and one-line descriptions still come
// from PowerProfiles, which is where they live so the Control Center and
// Settings cannot disagree about what "Balanced" means.
import QtQuick
import qs.config
import qs.services
import qs.widgets
Flow {
id: root
// The daemon's own list, its current answer, and whether a change is still
// in flight.
property var profiles: []
property string current: ""
property bool busy: false
signal picked(string profile)
width: parent ? parent.width : 620
spacing: 10
bottomPadding: 12
// The same freedesktop symbolic names the quick-settings panel uses, so a
// themed icon set redresses both at once.
function iconFor(profile: string): string {
switch (profile) {
case "power-saver": return "power-profile-power-saver-symbolic";
case "performance": return "power-profile-performance-symbolic";
default: return "power-profile-balanced-symbolic";
}
}
// Three across when they fit, one across when they do not. There is no
// useful two-across arrangement of three tiles.
readonly property int columns: root.width >= 460 ? 3 : 1
readonly property real tileWidth:
(root.width - root.spacing * (root.columns - 1)) / root.columns
Repeater {
model: root.profiles
Rectangle {
id: tile
required property var modelData
readonly property string profile: String(tile.modelData)
readonly property bool selected: tile.profile === root.current
width: root.tileWidth
implicitHeight: body.implicitHeight + 24
radius: Theme.cardRadius
opacity: root.busy && !tile.selected ? 0.55 : 1
color: tile.selected
? Theme.alpha(Theme.accent, 0.09)
: Theme.alpha(Theme.fg, tileHover.hovered ? 0.08 : 0.04)
border.width: tile.selected || tile.activeFocus ? 2 : 1
border.color: tile.activeFocus
? Theme.accentSecondary
: (tile.selected ? Theme.alpha(Theme.accent, 0.6) : Theme.alpha(Theme.fg, 0.08))
activeFocusOnTab: true
Accessible.role: Accessible.RadioButton
Accessible.name: PowerProfiles.label(tile.profile)
Accessible.checked: tile.selected
function choose(): void {
if (!tile.selected && !root.busy)
root.picked(tile.profile);
}
Keys.onReturnPressed: tile.choose()
Keys.onSpacePressed: tile.choose()
Column {
id: body
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 12
spacing: 6
ThemedIcon {
icon: root.iconFor(tile.profile)
iconFallback: "preferences-system-power-symbolic"
size: 20
tint: tile.selected ? Theme.accent : Theme.fgDim
}
Text {
width: parent.width
text: PowerProfiles.label(tile.profile)
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Text {
width: parent.width
text: PowerProfiles.detail(tile.profile)
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
wrapMode: Text.WordWrap
}
}
HoverHandler {
id: tileHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: {
tile.choose();
tile.forceActiveFocus();
}
}
}
}
}
@@ -132,3 +132,5 @@ PasswordStrengthRow 1.0 PasswordStrengthRow.qml
StockAvatarPicker 1.0 StockAvatarPicker.qml
FingerprintEnrollPanel 1.0 FingerprintEnrollPanel.qml
OnlineAccountRow 1.0 OnlineAccountRow.qml
IdleTimeline 1.0 IdleTimeline.qml
PowerProfileTiles 1.0 PowerProfileTiles.qml
+60 -3
View File
@@ -69,6 +69,53 @@ read_int() {
printf '%s\n' "$value"
}
# How much of the pack's design capacity is left, as a whole percent.
#
# Energy (µWh) and charge (µAh) are the two spellings of the same pair and a
# pack has one or the other, never both worth trusting, so each is tried in the
# order the kernel prefers. Summed across every system pack for the same reason
# the percentage is: on a two-battery machine, one pack's wear is not the
# machine's.
#
# Fails rather than guessing when the firmware exposes no design capacity,
# which plenty does not. A health figure computed from a design capacity that
# was itself invented is a confident number that is wrong, and this page has
# already decided once (no time-to-empty) that it would rather say nothing.
battery_health() {
local dir full design total_full=0 total_design=0
while IFS= read -r dir; do
full="$(read_int "$dir/energy_full" || read_int "$dir/charge_full")" || continue
design="$(read_int "$dir/energy_full_design" || read_int "$dir/charge_full_design")" || continue
(( design > 0 )) || continue
total_full=$(( total_full + full ))
total_design=$(( total_design + design ))
done < <(all_battery_dirs)
(( total_design > 0 )) || return 1
printf '%s\n' "$(( (total_full * 100 + total_design / 2) / total_design ))"
}
# Full charge cycles, from the primary pack and then from whichever pack
# answers.
#
# A reported zero is treated as "the firmware does not count", not as a battery
# that has never been charged: a great many laptops export cycle_count as a
# permanent 0, and "0 cycles" beside a four-year-old pack reads as a fact
# rather than the absence of one.
battery_cycles() {
local primary="$1" dir value
if [[ -n "$primary" ]] && value="$(read_int "$primary/cycle_count")" && (( value > 0 )); then
printf '%s\n' "$value"
return 0
fi
while IFS= read -r dir; do
if value="$(read_int "$dir/cycle_count")" && (( value > 0 )); then
printf '%s\n' "$value"
return 0
fi
done < <(all_battery_dirs)
return 1
}
cmd_paths() {
local battery mains threshold=""
battery="$(battery_dir)" || battery=""
@@ -87,6 +134,7 @@ cmd_paths() {
cmd_status() {
local battery mains capacity="" state="Unknown" online=1 threshold=0
local health="" cycles=""
battery="$(battery_dir)" || battery=""
mains="$(mains_dir)" || mains=""
@@ -109,16 +157,23 @@ cmd_status() {
done
(( total_full > 0 )) && capacity=$(( (total_now * 100 + total_full / 2) / total_full ))
fi
health="$(battery_health)" || health=""
cycles="$(battery_cycles "$battery")" || cycles=""
fi
if [[ -n "$mains" ]]; then
online="$(read_int "$mains/online")" || online=0
fi
printf '{"available":%s,"percent":%s,"status":"%s","acOnline":%s,"chargeLimit":%s}\n' \
# health and cycles are JSON null when sysfs does not report them, which is
# most desktops and a fair number of laptops. Null rather than 0: a zero
# would render as a dead battery that has never been charged.
printf '{"available":%s,"percent":%s,"status":"%s","acOnline":%s,"chargeLimit":%s,"healthPercent":%s,"cycleCount":%s}\n' \
"$([[ -n "$capacity" ]] && echo true || echo false)" \
"${capacity:-0}" "$state" \
"$([[ "$online" == "1" ]] && echo true || echo false)" \
"$threshold"
"$threshold" \
"${health:-null}" "${cycles:-null}"
}
cmd_set_threshold() {
@@ -156,7 +211,9 @@ case "${1:-status}" in
usage: panama-battery [paths|status|set-threshold <50-100>]
paths JSON: which sysfs files hold the battery, mains and threshold
status JSON: one reading of charge, state, power source and limit
status JSON: one reading of charge, state, power source, limit,
health against design capacity and charge cycles (the last
two null where the firmware does not report them)
set-threshold cap charging at N percent (asks for a password)
USAGE
;;
+60 -5
View File
@@ -9,10 +9,11 @@
# `-c`. The repository's hypridle.conf remains the shipped default and is what
# runs if this has never been set up.
#
# panama-idle apply regenerate and restart hypridle
# panama-idle status report as JSON what is in effect
# panama-idle install write the systemd drop-in (idempotent)
# panama-idle remove remove the drop-in and fall back to the shipped config
# panama-idle apply regenerate and restart hypridle
# panama-idle status report as JSON what is in effect
# panama-idle inhibitors what is currently holding sleep or idle off
# panama-idle install write the systemd drop-in (idempotent)
# panama-idle remove remove the drop-in and fall back to the shipped config
#
# All values are read from the settings store and clamped here as well as in the
# schema, because this script is also reachable from a shell.
@@ -166,6 +167,57 @@ generate() {
generated_tmp=""
}
# What is currently holding sleep or idle off, as a JSON array of
# { who, why, what, mode }.
#
# The Power page can promise a timeline all it likes; if something in the
# session holds a wake lock, the timeline is not what will happen. This is the
# honest substitute for the conditional suspend rules hypridle cannot express:
# rather than inventing rules about when not to sleep, say who is already
# saying it.
#
# Read from logind over D-Bus rather than by parsing `systemd-inhibit --list`.
# That table is a padded, human-facing layout whose `why` column contains
# spaces and whose `who` column is a free string the inhibiting program picks,
# so there is no column count that reliably splits it -- and systemd documents
# it as display output, not an interface. ListInhibitors returns the same rows
# as typed data. `--json=short` plus jq is the whole parser.
#
# `mode` is carried through because it is the difference between a program that
# stops the machine sleeping and one that merely asks for a moment on the way
# down. A `delay` inhibitor holds sleep for at most InhibitDelayMaxSec and then
# the machine sleeps anyway; NetworkManager, UPower and hypridle all hold one
# permanently, and listing those as reasons the machine is awake would be a
# lie the size of the card they appear on. Only `block` keeps a machine up.
#
# Panama's own lid inhibitor is included rather than filtered out. It IS one of
# the reasons a docked machine stays awake, and a list that quietly omits the
# desktop's own hold would be the one entry a person could not act on.
#
# Prints NOTHING and exits non-zero when logind could not be asked, so the
# caller can tell "found nothing" from "could not look". An empty array on
# failure would claim that nothing holds the machine awake, which is the wrong
# way to be wrong, and it would claim it in a shape the reader cannot question.
cmd_inhibitors() {
local raw
raw="$(busctl --json=short call org.freedesktop.login1 /org/freedesktop/login1 \
org.freedesktop.login1.Manager ListInhibitors 2>/dev/null)" || return 1
# ListInhibitors returns a(ssssuu): what, who, why, mode, uid, pid. Only
# the holds that bear on sleeping or idling are kept -- a shutdown or
# power-key inhibitor says nothing about whether the screen will blank.
# handle-lid-switch earns its place for the same reason Panama's own hold
# does: on a docked laptop it is exactly why the machine is still up.
jq -c '
[ .data[0][]
| { what: .[0], who: .[1], why: .[2], mode: .[3] }
| select(.what | split(":")
| any(. == "sleep" or . == "idle" or . == "handle-lid-switch"))
| { who, why, what, mode } ]
| sort_by(.mode == "delay", .who)
' <<<"$raw" 2>/dev/null || return 1
}
install_dropin() {
mkdir -p "$dropin_dir"
cat >"$dropin" <<EOF
@@ -214,8 +266,11 @@ case "${1:-apply}" in
"$(on_battery && printf battery || printf ac)" \
"$blank_min" "$lock_min" "$suspend_min" "$lock_on_sleep" "$generated"
;;
inhibitors)
cmd_inhibitors
;;
*)
printf 'usage: panama-idle [apply|install|remove|status]\n' >&2
printf 'usage: panama-idle [apply|install|remove|status|inhibitors]\n' >&2
exit 2
;;
esac
+152 -15
View File
@@ -91,23 +91,159 @@ cmd_close() {
hyprctl eval "hl.monitor({ output = \"$internal\", disable = true })" >/dev/null
}
# Re-enable on open, preferring what the person chose for this panel in
# Settings (the same displays store monitors.lua reads at startup) and
# falling back to the panel's preferred mode.
# The stored display record for one output, rendered as hl.monitor arguments.
#
# This exists because the four-key version it replaces was a clobber. Opening
# the lid emitted mode, position="auto" and scale and nothing else -- and an
# hl.monitor call REPLACES the rule for that output whole, so every other field
# the person had chosen for the panel went with it. Transform, VRR, bit depth,
# colour profile, SDR trim, mirroring and, worst of all, the position: a panel
# arranged to the left of an external display jumped back to automatic
# placement, taking its workspaces with it, every time the lid was opened.
#
# So the rule is rebuilt from the same store config/dot/hypr/monitors.lua reads
# at startup, with the same validation, so that opening the lid produces the
# rule a reload would have produced. Mirroring that file field for field is the
# point; the two must not be able to disagree about what a saved record means.
#
# The two failure directions are deliberately different, exactly as they are
# there:
#
# * mode, scale or transform unreadable -- or a half-written position, where
# a record carries some layout fields but not a valid pair -- refuses the
# WHOLE record. Nothing is printed and the caller falls back to the panel's
# preferred mode. Guessing a position can strand an output where no cursor
# can reach it.
# * an unreadable colour, VRR, SDR or mirror value drops only itself. The
# geometry survives, and the worst it costs is a wrong shade.
#
# Every value that reaches the emitted string is validated first -- modes and
# positions against a numeric pattern, connector names against the same
# `[%w_.-]` class monitors.lua uses, colour profiles against a fixed set -- so
# nothing from the settings file can carry a quote into the Lua that is built
# from it.
monitor_rule() {
local name="$1" settings="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
[[ -r "$settings" ]] || return 0
jq -r --arg name "$name" '
def numeric: if type == "number" and (isnan | not) and (isinfinite | not)
then . else null end;
def whole: numeric | if . != null and . == floor then . else null end;
. as $root
| ($root.displays[$name] // null) as $e
| if ($e | type) != "object" then "" else
# ── Geometry: all of it, or none of it ───────────────────────────────
($e.mode | if type == "string"
and test("^[0-9]+x[0-9]+@[0-9]+(\\.[0-9]+)?$")
then . else null end) as $mode
| ($mode | if . == null then null
else capture("^(?<w>[0-9]+)x(?<h>[0-9]+)@(?<r>[0-9.]+)$")
| [(.w | tonumber), (.h | tonumber), (.r | tonumber)]
end) as $dim
| (if $dim == null or $dim[0] <= 0 or $dim[1] <= 0 or $dim[2] <= 0
then null else $mode end) as $mode
# A scale is only valid if it divides the mode into whole logical
# pixels; Hyprland refuses the rest, and monitors.lua refuses them here
# first so the two agree about which records are usable.
| ($e.scale | numeric | if . != null and . > 0 and . <= 4
then . else null end) as $rawScale
| (if $mode == null or $rawScale == null then null
else ($dim[0] / $rawScale) as $lw
| ($dim[1] / $rawScale) as $lh
| if ((($lw - ($lw | round)) | fabs) < 0.0001)
and ((($lh - ($lh | round)) | fabs) < 0.0001)
then $rawScale else null end
end) as $scale
| ($e.transform | whole
| if . != null and . >= 0 and . <= 3 then . else null end) as $transform
# Legacy records carry no layout fields at all and keep automatic
# placement. A record that carries SOME of them and gets one wrong is
# refused outright rather than half-honoured.
| (($e.x != null) or ($e.y != null) or ($e.primary != null)) as $hasLayout
| ($e.x | whole | if . != null and . >= -100000 and . <= 100000
then . else null end) as $x
| ($e.y | whole | if . != null and . >= -100000 and . <= 100000
then . else null end) as $y
| ($hasLayout
and ($x == null or $y == null or ($e.primary | type) != "boolean")) as $layoutBroken
# ── Extended fields: each one drops on its own ───────────────────────
| ($e.bitdepth | if . == 8 or . == 10 then . else null end) as $bitdepth
| ($e.colorProfile
| if . == "auto" or . == "srgb" or . == "wide" or . == "hdr"
then . else null end) as $cm
# -1 means "follow the global VRR policy", which is said by leaving the
# key out; 3 is the global policy value and not a per-display choice.
| ($e.vrrMode | whole | if . != null and . >= 0 and . <= 2
then . else null end) as $vrr
# Neutral is 1.0 and is left out rather than written: naming it pins the
# display to it, which is not the same as leaving the trim alone.
| ($e.sdrBrightness | numeric
| if . != null and . >= 0.8 and . <= 2.0 and (((. - 1) | fabs) >= 0.001)
then . else null end) as $sdrBrightness
| ($e.sdrSaturation | numeric
| if . != null and . >= 0.8 and . <= 1.2 and (((. - 1) | fabs) >= 0.001)
then . else null end) as $sdrSaturation
# A mirror needs a target that is not itself and not another mirror --
# Hyprland has no chain to follow -- and the primary may not mirror at
# all, since the arrangement is anchored on it.
| ($e.mirrorOf
| if type == "string" and . != "" and . != $name
and test("^[A-Za-z0-9_.-]+$")
then . else null end) as $mirrorName
| (if $mirrorName == null or $e.primary == true then null
else ($root.displays[$mirrorName] // null) as $target
| if ($target | type) == "object"
and ($target.mirrorOf | type) == "string"
and $target.mirrorOf != ""
then null else $mirrorName end
end) as $mirror
# A mirror shows its target picture in its target place, so the saved
# position is not ours to ask for.
| (if $mirror != null then "auto"
elif $hasLayout then "\($x)x\($y)"
else "auto" end) as $position
| if $mode == null or $scale == null or $transform == null or $layoutBroken
then ""
else ([ "mode = \"\($mode)\"",
"position = \"\($position)\"",
"scale = \($scale)",
"transform = \($transform)" ]
+ (if $bitdepth == null then [] else ["bitdepth = \($bitdepth)"] end)
+ (if $cm == null then [] else ["cm = \"\($cm)\""] end)
+ (if $vrr == null then [] else ["vrr = \($vrr)"] end)
+ (if $sdrBrightness == null then []
else ["sdrbrightness = \($sdrBrightness)"] end)
+ (if $sdrSaturation == null then []
else ["sdrsaturation = \($sdrSaturation)"] end)
+ (if $mirror == null then [] else ["mirror = \"\($mirror)\""] end)
) | join(", ")
end
end
' "$settings" 2>/dev/null || true
}
# Re-enable on open, restoring what the person chose for this panel in Settings
# -- the whole record, not the three fields that used to survive -- and falling
# back to the panel's preferred mode when there is no usable record.
cmd_open() {
"$HW" laptop || exit 0
local internal entry mode scale
local internal rule
internal="$(internal_connector)"
[[ -n "$internal" ]] || exit 0
entry="$(jq -c --arg name "$internal" '.displays[$name] // empty' \
"${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json" 2>/dev/null)"
if [[ -n "$entry" ]]; then
mode="$(jq -r '.mode' <<<"$entry")"
scale="$(jq -r '.scale' <<<"$entry")"
hyprctl eval "hl.monitor({ output = \"$internal\", mode = \"$mode\", position = \"auto\", scale = $scale })" >/dev/null
else
hyprctl eval "hl.monitor({ output = \"$internal\", mode = \"preferred\", position = \"auto\", scale = \"auto\" })" >/dev/null
fi
# The connector name is interpolated into Lua too, and it comes from
# hyprctl rather than from us. Same class monitors.lua accepts.
[[ "$internal" =~ ^[A-Za-z0-9_.-]+$ ]] || exit 0
rule="$(monitor_rule "$internal")"
[[ -n "$rule" ]] || rule='mode = "preferred", position = "auto", scale = "auto"'
hyprctl eval "hl.monitor({ output = \"$internal\", $rule })" >/dev/null
}
case "${1:-status}" in
@@ -124,8 +260,9 @@ usage: panama-lid [status|guard|close|open]
connected; exits immediately on a machine that needs none
close docked lid closed: turn the internal panel off (bound to the lid
switch by keybinds.lua); does nothing undocked
open lid opened: turn the internal panel back on, restoring the mode
and scale chosen in Settings
open lid opened: turn the internal panel back on, restoring the whole
display record chosen in Settings -- position, mode, scale,
transform, and the colour, VRR and mirror fields when they are set
USAGE
;;
*) printf 'panama-lid: unknown command: %s\n' "$1" >&2; exit 2 ;;
@@ -52,6 +52,21 @@ Singleton {
property int chargeLimit: 0
property bool chargeLimitSupported: false
// How much of its design capacity the pack still holds, as a whole
// percent, and how many full cycles it has been through.
//
// Both are `null` rather than 0 on the very many machines whose firmware
// does not report them -- every desktop, and a fair number of laptops that
// export cycle_count as a permanent zero. Null is the value the Power page
// renders as an em dash; a zero would render as a dead battery that has
// never been charged, which is the same class of lie as the time-to-empty
// estimate this service deliberately does not compute.
//
// `var` rather than `int` so that null survives: an int property would
// coerce it to 0 and put the lie back.
property var healthPercent: null
property var cycleCount: null
readonly property bool charging: root.status === "Charging"
readonly property bool low: root.available && !root.acOnline
&& root.percent <= Settings.batteryLowPercent
@@ -81,6 +96,20 @@ Singleton {
thresholdFile.reload();
}
// Wear, read through the helper rather than from sysfs directly.
//
// It is the one reading here that needs arithmetic across a variable set of
// files -- energy_full or charge_full, against a design capacity that may
// not exist, summed over however many packs the machine has -- which is
// exactly what the helper already does for `status`. Kept off the 20-second
// poll: health moves over months and cycles over days, so this runs once
// when the paths resolve and again whenever the Power page asks.
function refreshHealth(): void {
if (root.batteryPath === "" || healthQuery.running)
return;
healthQuery.running = true;
}
function setChargeLimit(percent: int): void {
if (!root.chargeLimitSupported || applyLimit.running)
return;
@@ -118,9 +147,33 @@ Singleton {
}
if (root.batteryPath === "") {
root.available = false;
root.healthPercent = null;
root.cycleCount = null;
return;
}
root.refresh();
root.refreshHealth();
}
}
}
Process {
id: healthQuery
command: [root.helperPath, "status"]
stdout: StdioCollector {
onStreamFinished: {
try {
const state = JSON.parse(this.text);
// Anything that is not a number -- null, absent, a string
// from a future field -- is "this machine does not say".
root.healthPercent = typeof state.healthPercent === "number"
? state.healthPercent : null;
root.cycleCount = typeof state.cycleCount === "number"
? state.cycleCount : null;
} catch (error) {
root.healthPercent = null;
root.cycleCount = null;
}
}
}
}
@@ -149,6 +202,10 @@ Singleton {
onLoadFailed: {
root.available = false;
root.located = false;
// The pack that these described is gone; keep no wear figures for
// a battery that is no longer there.
root.healthPercent = null;
root.cycleCount = null;
}
}
@@ -24,6 +24,28 @@ import QtQuick
import qs.config
Singleton {
// Whether logind says this machine can resume from disk. Probed once --
// the answer changes only when swap is reconfigured. The power menu keeps
// its own copy of this probe for its Hibernate entry; this one exists so
// the Power page can state the machine's answer rather than describing
// the menu's behavior from a distance.
property bool canHibernate: false
property bool canHibernateKnown: false
Process {
id: hibernateProbe
command: ["busctl", "call", "org.freedesktop.login1",
"/org/freedesktop/login1", "org.freedesktop.login1.Manager",
"CanHibernate"]
running: true
stdout: StdioCollector {
onStreamFinished: {
canHibernate = this.text.includes('"yes"');
canHibernateKnown = true;
}
}
}
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-idle"
@@ -35,6 +57,49 @@ Singleton {
readonly property bool busy: statusQuery.running || applyRun.running
// What is holding sleep or idle off right now: an array of
// { who, why, what, mode }, newest read wins, blocks sorted before delays.
//
// hypridle has no conditional listener -- there is no way to say "not while
// a video is playing" -- so rather than inventing rules about when not to
// sleep, the Power page shows who is already saying it. `mode` is the part
// that matters when reading the list: a `delay` inhibitor holds sleep for a
// few seconds on the way down and nothing more, while a `block` is what
// actually keeps a machine awake. NetworkManager, UPower and hypridle
// itself hold delays permanently, so a list that did not distinguish them
// would report a machine as pinned awake at all times.
property var inhibitors: []
// False until logind has actually answered. "Nothing holds the machine
// awake" and "nobody could be asked" are different claims and the page
// must not make the first one on the strength of the second.
property bool inhibitorsKnown: false
function refreshInhibitors(): void {
if (!inhibitorQuery.running)
inhibitorQuery.running = true;
}
Process {
id: inhibitorQuery
command: [root.helperPath, "inhibitors"]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.inhibitors = Array.isArray(parsed) ? parsed : [];
root.inhibitorsKnown = true;
} catch (error) {
// The helper prints nothing at all when logind could not
// be asked, precisely so this lands here rather than
// parsing an empty array and believing it.
root.inhibitors = [];
root.inhibitorsKnown = false;
}
}
}
}
// The values as stored. They only describe what is running when `managed`.
readonly property int blankMinutes: DesktopPreferences.get("screenBlankMinutes")
readonly property int lockMinutes: DesktopPreferences.get("lockMinutes")
@@ -34,6 +34,7 @@ Singleton {
"datetime": "datetime",
"battery": "power",
"idleBattery": "power",
"power": "power",
"typography": "appearance",
"themes": "appearance",
"titlebar": "appearance",
@@ -148,6 +149,18 @@ Singleton {
{ label: "Performance overlay", detail: "Frame rate and sensors on top of the game", page: "gaming" },
{ label: "Proton", detail: "Compatibility tools available to Steam", page: "gaming" },
{ label: "Graphics card", detail: "Temperature, power draw, and video memory", page: "gaming" },
// Power & Lock. The sliders come from the schema, but the subjects
// people arrive with are the physical ones — a button, a lid, sleep —
// and none of those is the label of a preference. Hibernate is the
// sharpest case: it has no control at all, only an honest row saying
// why this machine will not do it, and a search that found nothing
// would read as the desktop having no opinion.
{ label: "Hibernate", detail: "Whether this machine can hibernate, and why zram swap means it does not", page: "power" },
{ label: "Power profile", detail: "Power saver, balanced, or performance, applied system-wide", page: "power" },
{ label: "Power button", detail: "What pressing it does: the power menu, suspend, power off, or nothing", page: "power" },
{ label: "Lid", detail: "What closing the lid does, and why an external display changes it", page: "power" },
{ label: "Suspend", detail: "When the machine sleeps on its own, on wall power and on battery", page: "power" },
{ label: "Sleep", detail: "The idle timeline: screen off, then lock, then suspend", page: "power" },
{ label: "Software update", detail: "Packages, applications, and firmware", page: "updates" },
{ label: "Updates", detail: "What is waiting to be installed", page: "updates" },
{ label: "Firmware", detail: "Updates for the hardware itself", page: "updates" },
+8 -1
View File
@@ -106,7 +106,7 @@ ShellRoot {
CaptureOverlay {}
IntelligenceResult {}
ActivityPanel {}
PowerMenu {}
PowerMenu { id: powerMenu }
SettingsWindow { id: settingsWindow }
// Toasts are their own always-on layer; they must be able to appear
@@ -606,6 +606,13 @@ ShellRoot {
IpcHandler {
target: "powermenu"
function toggle(): void { ShellState.toggle("powermenu"); }
// Open with one entry pre-armed, by id: lock, logout, suspend,
// hibernate, restart, poweroff. The power-button bind uses this for
// its "Powers off" setting -- the first press opens the menu with
// Power Off armed and the second press is the menu's own confirm, so
// nothing here is a shortcut past it. An id this machine has no entry
// for just opens the menu.
function open(entry: string): void { powerMenu.preselect(entry); }
}
IpcHandler {