533 lines
16 KiB
QML
533 lines
16 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 resolveTrayIconSource(iconValue) {
|
|
const normalized = String(iconValue || "").trim();
|
|
if (normalized.length === 0)
|
|
return "";
|
|
if (normalized.indexOf("/") >= 0 || normalized.indexOf("file://") === 0)
|
|
return normalized;
|
|
return String(Quickshell.iconPath(normalized, true) || "");
|
|
}
|
|
|
|
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
|
|
readonly property string trayIconSource: root.resolveTrayIconSource(trayItem ? trayItem.icon : "")
|
|
|
|
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: trayIconSource
|
|
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()
|
|
}
|