diff --git a/README.md b/README.md index 5fcdc8e..e49009d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config/dot/hypr/keybinds.lua b/config/dot/hypr/keybinds.lua index c784cbb..c59aec1 100644 --- a/config/dot/hypr/keybinds.lua +++ b/config/dot/hypr/keybinds.lua @@ -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 diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index 38faeb5..cbc57c5 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -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 diff --git a/config/dot/quickshell/modules/powermenu/PowerMenu.qml b/config/dot/quickshell/modules/powermenu/PowerMenu.qml index 69945cb..9b455f8 100644 --- a/config/dot/quickshell/modules/powermenu/PowerMenu.qml +++ b/config/dot/quickshell/modules/powermenu/PowerMenu.qml @@ -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 { diff --git a/config/dot/quickshell/modules/settings/IdleTimeline.qml b/config/dot/quickshell/modules/settings/IdleTimeline.qml new file mode 100644 index 0000000..6c0eaf2 --- /dev/null +++ b/config/dot/quickshell/modules/settings/IdleTimeline.qml @@ -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 + } + } +} diff --git a/config/dot/quickshell/modules/settings/OptionPickerRow.qml b/config/dot/quickshell/modules/settings/OptionPickerRow.qml index d405036..d4d5d5f 100644 --- a/config/dot/quickshell/modules/settings/OptionPickerRow.qml +++ b/config/dot/quickshell/modules/settings/OptionPickerRow.qml @@ -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 diff --git a/config/dot/quickshell/modules/settings/PowerPage.qml b/config/dot/quickshell/modules/settings/PowerPage.qml index f174a26..69c6287 100644 --- a/config/dot/quickshell/modules/settings/PowerPage.qml +++ b/config/dot/quickshell/modules/settings/PowerPage.qml @@ -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 { diff --git a/config/dot/quickshell/modules/settings/PowerProfileTiles.qml b/config/dot/quickshell/modules/settings/PowerProfileTiles.qml new file mode 100644 index 0000000..cace223 --- /dev/null +++ b/config/dot/quickshell/modules/settings/PowerProfileTiles.qml @@ -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(); + } + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index f38277f..a5f171c 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -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 diff --git a/config/dot/quickshell/scripts/panama-battery b/config/dot/quickshell/scripts/panama-battery index 9b95c8c..8003478 100755 --- a/config/dot/quickshell/scripts/panama-battery +++ b/config/dot/quickshell/scripts/panama-battery @@ -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 ;; diff --git a/config/dot/quickshell/scripts/panama-idle b/config/dot/quickshell/scripts/panama-idle index 781c643..6d313ef 100755 --- a/config/dot/quickshell/scripts/panama-idle +++ b/config/dot/quickshell/scripts/panama-idle @@ -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" <&2 + printf 'usage: panama-idle [apply|install|remove|status|inhibitors]\n' >&2 exit 2 ;; esac diff --git a/config/dot/quickshell/scripts/panama-lid b/config/dot/quickshell/scripts/panama-lid index a1ef9b2..3e4ae71 100755 --- a/config/dot/quickshell/scripts/panama-lid +++ b/config/dot/quickshell/scripts/panama-lid @@ -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("^(?[0-9]+)x(?[0-9]+)@(?[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 ;; diff --git a/config/dot/quickshell/services/Battery.qml b/config/dot/quickshell/services/Battery.qml index 50a4304..c4bb149 100644 --- a/config/dot/quickshell/services/Battery.qml +++ b/config/dot/quickshell/services/Battery.qml @@ -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; } } diff --git a/config/dot/quickshell/services/IdleLock.qml b/config/dot/quickshell/services/IdleLock.qml index f725add..565efb9 100644 --- a/config/dot/quickshell/services/IdleLock.qml +++ b/config/dot/quickshell/services/IdleLock.qml @@ -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") diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index 4649af8..320a709 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -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" }, diff --git a/config/dot/quickshell/shell.qml b/config/dot/quickshell/shell.qml index ad21f24..3b0089b 100644 --- a/config/dot/quickshell/shell.qml +++ b/config/dot/quickshell/shell.qml @@ -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 { diff --git a/config/local/share/vicinae/scripts/settings-power b/config/local/share/vicinae/scripts/settings-power index f8509f1..8e3452d 100755 --- a/config/local/share/vicinae/scripts/settings-power +++ b/config/local/share/vicinae/scripts/settings-power @@ -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 diff --git a/docs/settings.md b/docs/settings.md index 3147b1e..fc9e597 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -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**
`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**
`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**
`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**. diff --git a/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md b/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md index 5b3628f..e15387a 100644 --- a/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md +++ b/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md @@ -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. diff --git a/docs/superpowers/specs/2026-08-24-power-lock-redesign.md b/docs/superpowers/specs/2026-08-24-power-lock-redesign.md new file mode 100644 index 0000000..34bd77d --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-power-lock-redesign.md @@ -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 ` 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. diff --git a/tests/quickshell/battery-contract b/tests/quickshell/battery-contract index e2b49d7..8d2637b 100755 --- a/tests/quickshell/battery-contract +++ b/tests/quickshell/battery-contract @@ -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 diff --git a/tests/quickshell/power-page-contract b/tests/quickshell/power-page-contract new file mode 100755 index 0000000..792f85d --- /dev/null +++ b/tests/quickshell/power-page-contract @@ -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.*?)\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.*?)\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 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' diff --git a/tests/setup/lid-contract b/tests/setup/lid-contract index 7aa2fe3..f6b681e 100755 --- a/tests/setup/lid-contract +++ b/tests/setup/lid-contract @@ -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" <>"$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'