This commit is contained in:
2026-03-01 11:43:25 +02:00
parent 9762553b38
commit 8fa89bb99c
6 changed files with 628 additions and 1 deletions

View File

@@ -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
}
}

View File

@@ -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");

View File

@@ -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;

View File

@@ -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"
});
}
}
}
}

View File

@@ -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")
}
}

View File

@@ -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()
}