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
+1 -1
View File
@@ -151,7 +151,7 @@ docs/ Settings reference, and the design specs behind the work
## Tests
173 of them, under `tests/`. Run the lot, or a subset by pattern:
174 of them, under `tests/`. Run the lot, or a subset by pattern:
```sh
panama test # everything
+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
+9 -1
View File
@@ -4,7 +4,7 @@
Do not edit this file. Run `quickshell/scripts/panama-settings-docs`
after changing the schema; a contract fails when this copy is stale.
172 settings across 35 groups. 77 of them are applied to the compositor and confirmed by reading the value back.
173 settings across 36 groups. 77 of them are applied to the compositor and confirmed by reading the value back.
## accessibility
@@ -289,6 +289,14 @@ Found on **Input Mouse & Touchpad**.
| **Hide pointer while typing**<br>`cursorHideWhileTyping` `cursor:hide_on_key_press` | false | The pointer vanishes on the next keystroke and returns when you move it |
| **Jump pointer to the focused display**<br>`cursorWarpOnWorkspaceChange` `cursor:warp_on_change_workspace` | false | Moves the pointer to the last focused window after switching workspace |
## power
Found on **Power & Lock**.
| Setting | Default | What it does |
|---|---|---|
| **Pressing the power button**<br>`powerButtonAction` | menu | The system ignores the key; Panama decides — so a bumped button never yanks the plug Choices: Shows the power menu, Suspends, Powers off (two-press), Does nothing. |
## search
Found on **Applications Applications**.
@@ -1302,3 +1302,171 @@ documented").
one (`secrets-contract`, which lists the live keyring), then the harness ones
(`settings-search-contract`, `lock-screen-settings-contract`), and
`settings-pages-contract` last, as before.
## Phase 13 (Power & Lock) — append below
Spec: `2026-08-24-power-lock-redesign.md`. The Power page stopped being four
cards that each said a true thing and together said nothing. Two idle cards
holding the same three concepts became one, with a timeline drawn above the
sliders and the ordering warning rendered on it rather than appearing as a
fifth card. The power button became adjustable without root — logind keeps
`HandlePowerKey=ignore`, and the compositor bind branches on a new
`powerButtonAction` preference. The lid's `open` stopped clobbering the panel's
configuration. Battery grew health, and the idle card grew a list of what is
holding the machine awake.
Three agents edited the tree concurrently; everything below was reconciled
against the landed files rather than against the spec's pinned shapes.
### New contracts (1)
`quickshell/power-page-contract`. The README count line moves **173 → 174**;
`setup/readme-contract` was run and passes ("174 contracts, as documented").
The lid emission fix went into the EXISTING `setup/lid-contract` as a fifth
section rather than into a file of its own. It drives the same helper the other
four sections drive, through the same stub directory, and splitting it out
would have meant a second copy of that scaffolding for no separation anyone
benefits from.
### Run and passing
- **`setup/lid-contract` — RUN END TO END, PASS.** Hermetic throughout: a
stubbed `panama-hw` answers the predicates, a stubbed `hyprctl` answers the
monitor query from a fixture and records the rule instead of applying it, and
`XDG_CONFIG_HOME` points at a scratch settings store. Nothing reached the
compositor. The four existing sections are unchanged and still pass. The new
one pins:
- **The full record survives the round trip.** A fixture panel with mode,
scale, transform, stored x/y, VRR, bit depth, colour profile and both SDR
trims comes back as an `hl.monitor` rule carrying every one of them, with
`position` built from the stored coordinates rather than `"auto"`.
- **The old four-key emission fails it**, verified by running the check
against the pre-fix `cmd_open` before A's rewrite landed: eight findings,
naming the position and every extended field it dropped.
- **The two failure directions stay different**, which is the part worth a
contract. A bad colour profile, VRR, bit depth or SDR value drops on its
own and the geometry stands; a bad transform, scale or mode refuses the
WHOLE record and the panel comes back on `mode = "preferred"`, never
half-honoured. Each of the three geometry fixtures was confirmed to be
valid JSON differing from the good one in exactly one field, so none of
them passes by failing to parse.
- **No stored coordinates means `position = "auto"`**, and a half-written
position (an `x` with no `y`) is refused the same way `monitors.lua`
refuses it.
- **`null` never reaches the rule** — jq prints an absent key as the string
"null", and a rule carrying one is the shape this whole fix exists to avoid.
- **It stays `hyprctl eval`, never `keyword`**, so even a wrong rule dies at
the next reload.
- **`quickshell/battery-contract` — RUN END TO END, PASS.** Existing pins
unchanged; still driven against fixture sysfs trees. New: health is read
from `energy_full`/`energy_full_design` AND from the `charge_` spelling (a
driver that reports charge is not a machine without a battery), the cycle
count is carried through, and — the half that matters — a battery whose
firmware reports neither gets `null`, not a confident `100%` and `0 cycles`
on hardware that never said. Checked four ways: the laptop fixture without
the files, the desktop fixture with no battery at all, a zero design capacity
that would otherwise divide by it, and the JSON staying parseable in every
one of those cases.
- **`quickshell/power-page-contract` — RUN END TO END, PASS.** Static
apart from one stubbed run: `panama-idle inhibitors` against a fake `busctl`,
which is the only part of the row nothing else covers. What it pins:
- **The power button cannot power the machine off.** Said three ways, because
this is the assertion the redesign turns on: no `systemctl poweroff` on the
bind path (comments stripped first — keybinds.lua explains at length that it
deliberately does not, and a contract must not fail over prose that agrees
with it); the powermenu IPC handler in `shell.qml` runs no command of its
own; and the menu still disarms its destructive entries, which is what makes
powering off two presses.
- **Every option the schema offers is branched on**, with the option values
read OUT of the schema rather than restated — a contract listing the four
would pass on the day a fifth was added and did nothing. Plus: the fallback
arm opens the menu, so a hand-edited settings file cannot decide what the
power key does, and the bind stays `locked = true`.
- **`powerButtonAction` carries no `hypr` block**: it is read by keybinds.lua
the way workspace rules are, and a hypr block would send Hyprland a keyword
that does not exist.
- **One idle card, structurally.** Not by title — the AC and battery sliders
for each of the three timings must live inside the SAME `SettingsCard`
block, sliced by brace depth. A rename cannot satisfy this and a second
card cannot hide from it.
- **The inhibitor list is filtered, ordered, and honest about failure.** A
`shutdown` hold says nothing about whether the screen will blank and is
dropped; sleep, idle and handle-lid-switch holds are kept, Panama's own lid
inhibitor included; `delay` inhibitors sort below `block` ones, because a
delay does not keep a machine awake; and a logind that cannot be reached
exits non-zero WITHOUT printing `[]`, so "could not look" never reads as
"nothing is holding it". The ordering assertion was checked against a
delay-first list to confirm it discriminates.
- **The page's own components count as the page.** The file list is derived —
PowerPage.qml plus every settings component it instantiates that no other
settings page does — so a card lifted into a component of its own keeps
being checked instead of quietly falling out of scope.
- **`hypr/idle-config-contract`, `hypr/idle-defaults-contract`,
`quickshell/powermenu-contract` — RUN, PASS, unchanged.** Verified rather
than assumed: the `inhibitors` verb is additive (it adds no `read_setting`
call, which is what `idle-defaults-contract` walks) and the powermenu's
pre-select argument left `logoutScript` alone.
- **`quickshell/search-routing-contract` — RUN, PASS** (145 routed settings,
up from 144: `powerButtonAction` is the new one). Also RUN and passing:
`schema-hypr-shape-contract` (77 mapped options), `enum-hypr-map-contract`
(11 mapped enums), `preference-schema-contract`,
`settings-ownership-contract`, `setup/readme-contract`.
- **Schema docs and Vicinae commands regenerated and passing.**
`quickshell/scripts/panama-settings-docs` and `panama-settings-commands` were
both run without `--check` and their output committed:
`docs/settings.md` goes 172 → **173 settings across 36 groups**, with the new
`power` group rendering as "Found on **Power & Lock**" — the group comment
shape the spec asked C to verify, which comes from the `"power": "power"`
route added to `SettingsSearch.qml`. `settings-power`'s Vicinae keywords
gained "pressing the power button", "hibernate" and "power profile".
`quickshell/settings-docs-contract` — RUN, PASS (173 settings documented);
`quickshell/panama-commands-contract` — RUN, PASS (77 commands).
- **Six search entries added**: Hibernate, Power profile, Power button, Lid,
Suspend, Sleep, all routing to `power`, plus the `"power": "power"` group
route for the new schema group. Hibernate is the one worth having: 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.
### Deferred, and why
- **`quickshell/settings-search-contract` — NOT RUN**: it daemonizes a
Quickshell harness. The six new entries were checked statically and route to
`power`, which `settings-nav-contract` confirms is a leaf.
- **`quickshell/settings-pages-contract`, `settings-write-sweep-contract`,
`qmldir-registration-contract`, `settings-buttons-contract`,
`settings-nav-contract`, `settings-jump-contract`,
`settings-hardcoded-values-contract` — NOT RUN**: they load the QML or drive
the live settings window. The Power page was rebuilt this phase and needs all
of them at the end-of-redesign sweep.
- **`quickshell/lock-screen-settings-contract` — NOT RUN** (same harness reason
as phase 12); its source-only half still expects `lockMinutes`,
`lockMinutesBattery` and `lockOnSleep` bound on Power, which the rebuilt page
keeps.
- **One thing the two agents disagree about, left as it landed.** A's
`IdleLock` exposes `inhibitorsKnown` beside `inhibitors`, so a logind that
could not be reached is distinguishable from one holding nothing — the
helper's own contract pins that distinction, and the verb exits non-zero
without printing `[]` precisely so the caller can tell. B's page does not
read it: an empty list renders as "Nothing — no application holds a wake
lock" whether the probe came back empty or never came back, which the page
argues in a comment is also the honest thing to say before the first reading
lands. That is defensible for the first-read case and wrong for the failure
case, and it is a copy decision rather than a wiring one, so it is reported
rather than pinned. Worth a look on the rendered page.
- **The whole live half of the power button.** Nothing here presses it. The
contract proves the bind branches and that no branch powers off; whether the
compositor picks the new bind up needs a `hyprctl reload`, which this phase
deliberately did not run.
- **Hibernate is dormant on this machine.** `CanHibernate` answers no under
zram swap, so the gated row is pinned by its wiring rather than by having
been seen.
- **Battery health is dormant too**, for the same reason the rest of the
battery surface is: this is a desktop. Every health assertion runs against a
fixture sysfs tree.
- Run order for this phase: the hermetic ones first (`lid-contract`,
`battery-contract`, `power-page-contract`, `idle-config-contract`,
`idle-defaults-contract`), then the source-only ones (`powermenu-contract`,
`search-routing-contract`, `schema-hypr-shape-contract`,
`preference-schema-contract`, `settings-docs-contract`,
`readme-contract`), then the harness ones last.
@@ -0,0 +1,140 @@
# Power & Lock redesign — one timeline, honest sleep
Approved mock: `home-mocks/power.html` (scratchpad, :8642). Spec wins over mock on conflict.
Single tabless page stays.
## Goals
1. **Idle timeline**: one visualization (Active → Screen off → Lock → Suspend) above the
sliders, switching wall-power/battery by live source; the ordering warnings become part of
the picture. A "Keeping the machine awake right now" row lists live inhibitors.
2. **Power profile tiles** (existing service; presentation only, "via tuned" honesty).
3. **Battery health**: design-capacity %, charge cycles, and a charge-stop readback tile —
dormant here, real on the laptop.
4. **Power button adjustable without root**: logind keeps `HandlePowerKey=ignore`; the
compositor bind reads a new preference. Options: Shows the power menu (default) / Suspends /
Powers off (two-press: opens the power menu with Power Off pre-selected, so the second press
fires it) / Does nothing.
5. **Honesty rows**: lid card presents its inhibitor design; hibernate row states the zram
reason, gated on the live `CanHibernate` probe; Management shows the generated path.
6. **The `panama-lid` clobber dies**: `cmd_open` emits the full stored record (position from
stored x/y when present, scale, transform, vrr, bitdepth, cm, sdr fields — conditional,
mirroring `monitors.lua`'s validation) instead of the 4-key rule that wipes extended fields.
Non-goals: lid behavior override (deliberately read-only, per LidPolicy's argued design),
scheduled shutdown, suspend-then-hibernate, time-to-empty (deliberate omission stays), per-app
power, conditional suspend rules (hypridle has no conditional listener — the inhibitor row is
the honest substitute).
## Schema (A)
New group `power` (groupPages → `power`): `powerButtonAction` — enum, def `"menu"`, values
`menu | suspend | poweroff | nothing`, label "Pressing the power button", detail per mock. No
hypr block (consumed by keybinds.lua via prefs, like workspace rules — reload-applied is fine
for a power-button preference; if the keybind can read it live via a dispatcher branch,
better — A investigates which pattern keybinds.lua supports and documents the choice).
**Settled (A): per-press, no reload.** The bind is `hl.dsp.exec_cmd` of a one-line shell
`case` over `jq -r '.powerButtonAction // empty'` against the settings file, so the branch is
evaluated when the key goes down rather than when the config is read. All four values branch
inside keybinds.lua; a missing jq, missing file, malformed file or unknown value falls through
to `*)` and opens the menu. `prefs.get` was rejected because prefs.lua loads the store once at
config time, which would have made this the one Power-page control that needs `hyprctl reload`.
## Plumbing (A)
- **`config/dot/hypr/keybinds.lua`**: the `XF86PowerOff` bind branches on the preference:
menu → existing power-menu IPC; suspend → `systemctl suspend`; poweroff → power-menu IPC
with a `poweroff` preselect argument; nothing → no-op bind (still `locked = true`).
- **`shell.qml` + `modules/powermenu/PowerMenu.qml`**: the powermenu IPC accepts an optional
entry id to pre-select (arming Power Off so one more press fires — the existing two-press
semantics preserved); no other menu behavior changes.
- **Settled (A).** Each entry gains a stable `entryId`: `lock | logout | suspend |
hibernate | restart | poweroff`. `qs ipc call powermenu open <entryId>` calls
`PowerMenu.preselect(entryId)`, which opens the menu and calls the entry's own
`trigger()` — the same one a click calls — so a destructive entry arms on the first
press and fires on the second, inside the existing 4-second confirm window. Nothing
bypasses the confirm; an id this machine has no entry for (hibernate without swap) just
opens the menu. `toggle` is unchanged.
- **`scripts/panama-idle`**: new `inhibitors` verb → `[{ who, why, what, mode }]`
(sleep/idle/handle-lid-switch holds only, Panama's own lid inhibitor included honestly);
`services/IdleLock.qml` exposes `inhibitors` + `inhibitorsKnown` + `refreshInhibitors()`,
which B calls on page open.
- **Settled (A), two additions to the pinned shape.** Source is logind's `ListInhibitors`
over `busctl --json=short`, not `systemd-inhibit --list`: that table is padded display
output whose `why` column contains spaces and whose `who` column is a free string the
inhibiting program picks, so no column split is reliable. And each row carries `mode`,
because `delay` and `block` are not the same claim — NetworkManager, UPower and hypridle
hold permanent `delay` holds on every machine, and a list that did not distinguish them
would report the desktop as pinned awake at all times. **Only `block` keeps a machine
awake; B should read the empty state off the blocks, not the row count.** Rows sort blocks
first, then by `who`. On failure the verb prints NOTHING and exits non-zero (never `[]`),
which is what `inhibitorsKnown` is derived from.
- **`scripts/panama-battery`**: `status` gains `healthPercent` (energy_full/energy_full_design
or charge_ equivalents, summed across packs), `cycleCount` — both JSON `null` when sysfs
lacks them, and a reported `cycle_count` of 0 counts as absent rather than as a new battery;
`services/Battery.qml` exposes `healthPercent`/`cycleCount` as `property var` defaulting to
`null` (an `int` property would coerce null back to 0), filled by a new `refreshHealth()`
that runs once when the sysfs paths resolve and again whenever the page asks — never on the
20-second poll.
- **`scripts/panama-lid`**: `cmd_open` reads the stored record's full field set via jq and
emits `hl.monitor({...})` with every valid field, conditional per key, position from stored
x/y when both present else `"auto"` — validation mirroring `monitors.lua` (regex-safe values
only; invalid extended fields drop per-field, geometry survives). Header's "NOT YET VERIFIED
ON A LAPTOP" honesty stays.
## UI (B)
`PowerPage.qml` rebuilt per mock: Power profile tiles (icon, label, one-line detail, active
highlight, degraded note); Idle card (NEW `IdleTimeline.qml`: proportional stops on a
piecewise scale with labels, driven by the same schema values — draggable with snap-to-step
if robust, read-only visualization otherwise [flag which]; the source header "on wall power" /
"on battery" from `Battery.acOnline`; the battery-variant sliders swap in below when on
battery — one card, not two; ordering warnings rendered inline on the timeline rather than a
separate card); Lock before sleeping toggle; inhibitors row ("Nothing — no application holds a
wake lock" empty state); Battery card (health tiles + charge-stop readback + existing
low/critical/action rows; charge-limit slider keeps firmware-confirm detail); Lid card
(read-only, DOCKED/WILL SUSPEND badge from LidPolicy, the inhibitor-design prose); Power
button card (OptionPickerRow on `powerButtonAction`, the mock's detail); Session card (Lock
now — through a service or execDetached as today, hibernate row gated `!canHibernate` showing
the zram reason [read the live probe from PowerMenu's existing CanHibernate source — lift it
into a small service property if needed], power menu pointer row); Management card (managed
toggle + `IdleLock.generatedPath` + service state in the detail).
## Search & docs (C)
Extra entries: Hibernate, Power profile, Power button, Lid, Suspend, Sleep → power. Schema
regen: docs + commands after the new key (orchestrator can also run it; C verifies the group
comment shape).
## Contracts (C — write; hermetic runs only)
- NEW `power-page-contract`: page structure (profile tiles, IdleTimeline present and fed by
the schema keys, one idle card not two, inhibitors row with empty state, hibernate row gated
on the live probe with the zram copy, power button row bound to `powerButtonAction`,
generatedPath rendered); `powerButtonAction` consumed in keybinds.lua (all four values
branch); the poweroff branch goes through the power menu (никогда a direct poweroff — pin
that `systemctl poweroff` appears nowhere in the bind path).
- `lid-contract` / a new static half: fixture settings.json with extended display fields →
the generated `hl.monitor` line carries them; invalid values drop per-field; stored x/y
become position; absent → auto. The old 4-key emission must FAIL it.
- `battery-contract`: health fields present when sysfs provides them, absent-tolerant when
not (desktop fixture), never fabricated.
- `idle-config-contract`/`idle-defaults-contract`: verify unaffected (inhibitors verb is
additive).
- Backlog Phase 13; README count (173 → 174 expected).
## Agent ownership (parallel)
- **A**: `config/PreferenceSchema.qml`, `config/dot/hypr/keybinds.lua`, `shell.qml`,
`modules/powermenu/PowerMenu.qml`, `scripts/panama-idle`, `scripts/panama-battery`,
`scripts/panama-lid`, `services/IdleLock.qml`, `services/Battery.qml`.
- **B**: `modules/settings/PowerPage.qml`, NEW `IdleTimeline.qml`, other components
(+ qmldir).
- **C**: `services/SettingsSearch.qml`, contracts above, backlog, README count line.
Hard rules: NO live mutations — no systemctl suspend/poweroff, no logind writes, no threshold
writes, no hyprctl keyword/eval, no powermenu triggering; keybinds.lua edits must keep
`hyprctl reload` UNRUN (the user reloads naturally later — but the shell hot-reloads QML, so
PowerMenu/shell.qml edits land live and must stay valid). Read-only probes and stubs only.
B programs against the pinned APIs; A updates this spec before changing them.
+102 -3
View File
@@ -15,6 +15,12 @@
# 3. The charge-limit control appears only where the firmware has one.
# 4. The threshold write goes through panama-sudo with a reason, never bare
# sudo, and is read back rather than assumed.
# 5. Health -- design-capacity percentage and charge cycles -- is reported
# where sysfs reports it and is SILENT where it does not. Most of these
# files are optional and plenty of firmware omits them, so the tempting
# failure is a tile reading "100% of design capacity, 0 cycles" on a
# three-year-old battery that simply never said. That is worse than no
# tile: it is a confident wrong answer about whether hardware is dying.
#
# The helper is driven against fixture sysfs trees; the QML side is pinned
# statically, since a battery cannot be simulated into the running shell.
@@ -122,17 +128,110 @@ PANAMA_HW_SYS="$limited/sys" PANAMA_PATH="$repo_dir" "$helper" set-threshold 10
PANAMA_HW_SYS="$limited/sys" PANAMA_PATH="$repo_dir" "$helper" set-threshold abc >/dev/null 2>&1 \
&& note 'a non-numeric threshold was accepted'
# ── 5. The QML side hides itself ─────────────────────────────────────────────
# ── 5. Health, where the firmware reports it ─────────────────────────────────
#
# Two sysfs spellings for the same fact, depending on whether the driver
# reports energy or charge. Both have to work, or half the laptops in the world
# get a blank tile.
with_health() {
local name="$1" prefix="$2" full="$3" design="$4" cycles="${5:-}"
local root
root="$(fixture "$name" 72 1)"
printf '%s\n' "$full" >"$root/sys/class/power_supply/BAT0/${prefix}_full"
printf '%s\n' "$design" >"$root/sys/class/power_supply/BAT0/${prefix}_full_design"
[[ -n "$cycles" ]] && printf '%s\n' "$cycles" >"$root/sys/class/power_supply/BAT0/cycle_count"
printf '%s\n' "$root"
}
# A pack that has lost a tenth of its design capacity, in energy units.
energy="$(with_health energy energy 45000000 50000000 312)"
status="$(ask "$energy" status)"
[[ "$(field "$status" .healthPercent)" == "90" ]] \
|| note "health is not computed from energy_full against energy_full_design (got $(field "$status" .healthPercent))"
[[ "$(field "$status" .cycleCount)" == "312" ]] \
|| note "the charge cycle count is not reported (got $(field "$status" .cycleCount))"
# The same pack, on a driver that reports charge rather than energy.
charge="$(with_health charge charge 45000000 50000000 312)"
status="$(ask "$charge" status)"
[[ "$(field "$status" .healthPercent)" == "90" ]] \
|| note 'health is not read from the charge_full spelling, so a driver that reports charge shows no health at all'
# A pack that reports neither. Absent, null, or zero -- anything but a number
# that looks like an answer.
plain="$(ask "$laptop" status)"
for key in healthPercent cycleCount; do
value="$(field "$plain" ".$key")"
case "$value" in
""|null|0) ;;
*) note "a battery whose firmware reports no $key was given one anyway ($value), which is a confident wrong answer about whether the hardware is dying" ;;
esac
done
# And a machine with no battery at all invents nothing.
desktop_status="$(ask "$desktop" status)"
for key in healthPercent cycleCount; do
value="$(field "$desktop_status" ".$key")"
case "$value" in
""|null|0) ;;
*) note "a machine with no battery reported a $key of $value" ;;
esac
done
# A design capacity of zero would divide by it. Firmware does report this.
zeroed="$(with_health zeroed energy 45000000 0)"
status="$(ask "$zeroed" status)"
value="$(field "$status" .healthPercent)"
case "$value" in
""|null|0) ;;
*) note "a zero design capacity produced a health percentage of $value" ;;
esac
# The status JSON stays parseable in every one of those cases -- an empty
# substitution would have made it valid-looking but wrong above, and invalid
# here.
for candidate in "$energy" "$charge" "$laptop" "$desktop" "$zeroed"; do
jq -e . >/dev/null 2>&1 <<<"$(ask "$candidate" status)" \
|| note 'status stopped emitting valid JSON once the health fields were added'
done
# The service carries them through, and null-safely: `null > 0` is false in
# QML, which is what makes an absent reading hide rather than render as an
# empty tile.
for property in healthPercent cycleCount; do
grep -q "property .*$property" "$service" \
|| note "the battery service does not expose $property, so the tile would read undefined"
done
# ── 6. The QML side hides itself ─────────────────────────────────────────────
grep -q 'property bool available' "$service" \
|| note 'the battery service has no availability flag'
grep -q 'Settings.showBattery && Battery.available' "$cluster" \
|| note 'the bar indicator does not gate on both the preference and the hardware'
grep -q 'visible: Battery.available' "$page" \
# The Power page and the components it is built from. A card lifted into a
# component of its own is a normal thing to do, and every assertion below would
# quietly stop meaning anything if it only ever read PowerPage.qml.
power_surface=("$page")
for candidate in "$(dirname "$page")"/{Power,Battery,Idle}*.qml; do
[[ -r "$candidate" && "$candidate" != "$page" ]] && power_surface+=("$candidate")
done
grep -q 'visible: Battery.available' "${power_surface[@]}" \
|| note 'the Power page battery card does not hide on a machine without one'
grep -q 'visible: Battery.chargeLimitSupported' "$page" \
grep -q 'visible: Battery.chargeLimitSupported' "${power_surface[@]}" \
|| note 'the charge limit control does not hide where the firmware has none'
# The health tiles follow the same rule as everything else here: a reading the
# firmware did not give is a tile that is not drawn.
for property in healthPercent cycleCount; do
grep -q "Battery.$property" "${power_surface[@]}" \
|| note "the Power page never shows $property, so the battery health the helper reads goes nowhere"
grep -qE "visible: .*Battery\.$property" "${power_surface[@]}" \
|| note "the $property tile is drawn unconditionally, so a battery that reports none shows an empty one"
done
# The alias layer has to carry the key, or the binding silently reads undefined
# and the indicator never appears. This exact mistake was made writing it.
for key in showBattery batteryLowPercent batteryCriticalPercent; do
+496
View File
@@ -0,0 +1,496 @@
#!/usr/bin/env bash
# Power & Lock: one timeline, one idle card, and a power button that cannot
# power the machine off on its own.
#
# The page was four cards that each said a true thing and together said nothing:
# two idle cards holding the same three concepts under different headings, an
# ordering warning that appeared as a fifth card, and two informational cards
# about hardware -- a lid and a button -- neither of which could be changed. The
# redesign turns the timings into one picture and makes the button adjustable.
#
# Three properties are worth a contract, in descending order of what it would
# cost to get them wrong:
#
# 1. THE POWER BUTTON NEVER POWERS OFF DIRECTLY. logind is told to ignore the
# key so a stray press is a question rather than an instant loss of
# unsaved work; the whole point of making the action a preference is that
# "Powers off" still means "opens the menu with Power Off armed, press
# again". A `systemctl poweroff` anywhere in the bind path silently undoes
# that, and the failure is invisible until somebody brushes the button.
# 2. One idle card, not two. The AC and battery timings are the same three
# concepts, and the old shape let them drift apart on screen -- a person
# could read "suspend after 20 minutes" off one card while the other one
# was in effect. Pinned structurally: the battery sliders live inside the
# same card as their wall-power counterparts, whatever the card is called.
# 3. Everything the page claims about this machine is read from something
# that asked it. Hibernate is gated on logind's own CanHibernate rather
# than on a guess, the inhibitor row is a live list with an empty state,
# and the generated hypridle path is shown rather than described.
#
# Static apart from one thing: the inhibitors verb is run against a stubbed
# `busctl`, so the row's filtering and ordering are exercised without asking
# the live session what is holding it awake. Nothing here starts a shell,
# presses a button, or reaches a real bus.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
shell_dir="$repo_dir/config/dot/quickshell"
settings="$shell_dir/modules/settings"
page="$settings/PowerPage.qml"
timeline="$settings/IdleTimeline.qml"
qmldir="$settings/qmldir"
schema="$shell_dir/config/PreferenceSchema.qml"
keybinds="$repo_dir/config/dot/hypr/keybinds.lua"
shell_qml="$shell_dir/shell.qml"
powermenu="$shell_dir/modules/powermenu/PowerMenu.qml"
idle_lock="$shell_dir/services/IdleLock.qml"
idle_helper="$shell_dir/scripts/panama-idle"
findings=()
note() { findings+=("$1"); }
for file in "$page" "$timeline" "$schema" "$keybinds" "$shell_qml" "$powermenu" \
"$idle_lock" "$idle_helper" "$qmldir"; do
[[ -r "$file" ]] || { printf 'power page contract: missing %s\n' "${file#"$repo_dir/"}" >&2; exit 1; }
done
# The page and the components only it uses. A card lifted into a component of
# its own is a normal thing to do while building this, and every assertion
# below about what the page shows would quietly stop meaning anything if it
# only ever read PowerPage.qml. Shared rows (SliderRow, SettingsCard, and the
# rest) are excluded by the same test that finds these: a component another
# settings page also instantiates is not part of this page's own structure.
mapfile -t page_files < <(python3 - "$page" "$settings" <<'PY'
import os
import re
import sys
page, settings = sys.argv[1], sys.argv[2]
source = open(page, encoding="utf-8").read()
others = [os.path.join(settings, name) for name in os.listdir(settings)
if name.endswith(".qml") and os.path.join(settings, name) != page]
other_text = "\n".join(open(path, encoding="utf-8").read() for path in others)
files = [page]
for name in sorted(set(re.findall(r"\b([A-Z][A-Za-z0-9]+) \{", source))):
candidate = os.path.join(settings, name + ".qml")
if not os.path.exists(candidate):
continue
if re.search(r"\b" + name + r" \{", other_text):
continue
files.append(candidate)
print("\n".join(files))
PY
)
# grep across the page and its own components, as one surface.
page_has() { grep -Fq "$@" "${page_files[@]}"; }
page_matches() { grep -Eq "$@" "${page_files[@]}"; }
# ── 1. The power button ──────────────────────────────────────────────────────
#
# The preference's option values are read out of the schema rather than
# restated here, so a fifth option cannot be added without the bind that has to
# handle it. A contract that hardcoded the four would pass on the day a fifth
# was added and did nothing.
mapfile -t options < <(python3 - "$schema" <<'PY'
import re
import sys
source = open(sys.argv[1], encoding="utf-8").read()
match = re.search(r'\{\s*\n\s+key: "powerButtonAction".*?\n\s{8}\}', source, re.S)
if not match:
raise SystemExit(0)
block = match.group(0)
for value in re.findall(r'value: "([a-z]+)"', block):
print(value)
PY
)
if (( ${#options[@]} == 0 )); then
note 'the schema has no powerButtonAction entry with option values, so the power button is not adjustable at all'
else
expected=$(printf '%s\n' menu nothing poweroff suspend)
actual=$(printf '%s\n' "${options[@]}" | sort)
[[ "$actual" == "$expected" ]] \
|| note "powerButtonAction offers [$(tr '\n' ' ' <<<"$actual")], expected menu / suspend / poweroff / nothing"
fi
python3 - "$schema" <<'PY' || note 'the powerButtonAction entry is not a power-group enum defaulting to the menu'
import re
import sys
source = open(sys.argv[1], encoding="utf-8").read()
match = re.search(r'\{\s*\n\s+key: "powerButtonAction".*?\n\s{8}\}', source, re.S)
if not match:
raise SystemExit(1)
block = match.group(0)
ok = 'group: "power"' in block and 'def: "menu"' in block and 'type: "enum"' in block
raise SystemExit(0 if ok else 1)
PY
# No hypr block. This preference is read by keybinds.lua through prefs, the way
# workspace rules are; a hypr block would send Hyprland a keyword that does not
# exist and the write would fail on every commit.
python3 - "$schema" <<'PY' && note 'powerButtonAction declares a hypr block, but there is no compositor keyword behind it -- the bind reads the preference'
import re
import sys
source = open(sys.argv[1], encoding="utf-8").read()
match = re.search(r'\{\s*\n\s+key: "powerButtonAction".*?\n\s{8}\}', source, re.S)
raise SystemExit(0 if match and "hypr:" in match.group(0) else 1)
PY
# The bind reads it, and every value the schema offers is handled.
#
# "The bind path" is keybinds.lua plus anything the power-key bind hands the
# decision to: whether the preference is read at config time through prefs or
# at press time by a helper is A's call, and a contract that insisted on one
# mechanism would have to be rewritten to allow the other. What must hold
# either way is that the preference is read somewhere on the path, that every
# option the schema offers is branched on there, and -- the one that matters --
# that nothing on the path can power the machine off.
bind_path=("$keybinds")
while IFS= read -r script; do
[[ -n "$script" ]] || continue
candidate="$shell_dir/scripts/$script"
[[ -r "$candidate" ]] && bind_path+=("$candidate")
done < <(grep -oE 'panama-[a-z-]+' "$keybinds" | sort -u)
grep -lq 'powerButtonAction' "${bind_path[@]}" \
|| note 'nothing the power-key bind reaches reads powerButtonAction, so the preference changes nothing'
# The region of keybinds.lua that decides what the power key does: from
# wherever the preference is first named through the end of the bind statement
# itself, in either order. Where the branching lives in a helper instead, that
# helper's whole text stands in for it.
region="$(python3 - "$keybinds" <<'PY'
import sys
lines = open(sys.argv[1], encoding="utf-8").read().splitlines()
marks = [i for i, line in enumerate(lines)
if "powerButtonAction" in line or "XF86PowerOff" in line]
if not marks:
raise SystemExit(0)
# The bind's option table can trail onto the following lines, and `locked` is
# in it. Run on to the end of the statement.
end = max(marks)
while end + 1 < len(lines) and "})" not in lines[end]:
end += 1
print("\n".join(lines[min(marks):end + 1]))
PY
)"
decision="$region"
for file in "${bind_path[@]:1}"; do
grep -q 'powerButtonAction' "$file" && decision+=$'\n'"$(cat "$file")"
done
if [[ -z "$region" ]]; then
note 'no XF86PowerOff bind and no powerButtonAction read: the power key is unbound'
else
# By the option's own name rather than by a quoting style: the branch may
# be a Lua field, a shell case label, or a string, and which one it is is
# A's business.
for option in "${options[@]}"; do
grep -qw "$option" <<<"$decision" \
|| note "the power key bind has no branch for the \"$option\" option, so choosing it would fall through to whatever the last branch does"
done
grep -q 'powermenu' <<<"$decision" \
|| note 'the power key bind never reaches the power menu, which is the default action'
grep -q 'systemctl suspend' <<<"$decision" \
|| note 'the power key bind cannot suspend, so the Suspends option does nothing'
grep -q 'locked = true' <<<"$region" \
|| note 'the power key bind is not locked, so it stops working on the lock screen -- the one place a power button press is most likely'
# An unreadable, absent or unrecognised value opens the menu. This is the
# difference between a corrupt settings file being harmless and it being a
# power button that does something nobody chose.
grep -qE '\*\)[^;]*\b(menu|powermenu)\b|else[^;]*\b(menu|powermenu)\b' <<<"$decision" \
|| note 'an unrecognised powerButtonAction value does not fall back to the power menu, so a hand-edited settings file decides what the power key does'
fi
# THE assertion. Powering off is a two-press flow through the menu; a direct
# poweroff would make the "Powers off" option lose unsaved work on one press.
# Asserted over the whole bind path, so moving the branching into a helper
# moves this check with it rather than out from under it.
#
# Comments are stripped first. This file explains at length that it deliberately
# does NOT call systemctl poweroff, and a contract must not fail over prose that
# agrees with it.
for file in "${bind_path[@]}"; do
sed -E 's/(^|[^:])--.*/\1/; s/^[[:space:]]*#.*//' "$file" \
| grep -qE 'systemctl[^|;&]*poweroff|loginctl[^|;&]*poweroff|\bshutdown -h\b' \
&& note "${file##*/} can power the machine off directly -- the poweroff option must open the power menu with Power Off armed, so a second press is required"
done
# And the same thing said from the shell's side: the IPC the bind calls only
# opens a menu. It must not run anything itself.
python3 - "$shell_qml" <<'PY' || note "the powermenu IPC handler runs a command of its own, so a single power-key press could act without the menu's second press"
import re
import sys
source = open(sys.argv[1], encoding="utf-8").read()
match = re.search(r'IpcHandler \{\s*\n\s+target: "powermenu"(?P<body>.*?)\n \}', source, re.S)
if not match:
raise SystemExit(1)
body = match.group("body")
if "execDetached" in body or ".run(" in body or "systemctl" in body:
raise SystemExit(1)
raise SystemExit(0)
PY
# The pre-selection argument exists, and the menu still arms rather than fires.
python3 - "$shell_qml" <<'PY' || note 'the powermenu IPC takes no entry to pre-select, so the poweroff option cannot arm Power Off'
import re
import sys
source = open(sys.argv[1], encoding="utf-8").read()
match = re.search(r'IpcHandler \{\s*\n\s+target: "powermenu"(?P<body>.*?)\n \}', source, re.S)
if not match:
raise SystemExit(1)
raise SystemExit(0 if re.search(r'function \w+\(\s*\w+\s*:', match.group("body")) else 1)
PY
grep -q 'disarm' "$powermenu" \
|| note 'the power menu no longer disarms its destructive entries, which is what makes powering off two presses'
# ── 2. One idle card ─────────────────────────────────────────────────────────
#
# Structural rather than by title: whatever the card ends up called, the
# battery timings must sit inside the same card as their wall-power
# counterparts. Two cards is the shape this redesign exists to remove.
python3 - "${page_files[@]}" <<'PY' || note 'the AC and battery idle timings are in different cards again -- one card was the point, so the two sets can never disagree on screen'
import re
import sys
def cards_in(path):
"""Every SettingsCard block, sliced by brace depth from its opening line."""
lines = open(path, encoding="utf-8").read().splitlines()
for index, line in enumerate(lines):
if not re.match(r"\s*SettingsCard \{", line):
continue
depth = 0
body = []
for current in lines[index:]:
body.append(current)
depth += current.count("{") - current.count("}")
if depth == 0:
break
yield "\n".join(body)
cards = [card for path in sys.argv[1:] for card in cards_in(path)]
source = "\n".join(open(path, encoding="utf-8").read() for path in sys.argv[1:])
pairs = [("lockMinutes", "lockMinutesBattery"),
("screenBlankMinutes", "screenBlankMinutesBattery"),
("suspendMinutes", "suspendMinutesBattery")]
for ac, battery in pairs:
ac_needle = f'setting: "{ac}"'
battery_needle = f'setting: "{battery}"'
if battery_needle not in source:
continue
together = any(ac_needle in card and battery_needle in card for card in cards)
if not together:
raise SystemExit(1)
raise SystemExit(0)
PY
page_has 'title: "Idle behavior on battery"' \
&& note 'the separate battery idle card is still here'
# The timeline itself: a picture, fed the same numbers the sliders write.
page_has 'IdleTimeline {' \
|| note 'the Power page does not render the idle timeline'
grep -q '^IdleTimeline 1.0 IdleTimeline.qml$' "$qmldir" \
|| note 'IdleTimeline is not registered in the settings qmldir, so the page would fail to resolve it'
python3 - "${page_files[@]}" <<'PY' || note 'the timeline is not fed from the stored idle values, so the picture and the sliders below it could disagree'
import re
import sys
block = None
for path in sys.argv[1:]:
lines = open(path, encoding="utf-8").read().splitlines()
for index, line in enumerate(lines):
if not re.match(r"\s*IdleTimeline \{", line):
continue
depth = 0
body = []
for current in lines[index:]:
body.append(current)
depth += current.count("{") - current.count("}")
if depth == 0:
break
block = "\n".join(body)
break
if block is not None:
break
if block is None:
raise SystemExit(1)
sourced = ("IdleLock." in block or "DesktopPreferences.get(" in block
or "Settings." in block)
raise SystemExit(0 if sourced else 1)
PY
# The ordering warning is drawn on the timeline now rather than being a card of
# its own that appears and disappears above the controls.
python3 - "${page_files[@]}" <<'PY' || note 'the lock-before-blank warning is still a card of its own rather than being rendered on the timeline'
import re
import sys
for path in sys.argv[1:]:
lines = open(path, encoding="utf-8").read().splitlines()
for index, line in enumerate(lines):
if not re.match(r"\s*SettingsCard \{", line):
continue
depth = 0
body = []
for current in lines[index:]:
body.append(current)
depth += current.count("{") - current.count("}")
if depth == 0:
break
card = "\n".join(body)
if "lockBeforeBlank" in card and "IdleTimeline" not in card:
# A card whose entire visibility is the warning.
if re.search(r"visible: IdleLock\.lockBeforeBlank\s*$", card, re.M):
raise SystemExit(1)
raise SystemExit(0)
PY
# The page and its components are presentation. Anything that shells out
# belongs in a service, where one copy of it can be shared.
for file in "${page_files[@]}"; do
grep -qE '\bProcess\b' "$file" \
&& note "${file#"$settings/"} shells out; the Power page's subprocesses belong in IdleLock and Battery"
done
# ── 3. What the page claims, it asked for ────────────────────────────────────
# Profile tiles: the daemon owns the value, so the page reads and writes it
# rather than storing a copy, and it says when the hardware is holding back.
for needle in 'PowerProfiles.profiles' 'PowerProfiles.active' 'PowerProfiles.set(' 'PowerProfiles.degraded'; do
page_has "$needle" || note "the power profile tiles do not use $needle"
done
# The inhibitor row, and the sentence it shows when nothing is holding a lock.
page_has 'IdleLock.inhibitors' \
|| note 'nothing on the page lists what is keeping the machine awake'
grep -q 'property var inhibitors\|property list<var> inhibitors' "$idle_lock" \
|| note 'IdleLock exposes no inhibitors, so the row would read undefined'
grep -q 'inhibitors' "$idle_helper" \
|| note 'panama-idle has no inhibitors verb, so there is nothing to read them from'
# The verb itself, against a stubbed logind. Three properties, and the third is
# the one that decides whether this row can be believed.
if command -v jq >/dev/null 2>&1; then
stub_dir="$(mktemp -d /tmp/panama-power-stub.XXXXXX)"
trap 'rm -rf "$stub_dir"' EXIT
# ListInhibitors returns a(ssssuu): what, who, why, mode, uid, pid.
cat >"$stub_dir/busctl" <<'STUB'
#!/usr/bin/env bash
[[ -n "${PANAMA_STUB_BUSCTL_FAIL:-}" ]] && exit 1
cat <<'JSON'
{"type":"a(ssssuu)","data":[[
["sleep:idle","hypridle","Holding the screen awake for a video","delay",1000,4242],
["sleep","steam","Downloading Half-Life 3","block",1000,4243],
["shutdown","packagekit","Applying updates","block",0,4244],
["handle-lid-switch","panama-lid","An external display is connected","block",1000,4245]
]]}
JSON
STUB
chmod +x "$stub_dir/busctl"
listed="$(PATH="$stub_dir:$PATH" "$idle_helper" inhibitors 2>/dev/null)"
jq -e . >/dev/null 2>&1 <<<"$listed" \
|| note "the inhibitors verb did not emit valid JSON (got: $listed)"
# A shutdown hold says nothing about whether the screen will blank, and
# listing it would put an entry on the card that explains nothing.
jq -e 'map(.who) | index("packagekit") == null' >/dev/null 2>&1 <<<"$listed" \
|| note 'a shutdown inhibitor is listed as a reason the machine is awake, which it is not'
for who in hypridle steam panama-lid; do
jq -e --arg who "$who" 'map(.who) | index($who) != null' >/dev/null 2>&1 <<<"$listed" \
|| note "the inhibitor list drops $who, which does bear on sleeping or idling"
done
# Panama's own hold is the one entry a person could act on, so hiding it
# would be the least honest omission available.
jq -e 'map(.who) | index("panama-lid") != null' >/dev/null 2>&1 <<<"$listed" \
|| note "Panama's own lid inhibitor is filtered out of the list it belongs in"
# A delay inhibitor holds sleep for a few seconds and then the machine
# sleeps anyway. Ranking those above a block would put the things that do
# NOT keep the machine awake at the top of a card about what does.
jq -e '[.[] | .mode == "delay"] | (index(true) // length) >= (rindex(false) // -1)' \
>/dev/null 2>&1 <<<"$listed" \
|| note 'delay inhibitors sort above blocking ones, so the entries that do not keep the machine awake lead the list'
# And the failure that matters: not being able to ask must not read as
# "nothing is holding it".
if PATH="$stub_dir:$PATH" PANAMA_STUB_BUSCTL_FAIL=1 "$idle_helper" inhibitors 2>/dev/null \
| grep -q '^\[\]$'; then
note 'a logind that could not be reached produces an empty list, which claims nothing holds the machine awake rather than admitting it could not look'
fi
PATH="$stub_dir:$PATH" PANAMA_STUB_BUSCTL_FAIL=1 "$idle_helper" inhibitors >/dev/null 2>&1 \
&& note 'the inhibitors verb succeeds when logind cannot be reached, so the caller cannot tell an empty list from a failed one'
rm -rf "$stub_dir"
trap - EXIT
fi
page_matches -i 'holds a wake lock' \
|| note 'the inhibitor row has no empty state, so a machine holding nothing shows an empty row rather than saying so'
# Hibernate: gated on logind's answer, and honest about why the answer is no.
page_has 'canHibernate' \
|| note 'the hibernate row is not gated on whether this machine can hibernate'
page_matches -i 'zram' \
|| note 'the hibernate row does not say why this machine cannot hibernate, which is the only reason the row is there'
# ...and the property it reads has to trace back to logind, not to a constant.
probe="$(grep -rl 'property bool canHibernate' "$shell_dir/services" "$shell_dir/modules" 2>/dev/null)"
if [[ -z "$probe" ]]; then
note 'nothing declares canHibernate, so the gate is a guess'
elif ! grep -lq 'CanHibernate' $probe; then
note 'canHibernate is set without asking logind CanHibernate, so a machine that can hibernate would be told it cannot'
fi
# The lid card stays read-only by design (see LidPolicy's own argument).
page_has 'LidPolicy.' || note 'the lid card reads no live lid policy'
page_matches 'setting: "lid' \
&& note 'the lid grew an override, which LidPolicy deliberately does not have -- a lid set to never suspend is a laptop that cooks in a bag'
# The power button row is the schema key, not prose about a fixed behavior.
page_has 'setting: "powerButtonAction"' \
|| note 'the power button is still described rather than adjustable'
# Management says where the generated file went, rather than describing it.
page_has 'IdleLock.generatedPath' \
|| note 'the Management card does not show the generated hypridle path'
# The Power page changes settings. It is not a second power menu: nothing here
# suspends, hibernates or powers the machine off.
if page_matches 'systemctl", *"(suspend|hibernate|poweroff|reboot)|systemctl (suspend|hibernate|poweroff|reboot)'; then
note 'the Power page runs a power command of its own -- the power menu owns those, with its two-press arming'
fi
if (( ${#findings[@]} > 0 )); then
printf 'power page contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'power page contract: PASS\n'
+134 -1
View File
@@ -21,8 +21,17 @@
# 3. A docked laptop holds one, and it is the right kind.
# 4. Locking on the way down is not this code's job -- hypridle's
# before_sleep_cmd already does it -- and must not be quietly duplicated.
# 5. Opening the lid restores the panel as it was CONFIGURED, not as a
# four-key approximation of it. `open` used to emit a rule carrying
# output/mode/position/scale and nothing else, and a Hyprland monitor rule
# replaces the previous rule for that output whole -- so a docked laptop
# whose internal panel had been set to 10-bit, wide gamut, rotated, or
# placed at a particular position lost every one of those the first time
# the lid was closed and reopened. Silently, and only on a machine with a
# lid, which is the combination that keeps a bug alive.
#
# Driven with stubbed predicates. The end-to-end behavior of a real lid needs a
# Driven with stubbed predicates, and for (5) a stubbed `hyprctl` that records
# the rule instead of applying it. The end-to-end behavior of a real lid needs a
# machine with a lid; see the header of the helper.
set -uo pipefail
@@ -124,6 +133,130 @@ grep -q 'before_sleep_cmd' "$hypridle" \
uncommented "$helper" | grep -q 'loginctl lock-session\|hyprlock' \
&& note 'the lid helper locks the session itself, duplicating what hypridle already does on every sleep'
# ── 5. Opening the lid restores the whole configured record ──────────────────
#
# Everything below runs against a stubbed `hyprctl`, so nothing here reaches the
# compositor: the stub answers the monitor query from a fixture and writes the
# rule it was asked to apply into a file. `eval` is what the helper uses (a
# runtime rule, gone at the next reload) rather than `keyword`, which would
# persist -- so even a real run of this would be recoverable; it still does not
# happen.
if command -v jq >/dev/null 2>&1; then
emitted="$work/emitted"
config_home="$work/config"
mkdir -p "$config_home/panama"
cat >"$fake/hyprctl" <<STUB
#!/usr/bin/env bash
# The monitor query: one internal panel and one external display.
if [[ "\$1" == "-j" ]]; then
printf '%s\n' '[{"name":"eDP-1"},{"name":"DP-2"}]'
exit 0
fi
printf '%s\n' "\$*" >>"$emitted"
STUB
chmod +x "$fake/hyprctl"
# `open` with whatever this fixture stores for the internal panel.
open_with() {
: >"$emitted"
printf '%s' "$1" >"$config_home/panama/settings.json"
PATH="$fake:$PATH" PANAMA_PATH="$fake" XDG_CONFIG_HOME="$config_home" \
"$helper" open >/dev/null 2>&1
cat "$emitted" 2>/dev/null
}
carries() {
grep -Fq "$2" <<<"$1" \
|| note "opening the lid emitted no $3 (rule was: $(tr -d '\n' <<<"$1"))"
}
# A fully described panel. Every one of these fields is something the
# Displays page can write and the old four-key rule threw away.
full='{"displays":{"eDP-1":{"mode":"2880x1800@120","scale":2,"transform":1,
"x":1920,"y":0,"primary":false,"vrrMode":1,"colorProfile":"wide",
"bitdepth":10,"sdrBrightness":1.2,"sdrSaturation":0.9}}}'
rule="$(open_with "$full")"
[[ -n "$rule" ]] || note 'opening the lid on a laptop emitted no monitor rule at all'
carries "$rule" 'hl.monitor' 'monitor rule'
carries "$rule" 'eDP-1' 'output name'
carries "$rule" '2880x1800@120' 'stored mode'
# Position comes from the stored coordinates. "auto" here would move the
# panel out from under the arrangement the user dragged.
carries "$rule" '1920x0' 'position from the stored x and y'
carries "$rule" 'scale = 2' 'stored scale'
carries "$rule" 'transform = 1' 'stored rotation'
carries "$rule" 'vrr = 1' 'stored variable refresh rate'
carries "$rule" 'bitdepth = 10' 'stored bit depth'
carries "$rule" 'cm = "wide"' 'stored colour profile'
carries "$rule" 'sdrbrightness = 1.2' 'stored SDR brightness'
carries "$rule" 'sdrsaturation = 0.9' 'stored SDR saturation'
# jq prints an absent key as the string "null", which reaches a rule as a
# value the compositor will reject or, worse, accept.
grep -q 'null' <<<"$rule" \
&& note 'the emitted rule contains a null, so an unset field was written out rather than left off'
# ── Invalid values drop one at a time, and geometry survives ─────────────
#
# The two failure directions monitors.lua chose, and the reason they
# differ: an unreadable colour costs a shade, so it drops on its own and
# the arrangement stands. Geometry is the opposite -- guessing half of it
# can strand an output where no cursor reaches -- so a bad one refuses the
# whole record and the panel comes back on its preferred mode.
broken='{"displays":{"eDP-1":{"mode":"2880x1800@120","scale":2,"transform":1,
"x":1920,"y":0,"primary":false,"vrrMode":7,"colorProfile":"chartreuse",
"bitdepth":12,"sdrBrightness":9,"sdrSaturation":"a lot"}}}'
rule="$(open_with "$broken")"
carries "$rule" '2880x1800@120' 'mode, which is valid and must survive a bad colour profile'
carries "$rule" '1920x0' 'position, which is valid and must survive a bad colour profile'
carries "$rule" 'scale = 2' 'scale, which is valid and must survive a bad colour profile'
carries "$rule" 'transform = 1' 'rotation, which is valid and must survive a bad colour profile'
for bad in 'vrr = 7' 'chartreuse' 'bitdepth = 12' 'sdrbrightness = 9' 'a lot'; do
grep -Fq "$bad" <<<"$rule" \
&& note "an out-of-range value reached the compositor: $bad"
done
# Bad geometry takes the record down with it, back to the panel's own
# preferred mode -- never a partially honoured rule.
for field in '"transform":9' '"scale":7' '"mode":"enormous"'; do
rule="$(open_with "{\"displays\":{\"eDP-1\":{\"mode\":\"2880x1800@120\",
\"scale\":2,\"transform\":1,${field}}}}")"
carries "$rule" 'mode = "preferred"' "a fallback to the preferred mode for a record with $field"
grep -Fq '2880x1800@120' <<<"$rule" \
&& note "a record with $field was half-honoured: its mode was applied anyway"
done
# ── No stored position means automatic placement ─────────────────────────
legacy='{"displays":{"eDP-1":{"mode":"2880x1800@120","scale":2,"transform":0}}}'
rule="$(open_with "$legacy")"
carries "$rule" 'position = "auto"' 'automatic position for a record with no stored coordinates'
carries "$rule" '2880x1800@120' 'mode from a record predating the layout fields'
grep -Fq 'x0' <<<"$rule" \
&& note 'a record with no stored coordinates produced a position anyway'
# A half-written position is refused the way monitors.lua refuses it:
# guessing the other half can strand an output where nothing can reach it.
half='{"displays":{"eDP-1":{"mode":"2880x1800@120","scale":2,"transform":0,"x":1920}}}'
rule="$(open_with "$half")"
carries "$rule" 'position = "auto"' 'automatic position for a half-written record'
# ── Nothing stored at all falls back to the panel's own preference ───────
rule="$(open_with '{}')"
carries "$rule" 'mode = "preferred"' 'preferred mode when nothing is stored'
carries "$rule" 'position = "auto"' 'automatic position when nothing is stored'
carries "$rule" 'scale = "auto"' 'automatic scale when nothing is stored'
# ── It stays a runtime rule ──────────────────────────────────────────────
# `hyprctl keyword` would write the approximation into the compositor's
# live configuration, where a reload would not undo it.
uncommented "$helper" | grep -q 'hyprctl keyword' \
&& note 'the lid helper applies monitor rules with keyword rather than eval, so a wrong rule would outlive a reload'
fi
# ── The service that drives it ───────────────────────────────────────────────
[[ -r "$service" ]] || note 'LidPolicy.qml is missing, so nothing notices a display being connected'