Files
dotfiles/quickshell/overlays/PowerMenu.qml
T
nvrl c36a74e861 feat(quickshell): idle chain, drifting wallpaper, and a few fixes
- Idle is handled in the shell instead of hypridle: the screen dims
  slowly at 4 minutes, locks at 5, powers down at 6, and suspends at 15
  on battery. Idle inhibitors hold every stage off; Super+Shift+I does
  the same by hand and puts a cup on the rail.
- The shell draws the wallpaper (backdrop/), so hyprpaper is gone. The
  picture is drawn a little larger than the screen and drifts with the
  workspace a beat after the windows, and dims a shade while windows are
  open. The path lives in quickshell/wallpaper.
- The lock screen and power menu say "Take your time." instead of the
  NieR slogan.
- hyprpaper is no longer launched twice, the media module keeps the
  player that played last instead of an empty browser tab, and grouped
  windows show thin untitled tabs with their place in the group (2/3)
  in the bar title.
- qt6ct uses Darkly; the package list gains adw-gtk-theme and darkly-bin.
2026-10-04 23:10:19 +02:00

319 lines
11 KiB
QML

import QtQuick
import Quickshell
import Quickshell.Wayland
import Quickshell.Io
import "root:/config"
import "root:/components"
import "root:/services"
// Full-screen power menu, built like the lock screen: the desktop dims to the
// void, and one small group of type stands in it — the mark, the motto, a
// line about the session, and a NieR menu of six rows. The selected row is a
// pill of light with the text punched out of it; the three that end the
// session light in rust instead. Escape or a click on the dark dismisses it.
PanelWindow {
id: root
required property var modelData
screen: modelData
// Only the screen that had focus when the menu was asked for shows it; the
// other copies stay dormant so the keyboard has one place to land.
readonly property bool open: Actions.powerMenuOpen
&& Actions.powerMenuScreen === modelData.name
visible: root.open || closeAnim.running
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "takemi-power"
WlrLayershell.keyboardFocus: root.open
? WlrKeyboardFocus.Exclusive
: WlrKeyboardFocus.None
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
exclusionMode: ExclusionMode.Ignore
property int selectedAction: 0
function moveSelection(delta: int): void {
root.selectedAction = (root.selectedAction + delta + root.actionModel.length)
% root.actionModel.length;
}
readonly property var actionModel: [
{ label: "Lock", icon: "󰌾", danger: false, act: "lock" },
{ label: "Suspend", icon: "󰤄", danger: false, act: "suspend" },
{ label: "Hibernate", icon: "󰒲", danger: false, act: "hibernate" },
{ label: "Log Out", icon: "󰗽", danger: true, act: "logout" },
{ label: "Reboot", icon: "󰜉", danger: true, act: "reboot" },
{ label: "Shut Down", icon: "󰐥", danger: true, act: "shutdown" }
]
FileView {
id: hostFile
path: "/etc/hostname"
}
onOpenChanged: {
if (open) {
PopupCoordinator.dismissAll();
selectedAction = 0;
openAnim.restart();
} else {
closeAnim.restart();
}
}
// ------------------------------------------------------------- backdrop
// The desktop stays faintly there behind the dark, deepest on the left
// where the menu stands.
Rectangle {
id: backdrop
anchors.fill: parent
opacity: 0
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: Theme.alpha(Theme.ink, 0.97) }
GradientStop { position: 0.5; color: Theme.alpha(Theme.ink, 0.93) }
GradientStop { position: 1.0; color: Theme.alpha(Theme.ink, 0.86) }
}
TapHandler {
onTapped: Actions.closePowerMenu()
}
}
// ---------------------------------------------------------------- group
Column {
id: group
x: root.width * 0.14
anchors.verticalCenter: parent.verticalCenter
width: 340
opacity: 0
transform: Translate { id: drift }
Porthole {
size: 26
}
Text {
topPadding: Theme.padM
text: "Take your time."
color: Theme.accent
font.family: Theme.fontDisplay
font.pixelSize: Theme.fsHuge
font.weight: Font.Light
font.letterSpacing: 1
renderType: Text.NativeRendering
}
Text {
topPadding: Theme.padS
leftPadding: 2
text: [(Quickshell.env("USER") || "user") + "@" + hostFile.text().trim(),
Sys.uptime ? "UP " + Sys.uptime : ""].filter(t => t !== "").join(" · ")
color: Theme.muted
font.family: Theme.fontMono
font.pixelSize: Theme.fsMicro
font.letterSpacing: 2
renderType: Text.NativeRendering
}
Item { width: 1; height: Theme.padXL }
// ------------------------------------------------------------ menu
Repeater {
id: buttonRepeater
model: root.actionModel
Item {
id: entry
required property var modelData
required property int index
readonly property bool selected: root.selectedAction === entry.index
readonly property color tone: entry.modelData.danger ? Theme.alarm : Theme.accent
width: group.width
height: 44
opacity: 0
transform: Translate { id: rise; y: 6 }
// The divider after the third row: what pauses the session
// above, what ends it below.
Rectangle {
visible: entry.index === 3
anchors.top: parent.top
x: Theme.padL
width: parent.width - Theme.padL * 2
height: 1
color: Theme.alpha(Theme.outline, 0.35)
}
Rectangle {
id: slab
anchors.fill: parent
anchors.topMargin: 3
anchors.bottomMargin: 3
radius: height / 2
color: entry.selected ? entry.tone : "transparent"
Behavior on color {
ColorAnimation { duration: Theme.durBase; easing.type: Easing.OutCubic }
}
}
Rectangle {
id: marker
anchors.left: parent.left
anchors.leftMargin: Theme.padL
anchors.verticalCenter: parent.verticalCenter
width: Theme.marker
height: Theme.marker
radius: width / 2
color: entry.selected ? Theme.ink
: (entry.modelData.danger ? Theme.alpha(Theme.alarm, 0.6) : Theme.overlay)
}
Icon {
id: glyph
anchors.left: marker.right
anchors.leftMargin: Theme.padM
anchors.verticalCenter: parent.verticalCenter
// Fixed width so the labels line up whatever the glyph.
width: 20
horizontalAlignment: Text.AlignHCenter
text: entry.modelData.icon
color: entry.selected ? Theme.ink : Theme.muted
font.pixelSize: Theme.fsLarge
}
Text {
anchors.left: glyph.right
anchors.leftMargin: Theme.padM
anchors.verticalCenter: parent.verticalCenter
text: entry.modelData.label.toUpperCase()
color: entry.selected ? Theme.ink : Theme.text
font.family: Theme.fontDisplay
font.pixelSize: Theme.fsBody
font.weight: Theme.weightDisplay
font.letterSpacing: Theme.tracking * 1.5
renderType: Text.NativeRendering
}
HoverHandler {
cursorShape: Qt.PointingHandCursor
onHoveredChanged: if (hovered) root.selectedAction = entry.index
}
TapHandler {
onTapped: root.invoke(entry.modelData.act)
}
// Staggered entry: each row resolves a beat after the last.
Connections {
target: root
function onOpenChanged() {
if (root.open) entryAnim.restart();
else entry.opacity = 0;
}
}
SequentialAnimation {
id: entryAnim
PauseAnimation { duration: 120 + entry.index * 40 }
ParallelAnimation {
NumberAnimation {
target: entry; property: "opacity"
to: 1; duration: Theme.durSlow; easing.type: Easing.OutCubic
}
NumberAnimation {
target: rise; property: "y"
from: 6; to: 0; duration: Theme.durSlow; easing.type: Easing.OutCubic
}
}
}
}
}
Text {
topPadding: Theme.padXL
leftPadding: Theme.padL
text: "↑ ↓ SELECT ENTER CONFIRM ESC CANCEL"
color: Theme.overlay
font.family: Theme.fontMono
font.pixelSize: Theme.fsMicro
font.letterSpacing: 2
renderType: Text.NativeRendering
}
}
function invoke(what: string) {
Actions.closePowerMenu();
switch (what) {
case "lock": Actions.lock(); break;
case "suspend": Actions.suspend(); break;
case "hibernate": Actions.hibernate(); break;
case "logout": Actions.logout(); break;
case "reboot": Actions.reboot(); break;
case "shutdown": Actions.shutdown(); break;
}
}
// Escape closes. The layer takes exclusive focus while open.
Item {
id: keyController
anchors.fill: parent
focus: root.open
Keys.onEscapePressed: Actions.closePowerMenu()
Keys.onPressed: event => {
if (!root.open) return;
if (event.key === Qt.Key_Up || event.key === Qt.Key_K) {
root.moveSelection(-1);
event.accepted = true;
} else if (event.key === Qt.Key_Down || event.key === Qt.Key_J) {
root.moveSelection(1);
event.accepted = true;
} else if (keyController.activeFocus
&& !event.isAutoRepeat
&& (event.key === Qt.Key_Return || event.key === Qt.Key_Enter
|| event.key === Qt.Key_Space)) {
root.invoke(root.actionModel[root.selectedAction].act);
event.accepted = true;
}
}
}
ParallelAnimation {
id: openAnim
NumberAnimation {
target: backdrop; property: "opacity"
to: 1; duration: Theme.durSlow; easing.type: Easing.OutCubic
}
NumberAnimation {
target: group; property: "opacity"
to: 1; duration: Theme.durLazy; easing.type: Easing.OutCubic
}
NumberAnimation {
target: drift; property: "y"
from: 14; to: 0; duration: Theme.durLazy; easing.type: Easing.OutQuint
}
}
ParallelAnimation {
id: closeAnim
NumberAnimation {
target: backdrop; property: "opacity"
to: 0; duration: Theme.durBase; easing.type: Easing.InCubic
}
NumberAnimation {
target: group; property: "opacity"
to: 0; duration: Theme.durFast
}
}
}