diff --git a/Modals/PowerConfirmModal.qml b/Modals/PowerConfirmModal.qml index 33f18639..6a4c1ac3 100644 --- a/Modals/PowerConfirmModal.qml +++ b/Modals/PowerConfirmModal.qml @@ -12,14 +12,27 @@ DankModal { property string powerConfirmAction: "" property string powerConfirmTitle: "" property string powerConfirmMessage: "" + property int selectedButton: -1 // -1 = none, 0 = Cancel, 1 = Confirm + property bool keyboardNavigation: false function show(action, title, message) { powerConfirmAction = action powerConfirmTitle = title powerConfirmMessage = message + selectedButton = -1 // No button selected initially + keyboardNavigation = false open() } + function selectButton() { + if (selectedButton === 0) { + close() + } else { + close() + executePowerAction(powerConfirmAction) + } + } + function executePowerAction(action) { switch (action) { case "logout": @@ -44,6 +57,35 @@ DankModal { onBackgroundClicked: { close() } + onOpened: { + modalFocusScope.forceActiveFocus() + } + modalFocusScope.Keys.onPressed: function(event) { + switch (event.key) { + case Qt.Key_Left: + case Qt.Key_Up: + keyboardNavigation = true + selectedButton = 0 + event.accepted = true + break + case Qt.Key_Right: + case Qt.Key_Down: + keyboardNavigation = true + selectedButton = 1 + event.accepted = true + break + case Qt.Key_Tab: + keyboardNavigation = true + selectedButton = selectedButton === -1 ? 0 : (selectedButton + 1) % 2 + event.accepted = true + break + case Qt.Key_Return: + case Qt.Key_Enter: + selectButton() + event.accepted = true + break + } + } content: Component { Item { @@ -93,7 +135,16 @@ DankModal { width: 120 height: 40 radius: Theme.cornerRadius - color: cancelButton.containsMouse ? Theme.surfaceTextPressed : Theme.surfaceVariantAlpha + color: { + if (keyboardNavigation && selectedButton === 0) + return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) + else if (cancelButton.containsMouse) + return Theme.surfacePressed + else + return Theme.surfaceVariantAlpha + } + border.color: (keyboardNavigation && selectedButton === 0) ? Theme.primary : "transparent" + border.width: (keyboardNavigation && selectedButton === 0) ? 1 : 0 StyledText { text: "Cancel" @@ -110,9 +161,11 @@ DankModal { hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { - close() + selectedButton = 0 + selectButton() } } + } Rectangle { @@ -132,12 +185,15 @@ DankModal { baseColor = Theme.primary break } - return confirmButton.containsMouse ? Qt.rgba( - baseColor.r, - baseColor.g, - baseColor.b, - 0.9) : baseColor + if (keyboardNavigation && selectedButton === 1) + return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, 1) + else if (confirmButton.containsMouse) + return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, 0.9) + else + return baseColor } + border.color: (keyboardNavigation && selectedButton === 1) ? "white" : "transparent" + border.width: (keyboardNavigation && selectedButton === 1) ? 1 : 0 StyledText { text: "Confirm" @@ -154,13 +210,19 @@ DankModal { hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { - close() - executePowerAction(powerConfirmAction) + selectedButton = 1 + selectButton() } } + } + } + } + } + } + } diff --git a/Modals/PowerMenuModal.qml b/Modals/PowerMenuModal.qml new file mode 100644 index 00000000..53d4d79c --- /dev/null +++ b/Modals/PowerMenuModal.qml @@ -0,0 +1,337 @@ +import QtQuick +import QtQuick.Controls +import Quickshell +import Quickshell.Io +import qs.Common +import qs.Services +import qs.Widgets + +DankModal { + id: root + + property int selectedIndex: 0 + property int optionCount: 4 + + signal powerActionRequested(string action, string title, string message) + + function selectOption() { + close() + switch (selectedIndex) { + case 0: + root.powerActionRequested("logout", "Log Out", "Are you sure you want to log out?") + break + case 1: + root.powerActionRequested("suspend", "Suspend", "Are you sure you want to suspend the system?") + break + case 2: + root.powerActionRequested("reboot", "Reboot", "Are you sure you want to reboot the system?") + break + case 3: + root.powerActionRequested("poweroff", "Power Off", "Are you sure you want to power off the system?") + break + } + } + + shouldBeVisible: false + width: 320 + height: 300 + enableShadow: true + onBackgroundClicked: { + close() + } + onOpened: { + selectedIndex = 0 + modalFocusScope.forceActiveFocus() + } + modalFocusScope.Keys.onPressed: function(event) { + switch (event.key) { + case Qt.Key_Up: + selectedIndex = (selectedIndex - 1 + optionCount) % optionCount + event.accepted = true + break + case Qt.Key_Down: + selectedIndex = (selectedIndex + 1) % optionCount + event.accepted = true + break + case Qt.Key_Tab: + selectedIndex = (selectedIndex + 1) % optionCount + event.accepted = true + break + case Qt.Key_Return: + case Qt.Key_Enter: + selectOption() + event.accepted = true + break + } + } + + content: Component { + Item { + anchors.fill: parent + + Column { + anchors.fill: parent + anchors.margins: Theme.spacingL + spacing: Theme.spacingM + + Row { + width: parent.width + + StyledText { + text: "Power Options" + font.pixelSize: Theme.fontSizeLarge + color: Theme.surfaceText + font.weight: Font.Medium + anchors.verticalCenter: parent.verticalCenter + } + + Item { + width: parent.width - 150 + height: 1 + } + + DankActionButton { + iconName: "close" + iconSize: Theme.iconSize - 4 + iconColor: Theme.surfaceText + hoverColor: Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12) + onClicked: { + close() + } + } + + } + + Column { + width: parent.width + spacing: Theme.spacingS + + Rectangle { + width: parent.width + height: 50 + radius: Theme.cornerRadius + color: { + if (selectedIndex === 0) + return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12); + else if (logoutArea.containsMouse) + return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08); + else + return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08); + } + border.color: selectedIndex === 0 ? Theme.primary : "transparent" + border.width: selectedIndex === 0 ? 1 : 0 + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingM + + DankIcon { + name: "logout" + size: Theme.iconSize + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: "Log Out" + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + font.weight: Font.Medium + anchors.verticalCenter: parent.verticalCenter + } + + } + + MouseArea { + id: logoutArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + selectedIndex = 0 + selectOption() + } + } + + } + + Rectangle { + width: parent.width + height: 50 + radius: Theme.cornerRadius + color: { + if (selectedIndex === 1) + return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12); + else if (suspendArea.containsMouse) + return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08); + else + return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08); + } + border.color: selectedIndex === 1 ? Theme.primary : "transparent" + border.width: selectedIndex === 1 ? 1 : 0 + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingM + + DankIcon { + name: "bedtime" + size: Theme.iconSize + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: "Suspend" + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + font.weight: Font.Medium + anchors.verticalCenter: parent.verticalCenter + } + + } + + MouseArea { + id: suspendArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + selectedIndex = 1 + selectOption() + } + } + + } + + Rectangle { + width: parent.width + height: 50 + radius: Theme.cornerRadius + color: { + if (selectedIndex === 2) + return Qt.rgba(Theme.warning.r, Theme.warning.g, Theme.warning.b, 0.12); + else if (rebootArea.containsMouse) + return Qt.rgba(Theme.warning.r, Theme.warning.g, Theme.warning.b, 0.08); + else + return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08); + } + border.color: selectedIndex === 2 ? Theme.warning : "transparent" + border.width: selectedIndex === 2 ? 1 : 0 + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingM + + DankIcon { + name: "restart_alt" + size: Theme.iconSize + color: rebootArea.containsMouse ? Theme.warning : Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: "Reboot" + font.pixelSize: Theme.fontSizeMedium + color: rebootArea.containsMouse ? Theme.warning : Theme.surfaceText + font.weight: Font.Medium + anchors.verticalCenter: parent.verticalCenter + } + + } + + MouseArea { + id: rebootArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + selectedIndex = 2 + selectOption() + } + } + + } + + Rectangle { + width: parent.width + height: 50 + radius: Theme.cornerRadius + color: { + if (selectedIndex === 3) + return Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12); + else if (powerOffArea.containsMouse) + return Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.08); + else + return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08); + } + border.color: selectedIndex === 3 ? Theme.error : "transparent" + border.width: selectedIndex === 3 ? 1 : 0 + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingM + + DankIcon { + name: "power_settings_new" + size: Theme.iconSize + color: powerOffArea.containsMouse ? Theme.error : Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: "Power Off" + font.pixelSize: Theme.fontSizeMedium + color: powerOffArea.containsMouse ? Theme.error : Theme.surfaceText + font.weight: Font.Medium + anchors.verticalCenter: parent.verticalCenter + } + + } + + MouseArea { + id: powerOffArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + selectedIndex = 3 + selectOption() + } + } + + } + + } + + Item { + height: Theme.spacingS + } + + StyledText { + text: "↑↓ Navigate • Tab Cycle • Enter Select • Esc Close" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + anchors.horizontalCenter: parent.horizontalCenter + opacity: 0.7 + } + + } + + } + + } + +} diff --git a/README.md b/README.md index e38a7725..409d53e2 100644 --- a/README.md +++ b/README.md @@ -301,6 +301,9 @@ binds { Super+Alt+L hotkey-overlay-title="Lock Screen" { spawn "qs" "-c" "dms" "ipc" "call" "lock" "lock"; } + Mod+X hotkey-overlay-title="Power Menu" { + spawn "qs" "-c" "dms" "ipc" "call" "powermenu" "toggle"; + } XF86AudioRaiseVolume allow-when-locked=true { spawn "qs" "-c" "dms" "ipc" "call" "audio" "increment" "3"; } @@ -351,6 +354,7 @@ bind = SUPER, M, exec, qs -c dms ipc call processlist toggle bind = SUPER, N, exec, qs -c dms ipc call notifications toggle bind = SUPER, comma, exec, qs -c dms ipc call settings toggle bind = SUPERALT, L, exec, qs -c dms ipc call lock lock +bind = SUPER, X, exec, qs -c dms ipc call powermenu toggle # Audio controls (function keys) bindl = , XF86AudioRaiseVolume, exec, qs -c dms ipc call audio increment 3 @@ -378,6 +382,7 @@ qs -c dms ipc call audio mute ```bash qs -c dms ipc call spotlight toggle qs -c dms ipc call processlist toggle +qs -c dms ipc call powermenu toggle ``` # System control ``` diff --git a/docs/IPC.md b/docs/IPC.md index cb5a4c63..6ae629ca 100644 --- a/docs/IPC.md +++ b/docs/IPC.md @@ -361,6 +361,14 @@ System process list and performance modal control. - `close` - Hide process list modal - `toggle` - Toggle process list modal visibility +### Target: `powermenu` +Power menu modal control for system power actions. + +**Functions:** +- `open` - Show power menu modal +- `close` - Hide power menu modal +- `toggle` - Toggle power menu modal visibility + ### Modal Examples ```bash # Open application launcher @@ -377,6 +385,9 @@ qs -c dms ipc call settings open # Show system monitor qs -c dms ipc call processlist toggle + +# Show power menu +qs -c dms ipc call powermenu toggle ``` ## Common Usage Patterns @@ -389,6 +400,7 @@ These IPC commands are designed to be used with window manager keybindings. Exam binds { Mod+Space { spawn "qs" "-c" "dms" "ipc" "call" "spotlight" "toggle"; } Mod+V { spawn "qs" "-c" "dms" "ipc" "call" "clipboard" "toggle"; } + Mod+X { spawn "qs" "-c" "dms" "ipc" "call" "powermenu" "toggle"; } XF86AudioRaiseVolume { spawn "qs" "-c" "dms" "ipc" "call" "audio" "increment" "3"; } XF86MonBrightnessUp { spawn "qs" "-c" "dms" "ipc" "call" "brightness" "increment" "5" ""; } } diff --git a/shell.qml b/shell.qml index 8a281b3b..bc395a83 100644 --- a/shell.qml +++ b/shell.qml @@ -3,22 +3,22 @@ import QtQuick import Quickshell import Quickshell.Io import Quickshell.Widgets +import qs.Common import qs.Modals import qs.Modules import qs.Modules.AppDrawer -import qs.Modules.OSD import qs.Modules.CentcomCenter import qs.Modules.ControlCenter import qs.Modules.ControlCenter.Network +import qs.Modules.Dock import qs.Modules.Lock import qs.Modules.Notifications.Center import qs.Modules.Notifications.Popup +import qs.Modules.OSD import qs.Modules.ProcessList import qs.Modules.Settings import qs.Modules.TopBar -import qs.Modules.Dock import qs.Services -import qs.Common ShellRoot { id: root @@ -27,7 +27,8 @@ ShellRoot { PortalService.init() } - WallpaperBackground {} + WallpaperBackground { + } Lock { id: lock @@ -41,6 +42,7 @@ ShellRoot { delegate: TopBar { modelData: item } + } Variants { @@ -49,40 +51,47 @@ ShellRoot { delegate: Dock { modelData: item contextMenu: dockContextMenuLoader.item ? dockContextMenuLoader.item : null - Component.onCompleted: { dockContextMenuLoader.active = true } } + } Loader { id: centcomPopoutLoader + active: false + sourceComponent: Component { CentcomPopout { id: centcomPopout } + } + } LazyLoader { id: dockContextMenuLoader + active: false DockContextMenu { id: dockContextMenu } - } + } LazyLoader { id: notificationCenterLoader + active: false NotificationCenterPopout { id: notificationCenter } + } Variants { @@ -91,87 +100,99 @@ ShellRoot { delegate: NotificationPopupManager { modelData: item } + } LazyLoader { id: controlCenterLoader + active: false ControlCenterPopout { id: controlCenterPopout onPowerActionRequested: (action, title, message) => { - powerConfirmModalLoader.active = true - if (powerConfirmModalLoader.item) { - powerConfirmModalLoader.item.show( - action, title, message) - } - } + powerConfirmModalLoader.active = true + if (powerConfirmModalLoader.item) + powerConfirmModalLoader.item.show(action, title, message) + } onLockRequested: { lock.activate() } } + } LazyLoader { id: wifiPasswordModalLoader + active: false WifiPasswordModal { id: wifiPasswordModal } + } LazyLoader { id: networkInfoModalLoader + active: false NetworkInfoModal { id: networkInfoModal } + } LazyLoader { id: batteryPopoutLoader + active: false BatteryPopout { id: batteryPopout } + } LazyLoader { id: powerMenuLoader + active: false PowerMenu { id: powerMenu + onPowerActionRequested: (action, title, message) => { - powerConfirmModalLoader.active = true - if (powerConfirmModalLoader.item) { - powerConfirmModalLoader.item.show( - action, title, message) - } - } + powerConfirmModalLoader.active = true + if (powerConfirmModalLoader.item) + powerConfirmModalLoader.item.show(action, title, message) + } } + } LazyLoader { id: powerConfirmModalLoader + active: false PowerConfirmModal { id: powerConfirmModal } + } LazyLoader { id: processListPopoutLoader + active: false ProcessListPopout { id: processListPopout } + } SettingsModal { @@ -180,11 +201,13 @@ ShellRoot { LazyLoader { id: appDrawerLoader + active: false AppDrawerPopout { id: appDrawerPopout } + } SpotlightModal { @@ -207,6 +230,51 @@ ShellRoot { ProcessListModal { id: processListModal } + + } + + LazyLoader { + id: powerMenuModalLoader + + active: false + + PowerMenuModal { + id: powerMenuModal + + onPowerActionRequested: (action, title, message) => { + powerConfirmModalLoader.active = true + if (powerConfirmModalLoader.item) + powerConfirmModalLoader.item.show(action, title, message) + } + } + + } + + IpcHandler { + function open() { + powerMenuModalLoader.active = true + if (powerMenuModalLoader.item) + powerMenuModalLoader.item.open() + + return "POWERMENU_OPEN_SUCCESS" + } + + function close() { + if (powerMenuModalLoader.item) + powerMenuModalLoader.item.close() + + return "POWERMENU_CLOSE_SUCCESS" + } + + function toggle() { + powerMenuModalLoader.active = true + if (powerMenuModalLoader.item) + powerMenuModalLoader.item.toggle() + + return "POWERMENU_TOGGLE_SUCCESS" + } + + target: "powermenu" } IpcHandler { @@ -243,6 +311,7 @@ ShellRoot { modelData: item visible: ToastService.toastVisible } + } Variants { @@ -251,17 +320,16 @@ ShellRoot { delegate: VolumeOSD { modelData: item } + } - - - Variants { model: SettingsData.getFilteredScreens("osd") delegate: MicMuteOSD { modelData: item } + } Variants { @@ -270,6 +338,7 @@ ShellRoot { delegate: BrightnessOSD { modelData: item } + } Variants { @@ -278,5 +347,7 @@ ShellRoot { delegate: IdleInhibitorOSD { modelData: item } + } -} \ No newline at end of file + +}