import QtQuick import QtQuick.Controls import QtQuick.Layouts import Quickshell import Quickshell.Wayland import Quickshell.Widgets import qs.Commons import qs.Widgets PopupWindow { id: root property var toplevel: null property Item anchorItem: null property bool hovered: menuMouseArea.containsMouse property var onAppClosed: null // Callback function for when an app is closed property bool canAutoClose: false // Track which menu item is hovered property int hoveredItem: -1 // -1: none, otherwise the index of the item in `items` property var items: [] signal requestClose property real menuContentWidth: 160 implicitWidth: menuContentWidth + (Style.marginM * 2) implicitHeight: contextMenuColumn.implicitHeight + (Style.marginM * 2) color: Color.transparent visible: false // Hidden text element for measuring text width Text { id: textMeasure visible: false font.pointSize: Style.fontSizeS font.family: "Sans Serif" // Match your NText font if different wrapMode: Text.NoWrap elide: Text.ElideNone } // Calculate the maximum width needed for all menu items function calculateMenuWidth() { let maxWidth = 0; // Start with 0, we'll apply minimum later if (root.items && root.items.length > 0) { for (let i = 0; i < root.items.length; i++) { const item = root.items[i]; if (item && item.text) { // Calculate width: margins + icon (if present) + spacing + text width let itemWidth = Style.marginS * 2; // left and right margins if (item.icon && item.icon !== "") { itemWidth += Style.fontSizeL + Style.marginS; // icon + spacing } // Measure actual text width textMeasure.text = item.text; const textWidth = textMeasure.contentWidth; itemWidth += textWidth; if (itemWidth > maxWidth) { maxWidth = itemWidth; } } } } // Apply a reasonable minimum width (like 120px) menuContentWidth = Math.max(120, maxWidth); } function initItems() { // Is this a running app? const isRunning = root.toplevel && ToplevelManager && ToplevelManager.toplevels.values.includes(root.toplevel); // Is this a pinned app? const isPinned = root.toplevel && root.isAppPinned(root.toplevel.appId); var next = []; if (isRunning) { // Focus item next.push({ "icon": "eye", "text": I18n.tr("dock.menu.focus"), "action": function () { handleFocus(); } }); } // Pin/Unpin item next.push({ "icon": !isPinned ? "pin" : "unpin", "text": !isPinned ? I18n.tr("dock.menu.pin") : I18n.tr("dock.menu.unpin"), "action": function () { handlePin(); } }); if (isRunning) { // Close item next.push({ "icon": "close", "text": I18n.tr("dock.menu.close"), "action": function () { handleClose(); } }); } // Create a menu entry for each app-specific action definied in its .desktop file if (typeof DesktopEntries !== 'undefined' && DesktopEntries.byId && root.toplevel?.appId) { const appId = root.toplevel.appId; const entry = (DesktopEntries.heuristicLookup) ? DesktopEntries.heuristicLookup(appId) : DesktopEntries.byId(appId); if (entry != null) { entry.actions.forEach(function (action) { next.push({ "icon": "", "text": action.name, "action": function () { action.execute(); } }); }); } } root.items = next; // Force width recalculation when items change calculateMenuWidth(); } // Helper function to normalize app IDs for case-insensitive matching function normalizeAppId(appId) { if (!appId || typeof appId !== 'string') return ""; return appId.toLowerCase().trim(); } // Helper function to get desktop entry ID from an app ID function getDesktopEntryId(appId) { if (!appId) return appId; // Try to find the desktop entry using heuristic lookup if (typeof DesktopEntries !== 'undefined' && DesktopEntries.heuristicLookup) { try { const entry = DesktopEntries.heuristicLookup(appId); if (entry && entry.id) { return entry.id; } } catch (e) // Fall through to return original appId {} } // Try direct lookup if (typeof DesktopEntries !== 'undefined' && DesktopEntries.byId) { try { const entry = DesktopEntries.byId(appId); if (entry && entry.id) { return entry.id; } } catch (e) // Fall through to return original appId {} } // Return original appId if we can't find a desktop entry return appId; } // Helper functions for pin/unpin functionality function isAppPinned(appId) { if (!appId) return false; const pinnedApps = Settings.data.dock.pinnedApps || []; const normalizedId = normalizeAppId(appId); return pinnedApps.some(pinnedId => normalizeAppId(pinnedId) === normalizedId); } function toggleAppPin(appId) { if (!appId) return; // Get the desktop entry ID for consistent pinning const desktopEntryId = getDesktopEntryId(appId); const normalizedId = normalizeAppId(desktopEntryId); let pinnedApps = (Settings.data.dock.pinnedApps || []).slice(); // Create a copy // Find existing pinned app with case-insensitive matching const existingIndex = pinnedApps.findIndex(pinnedId => normalizeAppId(pinnedId) === normalizedId); const isPinned = existingIndex >= 0; if (isPinned) { // Unpin: remove from array pinnedApps.splice(existingIndex, 1); } else { // Pin: add desktop entry ID to array pinnedApps.push(desktopEntryId); } // Update the settings Settings.data.dock.pinnedApps = pinnedApps; } anchor.item: anchorItem anchor.rect.x: anchorItem ? (anchorItem.width - implicitWidth) / 2 : 0 anchor.rect.y: anchorItem ? -implicitHeight - (Style.marginM) : 0 function show(item, toplevelData) { if (!item) { return; } // First hide completely visible = false; // Then set up new data anchorItem = item; toplevel = toplevelData; initItems(); // Force a complete re-evaluation by waiting for the next frame Qt.callLater(() => { Qt.callLater(() => { visible = true; canAutoClose = false; gracePeriodTimer.restart(); }); }); } function hide() { visible = false; root.items.length = 0; menuContentWidth = 120; // Reset to minimum } // Helper function to determine which menu item is under the mouse function getHoveredItem(mouseY) { const itemHeight = 32; const startY = Style.marginM; const relativeY = mouseY - startY; if (relativeY < 0) return -1; const itemIndex = Math.floor(relativeY / itemHeight); return itemIndex >= 0 && itemIndex < root.items.length ? itemIndex : -1; } function handleFocus() { if (root.toplevel?.activate) { root.toplevel.activate(); } root.requestClose(); } function handlePin() { if (root.toplevel?.appId) { root.toggleAppPin(root.toplevel.appId); } root.requestClose(); } function handleClose() { // Check if toplevel is still valid before trying to close it const isValidToplevel = root.toplevel && ToplevelManager && ToplevelManager.toplevels.values.includes(root.toplevel); if (isValidToplevel && root.toplevel.close) { root.toplevel.close(); // Trigger immediate dock update callback if provided if (root.onAppClosed && typeof root.onAppClosed === "function") { Qt.callLater(root.onAppClosed); } } root.hide(); root.requestClose(); } // Short delay to ignore spurious events Timer { id: gracePeriodTimer interval: 1500 repeat: false onTriggered: { root.canAutoClose = true; if (!menuMouseArea.containsMouse) { closeTimer.start(); } } } Timer { id: closeTimer interval: 500 repeat: false running: false onTriggered: { root.hide(); } } Rectangle { anchors.fill: parent color: Color.mSurfaceVariant radius: Style.radiusS border.color: Color.mOutline border.width: Style.borderS // Single MouseArea to handle both auto-close and menu interactions MouseArea { id: menuMouseArea anchors.fill: parent hoverEnabled: true cursorShape: root.hoveredItem >= 0 ? Qt.PointingHandCursor : Qt.ArrowCursor onEntered: { closeTimer.stop(); } onExited: { root.hoveredItem = -1; if (root.canAutoClose) { // Only close if grace period has passed closeTimer.start(); } } onPositionChanged: mouse => { root.hoveredItem = root.getHoveredItem(mouse.y); } onClicked: mouse => { const clickedItem = root.getHoveredItem(mouse.y); if (clickedItem >= 0) { root.items[clickedItem].action.call(); } } } ColumnLayout { id: contextMenuColumn anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top anchors.margins: Style.marginM spacing: 0 Repeater { model: root.items Rectangle { Layout.fillWidth: true height: 32 color: root.hoveredItem === index ? Color.mHover : Color.transparent radius: Style.radiusXS Row { id: rowLayout anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter anchors.leftMargin: Style.marginS anchors.rightMargin: Style.marginS spacing: Style.marginS NIcon { icon: modelData.icon pointSize: Style.fontSizeL color: root.hoveredItem === index ? Color.mOnHover : Color.mOnSurfaceVariant visible: icon !== "" anchors.verticalCenter: parent.verticalCenter } NText { text: modelData.text pointSize: Style.fontSizeS color: root.hoveredItem === index ? Color.mOnHover : Color.mOnSurfaceVariant anchors.verticalCenter: parent.verticalCenter } } } } } } }