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 // Explicit offset for centering on target item (computed from targetItem in openAtItem) property real targetOffsetX: 0 property real targetOffsetY: 0 property real targetWidth: 0 property real targetHeight: 0 readonly property string barPosition: Settings.getBarPositionForScreen(screen?.name) readonly property real barHeight: Style.getBarHeightForScreen(screen?.name) signal triggered(string action, var item) implicitWidth: calculatedWidth implicitHeight: Math.min(600, flickable.contentHeight + Style.margin2S) 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.margin2M; if (itemWidth > maxWidth) { maxWidth = itemWidth; } } } } calculatedWidth = Math.max(maxWidth + Style.margin2S, minWidth); } anchor.item: anchorItem anchor.rect.x: { if (anchorItem && screen) { const anchorGlobalPos = anchorItem.mapToItem(null, 0, 0); // Use stored targetOffsetX and targetWidth for positioning const effectiveWidth = targetWidth > 0 ? targetWidth : anchorItem.width; const targetGlobalX = anchorGlobalPos.x + targetOffsetX; // For right bar: position menu to the left of target if (root.barPosition === "right") { let baseX = targetOffsetX - implicitWidth - Style.marginM; return baseX; } // For left bar: position menu to the right of target if (root.barPosition === "left") { let baseX = targetOffsetX + effectiveWidth + Style.marginM; return baseX; } // For top/bottom bar: center horizontally on target const targetCenterScreenX = targetGlobalX + (effectiveWidth / 2); const menuScreenX = targetCenterScreenX - (implicitWidth / 2); let baseX = menuScreenX - anchorGlobalPos.x; 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 anchorGlobalPos = anchorItem.mapToItem(null, 0, 0); // Use target offset/height for vertical bars, anchor dimensions otherwise const effectiveHeight = targetHeight > 0 ? targetHeight : anchorItem.height; const effectiveOffsetY = targetOffsetY; // 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.marginS); } else if (root.barPosition === "top") { // For top bar: position menu below bar at consistent height // Compensate for anchor's Y position to ensure menu top is always at (barHeight + margin) baseY = barHeight + Style.marginS - anchorGlobalPos.y; } else { // For left/right bar: vertically center on target item const targetCenterY = effectiveOffsetY + (effectiveHeight / 2); baseY = targetCenterY - (implicitHeight / 2); } const menuScreenY = anchorGlobalPos.y + baseY; const menuBottom = menuScreenY + implicitHeight; // Define clipping boundaries based on bar position const topLimit = Style.marginM; const bottomLimit = root.barPosition === "bottom" ? screen.height - barHeight - Style.marginS : screen.height - Style.marginM; // Adjust if menu would clip at top (skip for bottom bar - don't push menu down over bar) if (menuScreenY < topLimit && root.barPosition !== "bottom") { const adjustment = topLimit - menuScreenY; return baseY + adjustment; } // Adjust if menu would clip at bottom (or overlap bar for bottom bar) if (menuBottom > bottomLimit) { const overflow = menuBottom - bottomLimit; return baseY - overflow; } return baseY; } // Fallback if no screen if (root.barPosition === "bottom") { return -implicitHeight - Style.marginS; } return 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.radiusM 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.radiusS 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 // Optional centerOnItem: if provided, menu will be horizontally centered on this item instead of anchorItem function openAtItem(item, itemScreen, centerOnItem) { if (!item) { Logger.w("NPopupContextMenu", "anchorItem is undefined, won't show menu."); return; } // Set anchor and screen first anchorItem = item; screen = itemScreen || null; // Compute target offset and dimensions from centerOnItem if (centerOnItem && centerOnItem !== item) { const relPos = centerOnItem.mapToItem(item, 0, 0); targetOffsetX = relPos.x; targetOffsetY = relPos.y; targetWidth = centerOnItem.width; targetHeight = centerOnItem.height; } else { targetOffsetX = 0; targetOffsetY = 0; targetWidth = 0; targetHeight = 0; } // Calculate menu width after anchor is set calculateWidth(); visible = true; // Force anchor recalculation after showing Qt.callLater(() => { anchor.updateAnchor(); }); } function close() { visible = false; } function closeMenu() { close(); } }