diff --git a/Assets/settings-default.json b/Assets/settings-default.json index b14d05c7e..37d85a509 100644 --- a/Assets/settings-default.json +++ b/Assets/settings-default.json @@ -1,5 +1,5 @@ { - "settingsVersion": 3, + "settingsVersion": 4, "bar": { "position": "top", "backgroundOpacity": 1, @@ -116,7 +116,8 @@ "exclusive": false, "backgroundOpacity": 1, "floatingRatio": 1, - "monitors": [] + "monitors": [], + "pinnedApps": [] }, "network": { "wifiEnabled": true, @@ -125,6 +126,7 @@ "notifications": { "doNotDisturb": false, "monitors": [], + "location": "top_right", "lastSeenTs": 0, "lowUrgencyDuration": 3, "normalUrgencyDuration": 8, @@ -149,7 +151,7 @@ }, "colorSchemes": { "useWallpaperColors": false, - "predefinedScheme": "", + "predefinedScheme": "Noctalia (default)", "darkMode": true }, "matugen": { diff --git a/Commons/Settings.qml b/Commons/Settings.qml index 8dd0ff055..1339f9525 100644 --- a/Commons/Settings.qml +++ b/Commons/Settings.qml @@ -113,7 +113,7 @@ Singleton { JsonAdapter { id: adapter - property int settingsVersion: 3 + property int settingsVersion: 4 // bar property JsonObject bar: JsonObject { @@ -233,6 +233,8 @@ Singleton { property real backgroundOpacity: 1.0 property real floatingRatio: 1.0 property list monitors: [] + // Desktop entry IDs pinned to the dock (e.g., "org.kde.konsole", "firefox.desktop") + property list pinnedApps: [] } // network diff --git a/Modules/Dock/Dock.qml b/Modules/Dock/Dock.qml index cec00ca3e..b8e143440 100644 --- a/Modules/Dock/Dock.qml +++ b/Modules/Dock/Dock.qml @@ -13,6 +13,7 @@ Variants { model: Quickshell.screens delegate: Item { + id: root required property ShellScreen modelData property real scaling: ScalingService.getScreenScale(modelData) @@ -25,6 +26,47 @@ Variants { } } + // Update dock apps when toplevels change + Connections { + target: ToplevelManager ? ToplevelManager.toplevels : null + function onValuesChanged() { + Logger.log("Dock", "ToplevelManager.toplevels.onValuesChanged triggered") + updateDockApps() + } + } + + // Also listen to model changes (for ObjectModel) + Connections { + target: ToplevelManager ? ToplevelManager.toplevels : null + function onCountChanged() { + Logger.log("Dock", "ToplevelManager.toplevels.onCountChanged triggered, count:", ToplevelManager.toplevels.count) + updateDockApps() + } + } + + // Update dock apps when pinned apps change + Connections { + target: Settings.data.dock + function onPinnedAppsChanged() { + updateDockApps() + } + } + + // Initial update when component is ready + Component.onCompleted: { + if (Settings.isLoaded && ToplevelManager) { + updateDockApps() + } + } + + // Update when Settings are loaded + Connections { + target: Settings + function onSettingsLoaded() { + updateDockApps() + } + } + // Shared properties between peek and dock windows readonly property bool autoHide: Settings.data.dock.autoHide readonly property int hideDelay: 500 @@ -43,12 +85,78 @@ Variants { // Shared state between windows property bool dockHovered: false property bool anyAppHovered: false + property bool menuHovered: false property bool hidden: autoHide property bool peekHovered: false // Separate property to control Loader - stays true during animations property bool dockLoaded: !autoHide // Start loaded if autoHide is off + // Track the currently open context menu + property var currentContextMenu: null + + // Combined model of running apps and pinned apps + property var dockApps: [] + + // Function to close any open context menu + function closeAllContextMenus() { + if (currentContextMenu && currentContextMenu.visible) { + currentContextMenu.hide() + } + } + + // Function to update the combined dock apps model + function updateDockApps() { + const runningApps = ToplevelManager ? (ToplevelManager.toplevels.values || []) : [] + const pinnedApps = Settings.data.dock.pinnedApps || [] + const combined = [] + + Logger.log("Dock", "Updating dock apps. Running:", runningApps.length, "Pinned:", pinnedApps.length) + + // First, add pinned apps (both running and non-running) in their pinned order + pinnedApps.forEach(pinnedAppId => { + const runningApp = runningApps.find(toplevel => toplevel && toplevel.appId === pinnedAppId) + if (runningApp) { + // Pinned app that is currently running + combined.push({ + "type": "pinned-running", + "toplevel": runningApp, + "appId": runningApp.appId, + "title": runningApp.title + }) + Logger.log("Dock", "Added pinned-running app:", runningApp.appId) + } else { + // Pinned app that is not running + combined.push({ + "type": "pinned", + "toplevel": null, + "appId": pinnedAppId, + "title": pinnedAppId + }) + Logger.log("Dock", "Added pinned app:", pinnedAppId) + } + }) + + // Then, add running apps that are not pinned + runningApps.forEach(toplevel => { + if (toplevel && toplevel.appId) { + const isPinned = pinnedApps.includes(toplevel.appId) + if (!isPinned) { + combined.push({ + "type": "running", + "toplevel": toplevel, + "appId": toplevel.appId, + "title": toplevel.title + }) + Logger.log("Dock", "Added running app:", toplevel.appId) + } + } + }) + + Logger.log("Dock", "Total dock apps:", combined.length) + dockApps = combined + } + // Timer to unload dock after hide animation completes Timer { id: unloadTimer @@ -65,7 +173,7 @@ Variants { id: hideTimer interval: hideDelay onTriggered: { - if (autoHide && !dockHovered && !anyAppHovered && !peekHovered) { + if (autoHide && !dockHovered && !anyAppHovered && !peekHovered && !menuHovered) { hidden = true unloadTimer.restart() // Start unload timer when hiding } @@ -137,7 +245,7 @@ Variants { onExited: { peekHovered = false - if (!hidden && !dockHovered && !anyAppHovered) { + if (!hidden && !dockHovered && !anyAppHovered && !menuHovered) { hideTimer.restart() } } @@ -147,7 +255,7 @@ Variants { // DOCK WINDOW Loader { - active: Settings.isLoaded && modelData && Settings.data.dock.monitors.includes(modelData.name) && dockLoaded && ToplevelManager && (ToplevelManager.toplevels.values.length > 0) + active: Settings.isLoaded && modelData && Settings.data.dock.monitors.includes(modelData.name) && dockLoaded && ToplevelManager && (dockApps.length > 0) sourceComponent: PanelWindow { id: dockWindow @@ -235,10 +343,15 @@ Variants { onExited: { dockHovered = false - if (autoHide && !anyAppHovered && !peekHovered) { + if (autoHide && !anyAppHovered && !peekHovered && !menuHovered) { hideTimer.restart() } } + + onClicked: { + // Close any open context menu when clicking on the dock background + closeAllContextMenus() + } } Item { @@ -247,10 +360,10 @@ Variants { height: parent.height - (Style.marginM * 2 * scaling) anchors.centerIn: parent - function getAppIcon(toplevel: Toplevel): string { - if (!toplevel) + function getAppIcon(appData): string { + if (!appData || !appData.appId) return "" - return AppIcons.iconForAppId(toplevel.appId?.toLowerCase()) + return AppIcons.iconForAppId(appData.appId?.toLowerCase()) } RowLayout { @@ -260,7 +373,7 @@ Variants { anchors.centerIn: parent Repeater { - model: ToplevelManager ? ToplevelManager.toplevels : null + model: dockApps delegate: Item { id: appButton @@ -268,10 +381,20 @@ Variants { Layout.preferredHeight: iconSize Layout.alignment: Qt.AlignCenter - property bool isActive: ToplevelManager.activeToplevel && ToplevelManager.activeToplevel === modelData + property bool isActive: modelData.toplevel && ToplevelManager.activeToplevel && ToplevelManager.activeToplevel === modelData.toplevel property bool hovered: appMouseArea.containsMouse property string appId: modelData ? modelData.appId : "" - property string appTitle: modelData ? modelData.title : "" + property string appTitle: modelData ? (modelData.title || modelData.appId) : "" + property bool isRunning: modelData && (modelData.type === "running" || modelData.type === "pinned-running") + + // Listen for the toplevel being closed + Connections { + target: modelData?.toplevel + function onClosed() { + Logger.log("Dock", "Toplevel closed signal received for:", appButton.appId) + Qt.callLater(root.updateDockApps) + } + } // Individual tooltip for this app NTooltip { @@ -296,6 +419,9 @@ Variants { fillMode: Image.PreserveAspectFit cache: true + // Dim pinned apps that aren't running + opacity: appButton.isRunning ? 1.0 : 0.6 + scale: appButton.hovered ? 1.15 : 1.0 Behavior on scale { @@ -305,6 +431,13 @@ Variants { easing.overshoot: 1.2 } } + + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.OutQuad + } + } } // Fall back if no icon @@ -314,6 +447,7 @@ Variants { icon: "question-mark" font.pointSize: iconSize * 0.7 color: appButton.isActive ? Color.mPrimary : Color.mOnSurfaceVariant + opacity: appButton.isRunning ? 1.0 : 0.6 scale: appButton.hovered ? 1.15 : 1.0 Behavior on scale { @@ -323,6 +457,29 @@ Variants { easing.overshoot: 1.2 } } + + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.OutQuad + } + } + } + + // Context menu popup + DockMenu { + id: contextMenu + scaling: root.scaling + onHoveredChanged: menuHovered = hovered + onRequestClose: contextMenu.hide() + onAppClosed: root.updateDockApps // Force immediate dock update when app is closed + onVisibleChanged: { + if (visible) { + root.currentContextMenu = contextMenu + } else if (root.currentContextMenu === contextMenu) { + root.currentContextMenu = null + } + } } MouseArea { @@ -330,7 +487,7 @@ Variants { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - acceptedButtons: Qt.LeftButton | Qt.MiddleButton + acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton onEntered: { anyAppHovered = true @@ -347,17 +504,38 @@ Variants { onExited: { anyAppHovered = false appTooltip.hide() - if (autoHide && !dockHovered && !peekHovered) { + if (autoHide && !dockHovered && !peekHovered && !menuHovered) { hideTimer.restart() } } onClicked: function (mouse) { - if (mouse.button === Qt.MiddleButton && modelData?.close) { - modelData.close() + // Close any existing context menu first + if (mouse.button !== Qt.RightButton || root.currentContextMenu !== contextMenu) { + root.closeAllContextMenus() } - if (mouse.button === Qt.LeftButton && modelData?.activate) { - modelData.activate() + + // Check if toplevel is still valid (not a stale reference) + const isValidToplevel = modelData?.toplevel && ToplevelManager && ToplevelManager.toplevels.values.includes(modelData.toplevel) + + if (mouse.button === Qt.MiddleButton && isValidToplevel && modelData.toplevel.close) { + Logger.log("Dock", "Middle-click closing app:", modelData.appId) + modelData.toplevel.close() + Qt.callLater(root.updateDockApps) // Force immediate dock update + } else if (mouse.button === Qt.LeftButton) { + if (isValidToplevel && modelData.toplevel.activate) { + // Running app - activate it + Logger.log("Dock", "Activating running app:", modelData.appId) + modelData.toplevel.activate() + } else if (modelData?.appId) { + // Pinned app not running - launch it + Logger.log("Dock", "Launching pinned app:", modelData.appId) + Quickshell.execDetached(["gtk-launch", modelData.appId]) + } + } else if (mouse.button === Qt.RightButton) { + // Hide tooltip when showing context menu + appTooltip.hide() + contextMenu.show(appButton, modelData.toplevel || modelData) } } } diff --git a/Modules/Dock/DockMenu.qml b/Modules/Dock/DockMenu.qml new file mode 100644 index 000000000..66526b7e0 --- /dev/null +++ b/Modules/Dock/DockMenu.qml @@ -0,0 +1,263 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland +import Quickshell.Widgets +import qs.Commons +import qs.Services +import qs.Widgets + +PopupWindow { + id: root + + property var toplevel: null + property Item anchorItem: null + property real scaling: 1.0 + property bool hovered: menuMouseArea.containsMouse || activateMouseArea.containsMouse || pinMouseArea.containsMouse || closeMouseArea.containsMouse + property var onAppClosed: null // Callback function for when an app is closed + + signal requestClose + + implicitWidth: 160 * scaling + implicitHeight: contextMenuColumn.implicitHeight + (Style.marginM * scaling * 2) + color: Color.transparent + visible: false + + // Helper functions for pin/unpin functionality + function isAppPinned(appId) { + if (!appId) + return false + const pinnedApps = Settings.data.dock.pinnedApps || [] + return pinnedApps.includes(appId) + } + + function toggleAppPin(appId) { + if (!appId) + return + + let pinnedApps = (Settings.data.dock.pinnedApps || []).slice() // Create a copy + const isPinned = pinnedApps.includes(appId) + + if (isPinned) { + // Unpin: remove from array + pinnedApps = pinnedApps.filter(id => id !== appId) + } else { + // Pin: add to array + pinnedApps.push(appId) + } + + // Update the settings + Settings.data.dock.pinnedApps = pinnedApps + Logger.log("DockMenu", isPinned ? "Unpinned" : "Pinned", "app:", appId) + } + + anchor.item: anchorItem + anchor.rect.x: anchorItem ? (anchorItem.width - implicitWidth) / 2 : 0 + anchor.rect.y: anchorItem ? -implicitHeight - (Style.marginM * scaling) : 0 + + function show(item, toplevelData) { + if (!item) { + Logger.warn("DockMenu", "anchorItem is undefined, won't show menu.") + return + } + + anchorItem = item + toplevel = toplevelData + visible = true + } + + function hide() { + visible = false + } + + // Close menu when clicking on background, track hover for the whole menu area + MouseArea { + id: menuMouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: root.hide() // Close when clicking on the background (outside menu content) + } + + Shortcut { + sequences: ["Escape"] + enabled: root.visible + onActivated: root.hide() + } + + Rectangle { + anchors.fill: parent + color: Color.mSurface + radius: Style.radiusS * scaling + border.color: Color.mOutline + border.width: Math.max(1, Style.borderS * scaling) + + // Prevent clicks inside the menu from closing it + MouseArea { + anchors.fill: parent + onClicked: { + + } // Do nothing, just consume the click + } + + Column { + id: contextMenuColumn + anchors.fill: parent + anchors.margins: Style.marginM * scaling + spacing: 0 + + // Activate/Focus item + Rectangle { + width: parent.width + height: 32 * scaling + color: activateMouseArea.containsMouse ? Qt.alpha(Color.mSecondary, 0.2) : Color.transparent + radius: Style.radiusXS * scaling + + Row { + anchors.left: parent.left + anchors.leftMargin: Style.marginS * scaling + anchors.verticalCenter: parent.verticalCenter + spacing: Style.marginS * scaling + + NIcon { + icon: "eye" + font.pointSize: 12 * scaling + color: Color.mOnSurface + anchors.verticalCenter: parent.verticalCenter + } + + NText { + text: { + if (!root.toplevel) + return "Activate" + // Check if this toplevel is active by comparing with ToplevelManager.activeToplevel + const isActive = ToplevelManager.activeToplevel && ToplevelManager.activeToplevel === root.toplevel + return isActive ? "Focus" : "Activate" + } + font.pointSize: Style.fontSizeS * scaling + color: Color.mOnSurface + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: activateMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onClicked: { + if (root.toplevel?.activate) { + root.toplevel.activate() + } + root.hide() + } + } + } + + // Pin/Unpin item + Rectangle { + width: parent.width + height: 32 * scaling + color: pinMouseArea.containsMouse ? Qt.alpha(Color.mTertiary, 0.2) : Color.transparent + radius: Style.radiusXS * scaling + + Row { + anchors.left: parent.left + anchors.leftMargin: Style.marginS * scaling + anchors.verticalCenter: parent.verticalCenter + spacing: Style.marginS * scaling + + NIcon { + icon: { + if (!root.toplevel) + return "pin" + return root.isAppPinned(root.toplevel.appId) ? "pinned-off" : "pin" + } + font.pointSize: 12 * scaling + color: Color.mOnSurface + anchors.verticalCenter: parent.verticalCenter + } + + NText { + text: { + if (!root.toplevel) + return "Pin" + return root.isAppPinned(root.toplevel.appId) ? "Unpin" : "Pin" + } + font.pointSize: Style.fontSizeS * scaling + color: Color.mOnSurface + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: pinMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onClicked: { + if (root.toplevel?.appId) { + root.toggleAppPin(root.toplevel.appId) + } + root.hide() + } + } + } + + // Close item + Rectangle { + width: parent.width + height: 32 * scaling + color: closeMouseArea.containsMouse ? Qt.alpha(Color.mPrimary, 0.2) : Color.transparent + radius: Style.radiusXS * scaling + + Row { + anchors.left: parent.left + anchors.leftMargin: Style.marginS * scaling + anchors.verticalCenter: parent.verticalCenter + spacing: Style.marginS * scaling + + NIcon { + icon: "x" + font.pointSize: 12 * scaling + color: closeMouseArea.containsMouse ? Color.mPrimary : Color.mOnSurface + anchors.verticalCenter: parent.verticalCenter + } + + NText { + text: "Close" + font.pointSize: Style.fontSizeS * scaling + color: closeMouseArea.containsMouse ? Color.mPrimary : Color.mOnSurface + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: closeMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onClicked: { + // 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) { + Logger.log("DockMenu", "Closing app via menu:", root.toplevel.appId) + root.toplevel.close() + // Trigger immediate dock update callback if provided + if (root.onAppClosed && typeof root.onAppClosed === "function") { + Qt.callLater(root.onAppClosed) + } + } else { + Logger.warn("DockMenu", "Cannot close app - invalid toplevel reference") + } + root.hide() + } + } + } + } + } +}