// Replaces GNOME's power-off dialog. Opened by Ctrl+Alt+Delete and from the // quick-settings footer. // // Log Out, Restart and Power Off arm on the first press and fire on the second // (see PowerButton); Lock and Suspend are one press because neither loses work. import Quickshell import Quickshell.Io import Quickshell.Wayland import QtQuick import qs.config import qs.services PanelWindow { id: win visible: ShellState.powerMenuOpen color: "transparent" anchors { top: true bottom: true left: true right: true } exclusiveZone: 0 WlrLayershell.namespace: "qs-popover-power" WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive property int currentIndex: 0 // Hibernate appears only where it can deliver: logind answers "yes" only // with a resume-capable swap configured, and a menu entry that fails // silently is worse than none. Checked once per shell run -- swap does // not come and go. property bool canHibernate: false Process { id: hibernateProbe command: ["busctl", "call", "org.freedesktop.login1", "/org/freedesktop/login1", "org.freedesktop.login1.Manager", "CanHibernate"] running: true stdout: StdioCollector { onStreamFinished: win.canHibernate = this.text.includes('"yes"') } } // A uwsm-managed session logs out by stopping the uwsm unit; a plain // "Hyprland" session must ask the compositor to exit instead. The branch // used to test merely that the uwsm BINARY existed -- but uwsm is always // installed here, so in a plain session `uwsm stop` found no unit to // stop, failed, and the button did nothing. Nor is `uwsm check is-active` // the test: it only checks graphical-session.target, which the plain // session also reaches. What only the managed session has is uwsm's own // compositor unit, wayland-wm@*.service, so that is what decides. readonly property string logoutScript: 'if systemctl --user list-units --no-legend --state=active "wayland-wm@*.service" 2>/dev/null | grep -q .; then exec uwsm stop; else exec hyprctl dispatch "hl.dsp.exit()"; fi' // The rows actually shown: an entry may declare `available: false` to // 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, available: win.canHibernate, cmd: ["systemctl", "hibernate"] }, { entryId: "restart", glyph: "󰜉", label: "Restart", destructive: true, cmd: ["systemctl", "reboot"] }, { entryId: "poweroff", glyph: "󰐥", label: "Power Off", destructive: true, cmd: ["systemctl", "poweroff"] } ] // 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 { ShellState.close(); Quickshell.execDetached(win.entries[index].cmd); } function move(step: int): void { // Moving away disarms, so Enter can never fire a button the user only // meant to walk past. rep.itemAt(win.currentIndex).disarm(); win.currentIndex = (win.currentIndex + step + win.entries.length) % win.entries.length; } FocusScope { id: keys anchors.fill: parent focus: true Keys.onPressed: event => { switch (event.key) { case Qt.Key_Escape: ShellState.close(); break; case Qt.Key_Left: win.move(-1); break; case Qt.Key_Right: win.move(1); break; case Qt.Key_Return: case Qt.Key_Enter: case Qt.Key_Space: rep.itemAt(win.currentIndex).trigger(); break; default: return; } event.accepted = true; } Rectangle { anchors.fill: parent color: Theme.alpha(Theme.bgDark, Theme.overlayAlpha) border.width: 0 MouseArea { anchors.fill: parent onClicked: ShellState.close() } } Rectangle { anchors.centerIn: parent implicitWidth: row.implicitWidth + 2 * 24 implicitHeight: row.implicitHeight + 2 * 24 radius: Theme.popoverRadius + 8 border.width: 0 color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha) // Swallow clicks that land on the panel but miss a tile, so the // outside-click handler underneath doesn't close the menu. MouseArea { anchors.fill: parent } Row { id: row anchors.centerIn: parent spacing: 14 Repeater { id: rep model: win.entries PowerButton { required property var modelData required property int index glyph: modelData.glyph label: modelData.label destructive: modelData.destructive selected: index === win.currentIndex onActivated: win.run(index) } } } } } }