From 3283aacf9bfe34afe52e044742057205c75ea49b Mon Sep 17 00:00:00 2001 From: ItsLemmy Date: Mon, 17 Nov 2025 20:35:45 -0500 Subject: [PATCH] BarWidgets: First pass on contextual widget menu accessible from right-click. Testing on volume widget for now. --- Assets/Translations/en.json | 5 + Modules/Bar/Extras/TrayMenu.qml | 11 +- Modules/Bar/Widgets/Tray.qml | 42 ++--- Modules/Bar/Widgets/Volume.qml | 58 ++++++- Modules/MainScreen/AllScreens.qml | 6 +- Modules/MainScreen/PopupMenuWindow.qml | 92 +++++++++++ Modules/MainScreen/TrayMenuWindow.qml | 63 -------- Modules/Panels/Tray/TrayDrawerPanel.qml | 26 +-- Services/UI/BarService.qml | 50 ++++++ Services/UI/PanelService.qml | 22 +-- Widgets/NPopupContextMenu.qml | 201 ++++++++++++++++++++++++ 11 files changed, 452 insertions(+), 124 deletions(-) create mode 100644 Modules/MainScreen/PopupMenuWindow.qml delete mode 100644 Modules/MainScreen/TrayMenuWindow.qml create mode 100644 Widgets/NPopupContextMenu.qml diff --git a/Assets/Translations/en.json b/Assets/Translations/en.json index c21b19e15..85ab70c13 100644 --- a/Assets/Translations/en.json +++ b/Assets/Translations/en.json @@ -387,6 +387,11 @@ "title": "Bluetooth" } }, + "context-menu": { + "open-mixer": "Audio mixer", + "toggle-mute": "Toggle mute", + "widget-settings": "Widget settings" + }, "calendar": { "panel": { "week": "Week" diff --git a/Modules/Bar/Extras/TrayMenu.qml b/Modules/Bar/Extras/TrayMenu.qml index 84d181d6f..2f72abcb7 100644 --- a/Modules/Bar/Extras/TrayMenu.qml +++ b/Modules/Bar/Extras/TrayMenu.qml @@ -98,13 +98,6 @@ PopupWindow { } } - // Full-sized, transparent MouseArea to track the mouse. - MouseArea { - id: rootMouseArea - anchors.fill: parent - hoverEnabled: true - } - Item { anchors.fill: parent Keys.onEscapePressed: root.hideMenu() @@ -186,7 +179,7 @@ PopupWindow { Rectangle { id: innerRect anchors.fill: parent - color: mouseArea.containsMouse ? Color.mTertiary : Color.transparent + color: mouseArea.containsMouse ? Color.mHover : Color.transparent radius: Style.radiusS visible: !(modelData?.isSeparator ?? false) @@ -199,7 +192,7 @@ PopupWindow { NText { id: text Layout.fillWidth: true - color: (modelData?.enabled ?? true) ? (mouseArea.containsMouse ? Color.mOnTertiary : Color.mOnSurface) : Color.mOnSurfaceVariant + color: (modelData?.enabled ?? true) ? (mouseArea.containsMouse ? Color.mOnHover : Color.mOnSurface) : Color.mOnSurfaceVariant text: modelData?.text !== "" ? modelData?.text.replace(/[\n\r]+/g, ' ') : "..." pointSize: Style.fontSizeS verticalAlignment: Text.AlignVCenter diff --git a/Modules/Bar/Widgets/Tray.qml b/Modules/Bar/Widgets/Tray.qml index 32779925b..a3915b4c2 100644 --- a/Modules/Bar/Widgets/Tray.qml +++ b/Modules/Bar/Widgets/Tray.qml @@ -16,22 +16,22 @@ Rectangle { property ShellScreen screen // Trigger re-evaluation when window is registered - property int trayMenuUpdateTrigger: 0 + property int popupMenuUpdateTrigger: 0 - // Get shared tray menu window from PanelService (reactive to trigger changes) - readonly property var trayMenuWindow: { + // Get shared popup menu window from PanelService (reactive to trigger changes) + readonly property var popupMenuWindow: { // Reference trigger to force re-evaluation - var _ = trayMenuUpdateTrigger; - return PanelService.getTrayMenuWindow(screen); + var _ = popupMenuUpdateTrigger; + return PanelService.getPopupMenuWindow(screen); } - readonly property var trayMenu: trayMenuWindow ? trayMenuWindow.trayMenuLoader : null + readonly property var trayMenu: popupMenuWindow ? popupMenuWindow.trayMenuLoader : null Connections { target: PanelService - function onTrayMenuWindowRegistered(registeredScreen) { + function onPopupMenuWindowRegistered(registeredScreen) { if (registeredScreen === screen) { - root.trayMenuUpdateTrigger++; + root.popupMenuUpdateTrigger++; } } } @@ -183,9 +183,9 @@ Rectangle { function toggleDrawer(button) { TooltipService.hideImmediately(); - // Close the tray menu if it's open - if (trayMenuWindow && trayMenuWindow.visible) { - trayMenuWindow.close(); + // Close the popup menu if it's open + if (popupMenuWindow && popupMenuWindow.visible) { + popupMenuWindow.close(); } const panel = PanelService.getPanel("trayDrawerPanel", root.screen); @@ -320,8 +320,8 @@ Rectangle { if (mouse.button === Qt.LeftButton) { // Close any open menu first - if (trayMenuWindow) { - trayMenuWindow.close(); + if (popupMenuWindow) { + popupMenuWindow.close(); } if (!modelData.onlyMenu) { @@ -329,8 +329,8 @@ Rectangle { } } else if (mouse.button === Qt.MiddleButton) { // Close the menu if it was visible - if (trayMenuWindow && trayMenuWindow.visible) { - trayMenuWindow.close(); + if (popupMenuWindow && popupMenuWindow.visible) { + popupMenuWindow.close(); return; } modelData.secondaryActivate && modelData.secondaryActivate(); @@ -338,8 +338,8 @@ Rectangle { TooltipService.hideImmediately(); // Close the menu if it was visible - if (trayMenuWindow && trayMenuWindow.visible) { - trayMenuWindow.close(); + if (popupMenuWindow && popupMenuWindow.visible) { + popupMenuWindow.close(); return; } @@ -348,8 +348,8 @@ Rectangle { PanelService.openedPanel.close(); } - if (modelData.hasMenu && modelData.menu && trayMenuWindow && trayMenu && trayMenu.item) { - trayMenuWindow.open(); + if (modelData.hasMenu && modelData.menu && popupMenuWindow && trayMenu && trayMenu.item) { + popupMenuWindow.open(); // Position menu based on bar position let menuX, menuY; @@ -376,8 +376,8 @@ Rectangle { } } onEntered: { - if (trayMenuWindow) { - trayMenuWindow.close(); + if (popupMenuWindow) { + popupMenuWindow.close(); } TooltipService.show(screen, trayIcon, modelData.tooltipTitle || modelData.name || modelData.id || "Tray Item", BarService.getTooltipDirection()); } diff --git a/Modules/Bar/Widgets/Volume.qml b/Modules/Bar/Widgets/Volume.qml index 0a2d4f1d1..231cca8fb 100644 --- a/Modules/Bar/Widgets/Volume.qml +++ b/Modules/Bar/Widgets/Volume.qml @@ -58,6 +58,10 @@ Item { } } + function openExternalMixer() { + Quickshell.execDetached(["sh", "-c", "pwvucontrol || pavucontrol"]); + } + Timer { id: externalHideTimer running: false @@ -67,6 +71,45 @@ Item { } } + NPopupContextMenu { + id: contextMenu + + model: [ + { + "label": I18n.tr("context-menu.toggle-mute"), + "action": "toggle_mute", + "icon": AudioService.muted ? "volume-off" : "volume" + }, + { + "label": I18n.tr("context-menu.open-mixer"), + "action": "open_mixer", + "icon": "adjustments" + } + // , + // { + // "label": I18n.tr("context-menu.widget-settings"), + // "action": "widget_settings", + // "icon": "settings" + // }, + ] + + onTriggered: action => { + if (action === "toggle_mute") { + AudioService.setOutputMuted(!AudioService.muted); + } else if (action === "open_mixer") { + root.openExternalMixer(); + } else if (action === "widget_settings") + // TODO: Open widget settings + {} + + // Close the popup menu window after handling the action + var popupMenuWindow = PanelService.getPopupMenuWindow(screen); + if (popupMenuWindow) { + popupMenuWindow.close(); + } + } + } + BarPill { id: pill @@ -100,10 +143,17 @@ Item { PanelService.getPanel("audioPanel", screen)?.toggle(this); } onRightClicked: { - AudioService.setOutputMuted(!AudioService.muted); - } - onMiddleClicked: { - Quickshell.execDetached(["sh", "-c", "pwvucontrol || pavucontrol"]); + // Get the shared popup menu window for this screen + var popupMenuWindow = PanelService.getPopupMenuWindow(screen); + if (popupMenuWindow) { + // Calculate position using centralized helper (with center-based positioning) + const pos = BarService.getContextMenuPosition(pill, contextMenu.implicitWidth, contextMenu.implicitHeight); + + // Show the context menu inside the popup window + contextMenu.openAtItem(pill, pos.x, pos.y); + popupMenuWindow.showContextMenu(contextMenu); + } } + onMiddleClicked: root.openExternalMixer() } } diff --git a/Modules/MainScreen/AllScreens.qml b/Modules/MainScreen/AllScreens.qml index fb2fbf982..00d5406ba 100644 --- a/Modules/MainScreen/AllScreens.qml +++ b/Modules/MainScreen/AllScreens.qml @@ -95,7 +95,7 @@ Variants { } } - // TrayMenuWindow - separate window for tray context menus + // PopupMenuWindow - reusable popup window for both tray menus and context menus // Disabled when bar is hidden or not configured for this screen Loader { active: { @@ -108,12 +108,12 @@ Variants { } asynchronous: false - sourceComponent: TrayMenuWindow { + sourceComponent: PopupMenuWindow { screen: modelData } onLoaded: { - Logger.d("AllScreens", "TrayMenuWindow created for", modelData?.name); + Logger.d("AllScreens", "PopupMenuWindow created for", modelData?.name); } } } diff --git a/Modules/MainScreen/PopupMenuWindow.qml b/Modules/MainScreen/PopupMenuWindow.qml new file mode 100644 index 000000000..b553d0d1d --- /dev/null +++ b/Modules/MainScreen/PopupMenuWindow.qml @@ -0,0 +1,92 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import qs.Commons +import qs.Services.UI + +// Generic full-screen popup window for menus and context menus +// This is a top-level PanelWindow (sibling to MainScreen, not nested inside it) +// Provides click-outside-to-close functionality for any popup content +// Loads TrayMenu by default but can show context menus via showContextMenu() +PanelWindow { + id: root + + required property ShellScreen screen + property string windowType: "popupmenu" // Used for namespace and registration + + // Content item to display (set by the popup that uses this window) + property var contentItem: null + + // Expose the trayMenu Loader directly (for backward compatibility) + readonly property alias trayMenuLoader: trayMenuLoader + + anchors.top: true + anchors.left: true + anchors.right: true + anchors.bottom: true + visible: false + color: Color.transparent + + // Use Top layer (same as MainScreen) for proper event handling + WlrLayershell.layer: WlrLayer.Top + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + WlrLayershell.namespace: "noctalia-" + windowType + "-" + (screen?.name || "unknown") + WlrLayershell.exclusionMode: ExclusionMode.Ignore + + // Register with PanelService so widgets can find this window + Component.onCompleted: { + objectName = "popupMenuWindow-" + (screen?.name || "unknown"); + PanelService.registerPopupMenuWindow(screen, root); + } + + // Load TrayMenu as the default content + Loader { + id: trayMenuLoader + source: Quickshell.shellDir + "/Modules/Bar/Extras/TrayMenu.qml" + onLoaded: { + if (item) { + item.screen = root.screen; + // Set the loaded item as default content + root.contentItem = item; + } + } + } + + function open() { + visible = true; + } + + // Show a context menu (temporarily replaces TrayMenu as content) + function showContextMenu(menu) { + if (menu) { + contentItem = menu; + open(); + } + } + + function close() { + visible = false; + // Call close/hide method on current content + if (contentItem) { + if (typeof contentItem.hideMenu === "function") { + contentItem.hideMenu(); + } else if (typeof contentItem.close === "function") { + contentItem.close(); + } + } + // Restore TrayMenu as default content + if (trayMenuLoader.item) { + contentItem = trayMenuLoader.item; + } + } + + // Full-screen click catcher - click anywhere outside content closes the window + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + onClicked: root.close() + } + + // Content will be parented here by the popup + // (e.g., TrayMenu, NPopupContextMenu) +} diff --git a/Modules/MainScreen/TrayMenuWindow.qml b/Modules/MainScreen/TrayMenuWindow.qml deleted file mode 100644 index 1275c0882..000000000 --- a/Modules/MainScreen/TrayMenuWindow.qml +++ /dev/null @@ -1,63 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Wayland -import qs.Commons -import qs.Modules.Bar.Extras -import qs.Services.UI - -// Separate window for TrayMenu context menus -// This is a top-level PanelWindow (sibling to MainScreen, not nested inside it) -PanelWindow { - id: root - - required property ShellScreen screen - - anchors.top: true - anchors.left: true - anchors.right: true - anchors.bottom: true - visible: false - color: Color.transparent - - // Use Top layer (same as MainScreen) for proper event handling - WlrLayershell.layer: WlrLayer.Top - WlrLayershell.keyboardFocus: WlrKeyboardFocus.None - WlrLayershell.namespace: "noctalia-traymenu-" + (screen?.name || "unknown") - WlrLayershell.exclusionMode: ExclusionMode.Ignore - - // Expose the trayMenu Loader directly - readonly property alias trayMenuLoader: trayMenu - - // Register with PanelService so panels can find this window - Component.onCompleted: { - objectName = "trayMenuWindow-" + (screen?.name || "unknown"); - PanelService.registerTrayMenuWindow(screen, root); - } - - function open() { - visible = true; - } - - function close() { - visible = false; - if (trayMenu.item) { - trayMenu.item.hideMenu(); - } - } - - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton - onClicked: root.close() - } - - Loader { - id: trayMenu - source: Quickshell.shellDir + "/Modules/Bar/Extras/TrayMenu.qml" - onLoaded: { - if (item) { - item.screen = root.screen; - } - } - } -} diff --git a/Modules/Panels/Tray/TrayDrawerPanel.qml b/Modules/Panels/Tray/TrayDrawerPanel.qml index d2c49aa3d..e0a0e995e 100644 --- a/Modules/Panels/Tray/TrayDrawerPanel.qml +++ b/Modules/Panels/Tray/TrayDrawerPanel.qml @@ -100,22 +100,22 @@ SmartPanel { } // Trigger re-evaluation when window is registered - property int trayMenuUpdateTrigger: 0 + property int popupMenuUpdateTrigger: 0 // Get the trayMenu window and loader from PanelService (reactive to trigger changes) - readonly property var trayMenuWindow: { + readonly property var popupMenuWindow: { // Reference trigger to force re-evaluation - var _ = trayMenuUpdateTrigger; - return PanelService.getTrayMenuWindow(screen); + var _ = popupMenuUpdateTrigger; + return PanelService.getPopupMenuWindow(screen); } - readonly property var trayMenu: trayMenuWindow ? trayMenuWindow.trayMenuLoader : null + readonly property var trayMenu: popupMenuWindow ? popupMenuWindow.trayMenuLoader : null Connections { target: PanelService - function onTrayMenuWindowRegistered(registeredScreen) { + function onPopupMenuWindowRegistered(registeredScreen) { if (registeredScreen === screen) { - root.trayMenuUpdateTrigger++; + root.popupMenuUpdateTrigger++; } } } @@ -192,13 +192,13 @@ SmartPanel { TooltipService.hideImmediately(); // Close menu if already visible - if (trayMenuWindow && trayMenuWindow.visible) { - trayMenuWindow.close(); + if (popupMenuWindow && popupMenuWindow.visible) { + popupMenuWindow.close(); return; } - if (modelData.hasMenu && modelData.menu && trayMenuWindow && trayMenu && trayMenu.item) { - trayMenuWindow.open(); + if (modelData.hasMenu && modelData.menu && popupMenuWindow && trayMenu && trayMenu.item) { + popupMenuWindow.open(); // Position menu at the tray icon const barPosition = Settings.data.bar.position; @@ -232,8 +232,8 @@ SmartPanel { } onEntered: { - if (trayMenuWindow) { - trayMenuWindow.close(); + if (popupMenuWindow) { + popupMenuWindow.close(); } TooltipService.show(screen, trayIcon, modelData.tooltipTitle || modelData.name || modelData.id || "Tray Item", BarService.getTooltipDirection()); } diff --git a/Services/UI/BarService.qml b/Services/UI/BarService.qml index b2c40fa07..854d8f02d 100644 --- a/Services/UI/BarService.qml +++ b/Services/UI/BarService.qml @@ -244,4 +244,54 @@ Singleton { return "bottom"; } } + + // Calculate context menu position based on bar position + // Parameters: + // anchorItem: The widget item to anchor the menu to + // menuWidth: Width of the context menu (optional, defaults to 180) + // menuHeight: Height of the context menu (optional, defaults to 100) + // Returns: { x: number, y: number } + // Note: Anchor position is top-left corner, so we calculate from center + function getContextMenuPosition(anchorItem, menuWidth, menuHeight) { + if (!anchorItem) { + Logger.w("BarService", "getContextMenuPosition: anchorItem is null"); + return { + "x": 0, + "y": 0 + }; + } + + const mWidth = menuWidth || 180; + const mHeight = menuHeight || 100; + const barPosition = Settings.data.bar.position; + let menuX = 0; + let menuY = 0; + + // Calculate center-based positioning for consistent spacing + const anchorCenterX = anchorItem.width / 2; + const anchorCenterY = anchorItem.height / 2; + + if (barPosition === "left") { + // For left bar: position menu to the right of anchor, vertically centered + menuX = anchorItem.width + Style.marginM; + menuY = anchorCenterY - (mHeight / 2); + } else if (barPosition === "right") { + // For right bar: position menu to the left of anchor, vertically centered + menuX = -mWidth - Style.marginM; + menuY = anchorCenterY - (mHeight / 2); + } else if (barPosition === "top") { + // For top bar: position menu below bar, horizontally centered + menuX = anchorCenterX - (mWidth / 2); + menuY = Style.barHeight; + } else { + // For bottom bar: position menu above, horizontally centered + menuX = anchorCenterX - (mWidth / 2); + menuY = -mHeight - Style.marginM; + } + + return { + "x": menuX, + "y": menuY + }; + } } diff --git a/Services/UI/PanelService.qml b/Services/UI/PanelService.qml index 675508d0a..1e55db04b 100644 --- a/Services/UI/PanelService.qml +++ b/Services/UI/PanelService.qml @@ -16,9 +16,9 @@ Singleton { signal willOpen signal didClose - // Tray menu windows (one per screen) - property var trayMenuWindows: ({}) - signal trayMenuWindowRegistered(var screen) + // Popup menu windows (one per screen) - used for both tray menus and context menus + property var popupMenuWindows: ({}) + signal popupMenuWindowRegistered(var screen) // Register this panel (called after panel is loaded) function registerPanel(panel) { @@ -26,21 +26,21 @@ Singleton { Logger.d("PanelService", "Registered panel:", panel.objectName); } - // Register tray menu window for a screen - function registerTrayMenuWindow(screen, window) { + // Register popup menu window for a screen + function registerPopupMenuWindow(screen, window) { if (!screen || !window) return; var key = screen.name; - trayMenuWindows[key] = window; - Logger.d("PanelService", "Registered tray menu window for screen:", key); - trayMenuWindowRegistered(screen); + popupMenuWindows[key] = window; + Logger.d("PanelService", "Registered popup menu window for screen:", key); + popupMenuWindowRegistered(screen); } - // Get tray menu window for a screen - function getTrayMenuWindow(screen) { + // Get popup menu window for a screen + function getPopupMenuWindow(screen) { if (!screen) return null; - return trayMenuWindows[screen.name] || null; + return popupMenuWindows[screen.name] || null; } // Returns a panel (loads it on-demand if not yet loaded) diff --git a/Widgets/NPopupContextMenu.qml b/Widgets/NPopupContextMenu.qml new file mode 100644 index 000000000..4fbdd8675 --- /dev/null +++ b/Widgets/NPopupContextMenu.qml @@ -0,0 +1,201 @@ +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 +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 real anchorX: 0 + property real anchorY: 0 + + signal triggered(string action) + + implicitWidth: 180 + implicitHeight: Math.min(600, flickable.contentHeight + (Style.marginS * 2)) + visible: false + color: Color.transparent + + anchor.item: anchorItem + anchor.rect.x: anchorX + anchor.rect.y: anchorY + + // Handle Escape key to close menu + Item { + anchors.fill: parent + focus: true + Keys.onEscapePressed: root.close() + } + + // Background + Rectangle { + id: menuBackground + anchors.fill: parent + color: Color.mSurface + border.color: Color.mOutline + border.width: Style.borderS + radius: Style.radiusM + + // Fade-in animation + opacity: root.visible ? 1.0 : 0.0 + + Behavior on opacity { + NumberAnimation { + duration: Style.animationNormal + easing.type: Easing.OutQuad + } + } + } + + // Content - Use Flickable + ColumnLayout like TrayMenu for consistency + Flickable { + id: flickable + anchors.fill: parent + anchors.margins: Style.marginS + contentHeight: columnLayout.implicitHeight + interactive: true + + // Fade-in animation + 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: Color.transparent + + Rectangle { + id: innerRect + anchors.fill: parent + color: mouseArea.containsMouse ? Color.mHover : Color.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 + + // Optional icon + 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()); + // Don't call root.close() here - let the parent PopupMenuWindow handle closing + } + } + } + } + } + } + } + } + + // Helper function to open at specific position relative to anchor item + function openAt(x, y, item) { + if (!item) { + Logger.w("NPopupContextMenu", "anchorItem is undefined, won't show menu."); + return; + } + + anchorItem = item; + anchorX = x; + anchorY = y; + + visible = true; + + // Force update after showing + Qt.callLater(() => { + if (root.anchor) { + root.anchor.updateAnchor(); + } + }); + } + + // Helper function to open at item (compatible with NContextMenu API) + function openAtItem(item, mouseX, mouseY) { + openAt(mouseX || 0, mouseY || 0, item); + } + + // Helper function to close menu (compatible with PopupMenuWindow) + function close() { + visible = false; + } + + // Alias for backward compatibility + function closeMenu() { + close(); + } +}