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
+46 -5
View File
@@ -472,11 +472,52 @@ bind("XF86RFKill", hl.dsp.exec_cmd(osd("airplane toggle")), { locked = true, des
-- displays are actually arranged.
bind("XF86Display", hl.dsp.exec_cmd("qs ipc call settings page displays"), { description = "Display settings" })
-- The power button. logind is told to ignore it (config/copy ships the
-- drop-in) so a stray press is a question, not an instant poweroff -- the
-- menu this opens is the question. Until the next boot after that drop-in
-- lands, logind still acts on the key; this bind costs nothing extra then.
bind("XF86PowerOff", hl.dsp.exec_cmd("qs ipc call powermenu toggle"), { locked = true, description = "Power menu" })
-- ── The power button ────────────────────────────────────────────────────────
category("Media & hardware")
--
-- logind is told to ignore the power key (config/copy ships the drop-in) so a
-- stray press is a question, not an instant poweroff. That makes what the
-- question IS Panama's to choose, and `powerButtonAction` is where the choice
-- is recorded. Until the next boot after that drop-in lands, logind still acts
-- on the key; this bind costs nothing extra then.
--
-- The branch runs ON EVERY PRESS rather than here at config time.
--
-- A config-time branch would be shorter -- `prefs.get` and four `if`s -- and it
-- would also make this the one control on the Power page that does nothing
-- until the compositor is reloaded. Every other setting in Panama applies as
-- you change it, and a power button that ignores what the settings app says it
-- does is a worse thing to ship than a long command string. So the bind is a
-- `case` over what the settings file says at the moment the key goes down.
--
-- Everything that can go wrong lands on the shipped default: no jq, no file, a
-- truncated file, or a value nobody recognises all fall through to `*)` and
-- open the menu. The failure direction is "the power button opens a menu",
-- never "the power button does something you did not ask for".
--
-- Powering off goes THROUGH the menu with Power Off pre-armed rather than
-- calling `systemctl poweroff` here. The menu's two-press confirm is what
-- stands between a pocketed key and an unsaved afternoon, and a direct
-- poweroff would quietly throw it away -- so a person who picks "Powers off"
-- gets a fast poweroff, not an unguarded one.
local power_button = {
menu = qs("powermenu", "toggle"),
suspend = "systemctl suspend",
poweroff = qs("powermenu", "open") .. " poweroff",
nothing = ":",
}
local power_button_command = table.concat({
[[case "$(jq -r '.powerButtonAction // empty' "${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json" 2>/dev/null)" in]],
"suspend) " .. power_button.suspend .. " ;;",
"poweroff) " .. power_button.poweroff .. " ;;",
"nothing) " .. power_button.nothing .. " ;;",
"*) " .. power_button.menu .. " ;;",
"esac",
}, " ")
bind("XF86PowerOff", hl.dsp.exec_cmd(power_button_command),
{ locked = true, description = "Power button" })
-- The lid, as a switch rather than a key. Closing a docked lid turns the
-- internal panel off so nothing renders inside a closed shell and no
@@ -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 {
@@ -5,6 +5,6 @@
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Power & Lock in Settings.
# @vicinae.keywords ["settings", "warn at", "urgent at", "at the urgent level", "stop charging at", "turn the screen off after", "lock the screen after", "suspend after", "lock before sleeping", "lock after"]
# @vicinae.keywords ["settings", "warn at", "urgent at", "at the urgent level", "stop charging at", "turn the screen off after", "lock the screen after", "suspend after", "lock before sleeping", "lock after", "pressing the power button", "hibernate", "power profile"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page power