import QtQuick import QtQuick.Controls import QtQuick.Layouts import Quickshell import qs.Commons // Simple context menu PopupWindow (similar to TrayMenu) // Designed to be rendered inside a PopupMenuWindow for click-outside-to-close // Automatically positions itself to respect screen boundaries PopupWindow { id: root property alias model: repeater.model property real itemHeight: 28 // Match TrayMenu property real itemPadding: Style.marginM property int verticalPolicy: ScrollBar.AsNeeded property int horizontalPolicy: ScrollBar.AsNeeded property var anchorItem: null property ShellScreen screen: null property real minWidth: 120 property real calculatedWidth: 180 readonly property string barPosition: Settings.data.bar.position signal triggered(string action, var item) implicitWidth: calculatedWidth implicitHeight: Math.min(600, flickable.contentHeight + (Style.marginS * 2)) visible: false color: "transparent" NText { id: textMeasure visible: false pointSize: Style.fontSizeS wrapMode: Text.NoWrap elide: Text.ElideNone width: undefined } NIcon { id: iconMeasure visible: false icon: "bell" pointSize: Style.fontSizeS applyUiScale: false } onModelChanged: { Qt.callLater(calculateWidth); } function calculateWidth() { let maxWidth = 0; if (model && model.length) { for (let i = 0; i < model.length; i++) { const item = model[i]; if (item && item.visible !== false) { const label = item.label || item.text || ""; textMeasure.text = label; textMeasure.forceLayout(); let itemWidth = textMeasure.contentWidth + 8; if (item.icon !== undefined) { itemWidth += iconMeasure.width + Style.marginS; } itemWidth += Style.marginM * 2; if (itemWidth > maxWidth) { maxWidth = itemWidth; } } } } calculatedWidth = Math.max(maxWidth + (Style.marginS * 2), minWidth); } anchor.item: anchorItem anchor.rect.x: { if (anchorItem && screen) { const anchorGlobalPos = anchorItem.mapToItem(null, 0, 0); // For right bar: position menu to the left of anchor if (root.barPosition === "right") { let baseX = -implicitWidth - Style.marginM; return baseX; } // For left bar: position menu to the right of anchor if (root.barPosition === "left") { let baseX = anchorItem.width + Style.marginM; return baseX; } // For top/bottom bar: center horizontally on anchor const anchorCenterX = anchorItem.width / 2; let baseX = anchorCenterX - (implicitWidth / 2); // Calculate menu position on screen const menuScreenX = anchorGlobalPos.x + baseX; const menuRight = menuScreenX + implicitWidth; // Adjust if menu would clip on the right if (menuRight > screen.width - Style.marginM) { const overflow = menuRight - (screen.width - Style.marginM); return baseX - overflow; } // Adjust if menu would clip on the left if (menuScreenX < Style.marginM) { return baseX + (Style.marginM - menuScreenX); } return baseX; } return 0; } anchor.rect.y: { if (anchorItem && screen) { // Check if using absolute positioning (small anchor point item) const isAbsolutePosition = anchorItem.width <= 1 && anchorItem.height <= 1; if (isAbsolutePosition) { // For absolute positioning, show menu directly at anchor Y // Only adjust if menu would clip at bottom const anchorGlobalPos = anchorItem.mapToItem(null, 0, 0); const menuBottom = anchorGlobalPos.y + implicitHeight; if (menuBottom > screen.height - Style.marginM) { // Position above the click point instead return -implicitHeight; } return 0; } const anchorCenterY = anchorItem.height / 2; // Calculate base Y position based on bar orientation let baseY; if (root.barPosition === "bottom") { // For bottom bar: position menu above the bar baseY = -(implicitHeight + Style.marginM); } else if (root.barPosition === "top") { // For top bar: position menu below bar baseY = Style.barHeight + Style.marginM; } else { // For left/right bar: vertically center on anchor baseY = anchorCenterY - (implicitHeight / 2); } // Calculate menu position on screen const anchorGlobalPos = anchorItem.mapToItem(null, 0, 0); const menuScreenY = anchorGlobalPos.y + baseY; const menuBottom = menuScreenY + implicitHeight; // Adjust if menu would clip at bottom if (menuBottom > screen.height - Style.marginM) { const overflow = menuBottom - (screen.height - Style.marginM); return baseY - overflow; } return baseY; } // Fallback if no screen if (root.barPosition === "bottom") { return -implicitHeight - Style.marginM; } return Style.barHeight; } Component.onCompleted: { Qt.callLater(calculateWidth); } Item { anchors.fill: parent focus: true Keys.onEscapePressed: root.close() } Rectangle { id: menuBackground anchors.fill: parent color: Color.mSurface border.color: Color.mOutline border.width: Style.borderS radius: Style.iRadiusM opacity: root.visible ? 1.0 : 0.0 Behavior on opacity { NumberAnimation { duration: Style.animationNormal easing.type: Easing.OutQuad } } } Flickable { id: flickable anchors.fill: parent anchors.margins: Style.marginS contentHeight: columnLayout.implicitHeight interactive: true opacity: root.visible ? 1.0 : 0.0 Behavior on opacity { NumberAnimation { duration: Style.animationNormal easing.type: Easing.OutQuad } } ColumnLayout { id: columnLayout width: flickable.width spacing: 0 Repeater { id: repeater delegate: Rectangle { id: menuItem required property var modelData required property int index Layout.preferredWidth: parent.width Layout.preferredHeight: modelData.visible !== false ? root.itemHeight : 0 visible: modelData.visible !== false color: "transparent" Rectangle { id: innerRect anchors.fill: parent color: mouseArea.containsMouse ? Color.mHover : "transparent" radius: Style.iRadiusS opacity: modelData.enabled !== false ? 1.0 : 0.5 Behavior on color { ColorAnimation { duration: Style.animationFast } } RowLayout { anchors.fill: parent anchors.leftMargin: Style.marginM anchors.rightMargin: Style.marginM spacing: Style.marginS NIcon { visible: modelData.icon !== undefined icon: modelData.icon || "" pointSize: Style.fontSizeS applyUiScale: false color: mouseArea.containsMouse ? Color.mOnHover : Color.mOnSurface verticalAlignment: Text.AlignVCenter Behavior on color { ColorAnimation { duration: Style.animationFast } } } NText { text: modelData.label || modelData.text || "" pointSize: Style.fontSizeS color: mouseArea.containsMouse ? Color.mOnHover : Color.mOnSurface verticalAlignment: Text.AlignVCenter Layout.fillWidth: true Behavior on color { ColorAnimation { duration: Style.animationFast } } } } MouseArea { id: mouseArea anchors.fill: parent hoverEnabled: true enabled: (modelData.enabled !== false) && root.visible cursorShape: Qt.PointingHandCursor onClicked: { if (menuItem.modelData.enabled !== false) { root.triggered(menuItem.modelData.action || menuItem.modelData.key || menuItem.index.toString(), menuItem.modelData); // Don't call root.close() here - let the parent PopupMenuWindow handle closing } } } } } } } } // Helper function to open context menu anchored to an item // Position is calculated automatically based on bar position and screen boundaries function openAtItem(item, itemScreen) { if (!item) { Logger.w("NPopupContextMenu", "anchorItem is undefined, won't show menu."); return; } calculateWidth(); anchorItem = item; screen = itemScreen || null; visible = true; } function close() { visible = false; } function closeMenu() { close(); } }