Files
DeltaruneQuickshell/Shell/Windows/PowerMenu/PowerMenuApp.qml
2026-03-01 11:43:25 +02:00

523 lines
15 KiB
QML

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