diff --git a/Shell/Overlay.qml b/Shell/Overlay.qml index df94929..c047021 100644 --- a/Shell/Overlay.qml +++ b/Shell/Overlay.qml @@ -5,6 +5,7 @@ import Quickshell.Hyprland import "Windows/QuickSettings" import "Windows/AppLauncher" +import "Windows/PowerMenu" PanelWindow { id: overlay @@ -76,4 +77,9 @@ PanelWindow { id: appLauncherWindow manager: ShellStateManager } + + PowerMenu { + id: powerMenuWindow + manager: ShellStateManager + } } diff --git a/Shell/ShellInputManager.qml b/Shell/ShellInputManager.qml index 732456f..650edb0 100644 --- a/Shell/ShellInputManager.qml +++ b/Shell/ShellInputManager.qml @@ -35,6 +35,8 @@ QtObject { context = "appLauncher"; else if (ShellStateManager.quickSettingsOpen) context = "quickSettings"; + else if (ShellStateManager.powerMenuOpen) + context = "powerMenu"; var handler = handlers[context]; if (handler) { var handled = handler(key); @@ -47,6 +49,8 @@ QtObject { ShellStateManager.closeAppLauncher(); } else if (context === "quickSettings") { ShellStateManager.closeQuickSettings(); + } else if (context === "powerMenu") { + ShellStateManager.closePowerMenu(); } else if (context === "topbar") { ShellStateManager.closeShell(); Hyprland.dispatch("submap reset"); diff --git a/Shell/ShellStateManager.qml b/Shell/ShellStateManager.qml index e1c1821..62af2eb 100644 --- a/Shell/ShellStateManager.qml +++ b/Shell/ShellStateManager.qml @@ -10,6 +10,7 @@ QtObject { property bool shellOpen: false property bool quickSettingsOpen: false property bool appLauncherOpen: false + property bool powerMenuOpen: false property var appUsageCounts: ({}) property string appUsagePath: { var homeDir = Quickshell.env("HOME"); @@ -26,6 +27,8 @@ QtObject { signal quickSettingsClosed signal appLauncherOpened signal appLauncherClosed + signal powerMenuOpened + signal powerMenuClosed signal windowRequested(string name, var payload) function openShell() { @@ -47,6 +50,9 @@ QtObject { if (appLauncherOpen) { closeAppLauncher(); } + if (powerMenuOpen) { + closePowerMenu(); + } shellOpen = false; shellClosed(); } @@ -72,6 +78,9 @@ QtObject { if (appLauncherOpen) { closeAppLauncher(); } + if (powerMenuOpen) { + closePowerMenu(); + } if (!quickSettingsOpen) { quickSettingsOpen = true; quickSettingsOpened(); @@ -97,6 +106,9 @@ QtObject { if (quickSettingsOpen) { closeQuickSettings(); } + if (powerMenuOpen) { + closePowerMenu(); + } if (!appLauncherOpen) { appLauncherOpen = true; appLauncherOpened(); @@ -116,6 +128,34 @@ QtObject { appLauncherOpen ? closeAppLauncher() : openAppLauncher(payload); } + function openPowerMenu(payload) { + var normalizedPayload = payload || ({}); + console.log("ShellStateManager: openPowerMenu", normalizedPayload); + if (quickSettingsOpen) { + closeQuickSettings(); + } + if (appLauncherOpen) { + closeAppLauncher(); + } + if (!powerMenuOpen) { + powerMenuOpen = true; + powerMenuOpened(); + } + requestWindow("powerMenu", normalizedPayload); + } + + function closePowerMenu() { + if (powerMenuOpen) { + console.log("ShellStateManager: closePowerMenu"); + powerMenuOpen = false; + powerMenuClosed(); + } + } + + function togglePowerMenu(payload) { + powerMenuOpen ? closePowerMenu() : openPowerMenu(payload); + } + function bumpAppUsage(id) { if (!id) return; diff --git a/Shell/Topbar/Topbar.qml b/Shell/Topbar/Topbar.qml index 96bfb8b..7b63647 100644 --- a/Shell/Topbar/Topbar.qml +++ b/Shell/Topbar/Topbar.qml @@ -55,6 +55,12 @@ Item { }); return true; } + if (iconSources[selectedIndex] === "power.png") { + ShellStateManager.openPowerMenu({ + source: "Topbar" + }); + return true; + } } return false; } @@ -90,7 +96,7 @@ Item { anchors.centerIn: parent selected: topbar.selectedIndex == repeatitem.index iconSource: topbar.iconSources[repeatitem.index] - showSoul: !topbar.manager.quickSettingsOpen && !topbar.manager.appLauncherOpen + showSoul: !topbar.manager.quickSettingsOpen && !topbar.manager.appLauncherOpen && !topbar.manager.powerMenuOpen } MouseArea { @@ -107,6 +113,11 @@ Item { source: "Topbar" }); } + if (topbar.iconSources[repeatitem.index] === "power.png") { + ShellStateManager.openPowerMenu({ + source: "Topbar" + }); + } } } } diff --git a/Shell/Windows/PowerMenu/PowerMenu.qml b/Shell/Windows/PowerMenu/PowerMenu.qml new file mode 100644 index 0000000..da85f96 --- /dev/null +++ b/Shell/Windows/PowerMenu/PowerMenu.qml @@ -0,0 +1,44 @@ +import QtQuick +import "../.." +import "../../Window" as ShellWindow + +ShellWindow.Window { + id: powerMenuWindow + property var manager: ShellStateManager + + property var powerMenuApp: null + + width: 1217 + 52 + height: 767 + 52 + visible: manager ? manager.powerMenuOpen : false + anchors.centerIn: parent + + Loader { + id: powerMenuLoader + anchors.fill: parent + asynchronous: true + source: "./PowerMenuApp.qml" + onLoaded: { + powerMenuApp = item; + if (powerMenuApp) + powerMenuApp.manager = manager; + } + } + + onManagerChanged: { + if (powerMenuApp) + powerMenuApp.manager = manager; + } + + QtObject { + id: powerMenuKeyHandler + function handle(key) { + if (powerMenuApp && powerMenuApp.handleKey) + return powerMenuApp.handleKey(key); + return false; + } + + Component.onCompleted: ShellInputManager.registerHandler("powerMenu", handle) + Component.onDestruction: ShellInputManager.unregisterHandler("powerMenu") + } +} diff --git a/Shell/Windows/PowerMenu/PowerMenuApp.qml b/Shell/Windows/PowerMenu/PowerMenuApp.qml new file mode 100644 index 0000000..50afb2c --- /dev/null +++ b/Shell/Windows/PowerMenu/PowerMenuApp.qml @@ -0,0 +1,522 @@ +import QtQuick +import Quickshell +import Quickshell.Services.SystemTray +import Quickshell.Widgets +import "../.." + +Item { + id: root + width: parent ? parent.width : 1280 + height: parent ? parent.height : 820 + focus: true + + property ShellStateManager manager: null + + // ITEM/APPS-like layout constants + property int menuTop: 140 + property int lineHeight: 79 + property int iconSize: 36 + property int textStartX: 239 + property int textRightPadding: 96 + property int visibleRows: Math.max(1, Math.floor((height - menuTop - 64) / lineHeight)) + + property bool inSubmenu: false + property int traySelection: 0 + property int submenuSelection: 0 + property int scrollOffset: 0 + property var menuStack: [] + property var currentMenuHandle: null + property var submenuSourceTrayItem: null + property int submenuPollTicks: 0 + + readonly property int activeSelection: inSubmenu ? submenuSelection : traySelection + + ListModel { + id: submenuData + } + + QsMenuOpener { + id: opener + menu: root.currentMenuHandle + } + + // Raw source entries from current DBus menu (hidden) + Repeater { + id: submenuSourceRepeater + model: opener.children + + delegate: Item { + visible: false + width: 0 + height: 0 + property var entry: modelData + } + } + + Timer { + id: submenuPollTimer + interval: 120 + repeat: true + running: false + onTriggered: { + if (!root.currentMenuHandle && root.submenuSourceTrayItem && root.submenuSourceTrayItem.menu) + root.currentMenuHandle = root.submenuSourceTrayItem.menu; + + root.rebuildSubmenuData(); + root.submenuPollTicks = root.submenuPollTicks + 1; + if (submenuData.count > 0 || root.submenuPollTicks >= 20) + stop(); + } + } + + function closeAll() { + if (manager && manager.closeShell) + manager.closeShell(); + } + + function clamp(v, lo, hi) { + return Math.max(lo, Math.min(hi, v)); + } + + function trayCount() { + return trayRepeater ? trayRepeater.count : 0; + } + + function submenuCount() { + return submenuData.count; + } + + function currentCount() { + return inSubmenu ? submenuCount() : trayCount(); + } + + function wrapIndex(i) { + const length = currentCount(); + if (length === 0) + return 0; + return (i + length) % length; + } + + function selectedTrayDelegate() { + if (trayCount() <= 0) + return null; + return trayRepeater.itemAt(traySelection); + } + + function selectedTrayItem() { + const delegate = selectedTrayDelegate(); + return delegate ? delegate.trayItem : null; + } + + function selectedMenuEntry() { + if (submenuSelection < 0 || submenuSelection >= submenuData.count) + return null; + const row = submenuData.get(submenuSelection); + return row ? row.entry : null; + } + + function normalizeSelection() { + const length = currentCount(); + if (length <= 0) { + if (inSubmenu) + submenuSelection = 0; + else + traySelection = 0; + scrollOffset = 0; + return; + } + + if (inSubmenu) + submenuSelection = clamp(submenuSelection, 0, length - 1); + else + traySelection = clamp(traySelection, 0, length - 1); + + const currentSelection = activeSelection; + const maxOffset = Math.max(0, length - visibleRows); + + if (currentSelection < scrollOffset) + scrollOffset = currentSelection; + else if (currentSelection >= scrollOffset + visibleRows) + scrollOffset = Math.max(0, currentSelection - visibleRows + 1); + + scrollOffset = clamp(scrollOffset, 0, maxOffset); + } + + function rebuildSubmenuData() { + submenuData.clear(); + + const count = submenuSourceRepeater ? submenuSourceRepeater.count : 0; + for (let i = 0; i < count; i++) { + const sourceItem = submenuSourceRepeater.itemAt(i); + const entry = sourceItem ? sourceItem.entry : null; + if (!entry || entry.isSeparator) + continue; + + submenuData.append({ + entry: entry + }); + } + + submenuSelection = clamp(submenuSelection, 0, Math.max(0, submenuData.count - 1)); + normalizeSelection(); + } + + function enterSubmenu(menuHandle) { + currentMenuHandle = menuHandle; + inSubmenu = true; + submenuSelection = 0; + scrollOffset = 0; + submenuData.clear(); + submenuPollTicks = 0; + Qt.callLater(function () { + rebuildSubmenuData(); + submenuPollTimer.restart(); + }); + } + + function openSelectedTraySubmenu() { + const trayItem = selectedTrayItem(); + if (!trayItem) + return; + + if (!trayItem.hasMenu) { + trayItem.activate(); + closeAll(); + return; + } + + menuStack = []; + submenuSourceTrayItem = trayItem; + enterSubmenu(trayItem.menu); + } + + function triggerSelectedSubmenuEntry() { + const entry = selectedMenuEntry(); + if (!entry) + return; + + if (entry.hasChildren) { + const stackCopy = menuStack.slice(0); + stackCopy.push(currentMenuHandle); + menuStack = stackCopy; + submenuSourceTrayItem = null; + enterSubmenu(entry); + return; + } + + entry.triggered(); + closeAll(); + } + + function backAction() { + if (inSubmenu) { + if (menuStack.length > 0) { + const stackCopy = menuStack.slice(0); + const parentHandle = stackCopy.pop(); + menuStack = stackCopy; + submenuSourceTrayItem = null; + enterSubmenu(parentHandle); + } else { + inSubmenu = false; + currentMenuHandle = null; + submenuSourceTrayItem = null; + submenuData.clear(); + submenuPollTimer.stop(); + scrollOffset = 0; + normalizeSelection(); + } + return; + } + + if (manager && manager.closePowerMenu) + manager.closePowerMenu(); + } + + function handleKey(key) { + switch (key) { + case Qt.Key_Up: + if (inSubmenu) + submenuSelection = wrapIndex(submenuSelection - 1); + else + traySelection = wrapIndex(traySelection - 1); + normalizeSelection(); + return true; + case Qt.Key_Down: + if (inSubmenu) + submenuSelection = wrapIndex(submenuSelection + 1); + else + traySelection = wrapIndex(traySelection + 1); + normalizeSelection(); + return true; + case Qt.Key_Z: + case Qt.Key_Return: + case Qt.Key_Enter: + if (inSubmenu) + triggerSelectedSubmenuEntry(); + else + openSelectedTraySubmenu(); + return true; + case Qt.Key_X: + case Qt.Key_Shift: + case Qt.Key_Escape: + backAction(); + return true; + } + + return false; + } + + Connections { + target: manager + ignoreUnknownSignals: true + function onPowerMenuOpened() { + root.inSubmenu = false; + root.currentMenuHandle = null; + root.submenuSourceTrayItem = null; + root.menuStack = []; + root.submenuPollTicks = 0; + submenuPollTimer.stop(); + submenuData.clear(); + root.scrollOffset = 0; + root.traySelection = 0; + root.submenuSelection = 0; + root.normalizeSelection(); + } + } + + Connections { + target: SystemTray.items + ignoreUnknownSignals: true + function onCountChanged() { + root.normalizeSelection(); + } + function onModelReset() { + root.normalizeSelection(); + } + function onRowsInserted() { + root.normalizeSelection(); + } + function onRowsRemoved() { + root.normalizeSelection(); + } + } + + Connections { + target: opener.children + ignoreUnknownSignals: true + function onCountChanged() { + root.rebuildSubmenuData(); + } + function onModelReset() { + root.rebuildSubmenuData(); + } + function onRowsInserted() { + root.rebuildSubmenuData(); + } + function onRowsRemoved() { + root.rebuildSubmenuData(); + } + } + + Text { + text: inSubmenu ? "OPTIONS" : "POWER" + font.family: "8bitoperator JVE" + font.pixelSize: 71 + renderType: Text.NativeRendering + font.hintingPreference: Font.PreferNoHinting + smooth: false + antialiasing: false + anchors.horizontalCenter: parent.horizontalCenter + color: "#ffffff" + y: 32 + } + + Repeater { + id: trayRepeater + model: SystemTray.items + + delegate: Item { + width: root.width + height: root.lineHeight + x: 0 + y: root.menuTop + (index - root.scrollOffset) * root.lineHeight + visible: !root.inSubmenu && index >= root.scrollOffset && index < root.scrollOffset + root.visibleRows + + property var trayItem: modelData + + Text { + x: root.textStartX + y: 0 + text: trayItem && trayItem.tooltipTitle ? trayItem.tooltipTitle : (trayItem ? trayItem.title : "") + width: root.width - root.textStartX - root.textRightPadding + font.family: "8bitoperator JVE" + font.pixelSize: 71 + font.letterSpacing: 1 + renderType: Text.NativeRendering + font.hintingPreference: Font.PreferNoHinting + smooth: false + antialiasing: false + wrapMode: Text.NoWrap + elide: Text.ElideRight + color: root.activeSelection == index ? "#fefe00" : "#ffffff" + } + + IconImage { + id: trayIcon + x: 182 + y: 8 + 14 + implicitSize: root.iconSize + source: trayItem ? trayItem.icon : "" + asynchronous: true + } + + Image { + source: "../QuickSettings/soul.png" + width: 36 + height: 36 + x: trayIcon.x + y: trayIcon.y + opacity: 0.7 + visible: root.activeSelection == index + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: { + root.traySelection = index; + root.normalizeSelection(); + } + onClicked: { + root.traySelection = index; + root.normalizeSelection(); + root.openSelectedTraySubmenu(); + } + onWheel: function (wheel) { + if (!trayItem) + return; + const horizontal = Math.abs(wheel.angleDelta.x) > Math.abs(wheel.angleDelta.y); + const delta = horizontal ? wheel.angleDelta.x : wheel.angleDelta.y; + trayItem.scroll(delta, horizontal); + wheel.accepted = true; + } + } + } + } + + Repeater { + id: submenuRepeater + model: submenuData + + delegate: Item { + property var entry: model.entry + width: root.width + height: root.lineHeight + x: 0 + y: root.menuTop + (index - root.scrollOffset) * root.lineHeight + visible: root.inSubmenu && index >= root.scrollOffset && index < root.scrollOffset + root.visibleRows + + Text { + x: root.textStartX + y: 0 + width: root.width - root.textStartX - root.textRightPadding + text: { + if (!entry) + return ""; + + let prefix = ""; + if (entry.buttonType === QsMenuButtonType.CheckBox) + prefix = entry.checkState === Qt.Checked ? "[x] " : "[ ] "; + else if (entry.buttonType === QsMenuButtonType.RadioButton) + prefix = entry.checkState === Qt.Checked ? "(x) " : "( ) "; + + return prefix + String(entry.text || "") + (entry.hasChildren ? " >" : ""); + } + font.family: "8bitoperator JVE" + font.pixelSize: 71 + font.letterSpacing: 1 + renderType: Text.NativeRendering + font.hintingPreference: Font.PreferNoHinting + smooth: false + antialiasing: false + wrapMode: Text.NoWrap + elide: Text.ElideRight + color: root.activeSelection == index ? "#fefe00" : "#ffffff" + } + + Image { + source: "../QuickSettings/soul.png" + width: 36 + height: 36 + x: 182 + y: 8 + 14 + opacity: 0.7 + visible: root.activeSelection == index + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: { + root.submenuSelection = index; + root.normalizeSelection(); + } + onClicked: { + root.submenuSelection = index; + root.normalizeSelection(); + root.triggerSelectedSubmenuEntry(); + } + } + } + } + + WheelHandler { + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + onWheel: function (event) { + const count = root.currentCount(); + if (count <= 0) + return; + + const delta = event.angleDelta.y > 0 ? -1 : 1; + if (root.inSubmenu) + root.submenuSelection = root.wrapIndex(root.submenuSelection + delta); + else + root.traySelection = root.wrapIndex(root.traySelection + delta); + + root.normalizeSelection(); + event.accepted = true; + } + } + + Text { + visible: !root.inSubmenu && trayCount() === 0 + text: "NO TRAY ITEMS" + font.family: "8bitoperator JVE" + font.pixelSize: 54 + font.letterSpacing: 1 + renderType: Text.NativeRendering + font.hintingPreference: Font.PreferNoHinting + smooth: false + antialiasing: false + color: "#ffffff" + anchors.horizontalCenter: parent.horizontalCenter + y: 302 + } + + Text { + visible: root.inSubmenu && !submenuPollTimer.running && submenuCount() === 0 + text: "NO OPTIONS" + font.family: "8bitoperator JVE" + font.pixelSize: 54 + font.letterSpacing: 1 + renderType: Text.NativeRendering + font.hintingPreference: Font.PreferNoHinting + smooth: false + antialiasing: false + color: "#ffffff" + anchors.horizontalCenter: parent.horizontalCenter + y: 302 + } + + Component.onCompleted: normalizeSelection() +}