From 6bd43fade6a39944a7bd70b473cec699f0e043c4 Mon Sep 17 00:00:00 2001 From: tibssy Date: Thu, 5 Feb 2026 01:37:08 +0000 Subject: [PATCH] feat(dock): fully implement StaticDockPanel with app management logic, transparent container styling, and hover-aware auto-close timers --- Modules/Dock/Dock.qml | 4 + Modules/Dock/DockContent.qml | 5 +- Modules/Panels/Dock/StaticDockPanel.qml | 437 ++++++++++++++++++++++-- 3 files changed, 415 insertions(+), 31 deletions(-) diff --git a/Modules/Dock/Dock.qml b/Modules/Dock/Dock.qml index b6e6251f4..1a0e79f24 100644 --- a/Modules/Dock/Dock.qml +++ b/Modules/Dock/Dock.qml @@ -423,6 +423,10 @@ Loader { if (autoHide && !dockHovered && !anyAppHovered && !peekHovered && !menuHovered) { if (isStaticMode) { const panel = getStaticDockPanel(); + if (panel && (panel.menuHovered || (panel.currentContextMenu && panel.currentContextMenu.visible))) { + restart(); + return; + } if (panel && panel.isDockHovered) { restart(); return; diff --git a/Modules/Dock/DockContent.qml b/Modules/Dock/DockContent.qml index 61161df21..48fff035d 100644 --- a/Modules/Dock/DockContent.qml +++ b/Modules/Dock/DockContent.qml @@ -18,13 +18,14 @@ Item { required property int extraLeft required property int extraRight property alias dockContainer: dockContainer + readonly property bool isStaticMode: Settings.data.dock.dockType === "static" Rectangle { id: dockContainer // For vertical dock, swap width and height logic width: dockRoot.isVertical ? Math.round(dockRoot.iconSize * 1.5) : Math.min(dockLayout.implicitWidth + Style.marginXL, dockRoot.maxWidth) height: dockRoot.isVertical ? Math.min(dockLayout.implicitHeight + Style.marginXL, dockRoot.maxHeight) : Math.round(dockRoot.iconSize * 1.5) - color: Qt.alpha(Color.mSurface, Settings.data.dock.backgroundOpacity) + color: Qt.alpha(Color.mSurface, (isStaticMode ? 0 : Settings.data.dock.backgroundOpacity)) // Anchor based on padding to achieve centering shift anchors.horizontalCenter: extraLeft > 0 || extraRight > 0 ? undefined : parent.horizontalCenter @@ -37,7 +38,7 @@ Item { radius: Style.radiusL border.width: Style.borderS - border.color: Qt.alpha(Color.mOutline, Settings.data.dock.backgroundOpacity) + border.color: Qt.alpha(Color.mOutline, (isStaticMode ? 0 : Settings.data.dock.backgroundOpacity)) // Enable layer caching to reduce GPU usage from continuous animations layer.enabled: true diff --git a/Modules/Panels/Dock/StaticDockPanel.qml b/Modules/Panels/Dock/StaticDockPanel.qml index ad79ccb77..ab7d22a16 100644 --- a/Modules/Panels/Dock/StaticDockPanel.qml +++ b/Modules/Panels/Dock/StaticDockPanel.qml @@ -2,18 +2,52 @@ import QtQuick import Quickshell import Quickshell.Wayland import qs.Commons +import qs.Modules.Dock import qs.Modules.MainScreen SmartPanel { id: root - - property real dockWidth: 0 - property real dockHeight: 0 + panelBackgroundColor: Qt.alpha(Color.mSurface, Settings.data.dock.backgroundOpacity) readonly property string dockPosition: Settings.data.dock.position readonly property bool isVertical: dockPosition === "left" || dockPosition === "right" - readonly property bool isStaticMode: Settings.data.dock.dockType === "static" + readonly property bool isFramed: Settings.data.bar.barType === "framed" property bool isDockHovered: false + readonly property int iconSize: Math.round(12 + 24 * (Settings.data.dock.size ?? 1)) + readonly property int maxWidth: screen ? screen.width * 0.8 : 1000 + readonly property int maxHeight: screen ? screen.height * 0.8 : 1000 + readonly property bool autoHide: false + readonly property int hideDelay: 500 + readonly property int showDelay: 100 + readonly property int hideAnimationDuration: Math.max(0, Math.round(Style.animationFast / (Settings.data.dock.animationSpeed || 1.0))) + readonly property int showAnimationDuration: Math.max(0, Math.round(Style.animationFast / (Settings.data.dock.animationSpeed || 1.0))) + + // Shared state with dock content + property bool dockHovered: false + property bool anyAppHovered: false + property bool menuHovered: false + property bool hidden: false + property bool peekHovered: false + + // Track the currently open context menu + property var currentContextMenu: null + + // Combined model of running apps and pinned apps + property var dockApps: [] + + // Track the session order of apps (transient reordering) + property var sessionAppOrder: [] + + // Drag and Drop state for visual feedback + property int dragSourceIndex: -1 + property int dragTargetIndex: -1 + + // Revision counter to force icon re-evaluation + property int iconRevision: 0 + + property alias hideTimer: hideTimer + property alias showTimer: showTimer + property alias unloadTimer: unloadTimer panelAnchorTop: dockPosition === "top" panelAnchorBottom: dockPosition === "bottom" @@ -25,41 +59,386 @@ SmartPanel { forceAttachToBar: true exclusiveKeyboard: false - // Fixed size 200x200 - preferredWidth: 200 - preferredHeight: 200 + preferredWidth: dockContainerWrapper.width + preferredHeight: dockContainerWrapper.height + + // when dragging ended but the cursor is outside the dock area, restart the timer + onDragSourceIndexChanged: { + if (dragSourceIndex === -1) { + if (autoHide && !dockHovered && !anyAppHovered && !peekHovered && !menuHovered) { + hideTimer.restart(); + } + } + } + + // Function to close any open context menu + function closeAllContextMenus() { + if (currentContextMenu && currentContextMenu.visible) { + currentContextMenu.hide(); + } + } + + function getAppKey(appData) { + if (!appData) + return null; + + // Use stable appId for pinned apps to maintain their slot regardless of running state + if (appData.type === "pinned" || appData.type === "pinned-running") { + return appData.appId; + } + + // prefer toplevel object identity for unpinned running apps to distinguish instances + if (appData.toplevel) + return appData.toplevel; + + // fallback to appId + return appData.appId; + } + + function sortDockApps(apps) { + if (!sessionAppOrder || sessionAppOrder.length === 0) { + return apps; + } + + const sorted = []; + const remaining = [...apps]; + + // Pick apps that are in the session order + for (let i = 0; i < sessionAppOrder.length; i++) { + const key = sessionAppOrder[i]; + + // Pick ALL matching apps (e.g. all instances of a pinned app) + while (true) { + const idx = remaining.findIndex(app => getAppKey(app) === key); + if (idx !== -1) { + sorted.push(remaining[idx]); + remaining.splice(idx, 1); + } else { + break; + } + } + } + + // Append any new/remaining apps + remaining.forEach(app => sorted.push(app)); + + return sorted; + } + + function reorderApps(fromIndex, toIndex) { + if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= dockApps.length || toIndex >= dockApps.length) + return; + + const list = [...dockApps]; + const item = list.splice(fromIndex, 1)[0]; + list.splice(toIndex, 0, item); + + dockApps = list; + sessionAppOrder = dockApps.map(getAppKey); + savePinnedOrder(); + } + + function savePinnedOrder() { + const currentPinned = Settings.data.dock.pinnedApps || []; + const newPinned = []; + const seen = new Set(); + + // Extract pinned apps in their current visual order + dockApps.forEach(app => { + if (app.appId && !seen.has(app.appId)) { + const isPinned = currentPinned.some(p => normalizeAppId(p) === normalizeAppId(app.appId)); + + if (isPinned) { + newPinned.push(app.appId); + seen.add(app.appId); + } + } + }); + + // Check if any pinned apps were missed (unlikely if dockApps is correct) + currentPinned.forEach(p => { + if (!seen.has(p)) { + newPinned.push(p); + seen.add(p); + } + }); + + if (JSON.stringify(currentPinned) !== JSON.stringify(newPinned)) { + Settings.data.dock.pinnedApps = newPinned; + } + } + + // Helper function to normalize app IDs for case-insensitive matching + function normalizeAppId(appId) { + if (!appId || typeof appId !== 'string') + return ""; + let id = appId.toLowerCase().trim(); + if (id.endsWith(".desktop")) + id = id.substring(0, id.length - 8); + return id; + } + + // Helper function to check if an app ID matches a pinned app (case-insensitive) + function isAppIdPinned(appId, pinnedApps) { + if (!appId || !pinnedApps || pinnedApps.length === 0) + return false; + const normalizedId = normalizeAppId(appId); + return pinnedApps.some(pinnedId => normalizeAppId(pinnedId) === normalizedId); + } + + // Helper function to get app name from desktop entry + function getAppNameFromDesktopEntry(appId) { + if (!appId) + return appId; + + try { + if (typeof DesktopEntries !== 'undefined' && DesktopEntries.heuristicLookup) { + const entry = DesktopEntries.heuristicLookup(appId); + if (entry && entry.name) { + return entry.name; + } + } + + if (typeof DesktopEntries !== 'undefined' && DesktopEntries.byId) { + const entry = DesktopEntries.byId(appId); + if (entry && entry.name) { + return entry.name; + } + } + } catch (e) + // Fall through to return original appId + {} + + // Return original appId if we can't find a desktop entry + return appId; + } + + // Function to update the combined dock apps model + function updateDockApps() { + const runningApps = ToplevelManager ? (ToplevelManager.toplevels.values || []) : []; + const pinnedApps = Settings.data.dock.pinnedApps || []; + const combined = []; + const processedToplevels = new Set(); + const processedPinnedAppIds = new Set(); + + // push an app onto combined with the given appType + function pushApp(appType, toplevel, appId, title) { + // Use canonical ID for pinned apps to ensure key stability + const canonicalId = isAppIdPinned(appId, pinnedApps) ? (pinnedApps.find(p => normalizeAppId(p) === normalizeAppId(appId)) || appId) : appId; + + // For running apps, track by toplevel object to allow multiple instances + if (toplevel) { + if (processedToplevels.has(toplevel)) { + return; + } + if (Settings.data.dock.onlySameOutput && toplevel.screens && !toplevel.screens.includes(screen)) { + return; + } + combined.push({ + "type": appType, + "toplevel": toplevel, + "appId": canonicalId, + "title": title + }); + processedToplevels.add(toplevel); + } else { + // For pinned apps that aren't running, track by appId to avoid duplicates + if (processedPinnedAppIds.has(canonicalId)) { + return; + } + combined.push({ + "type": appType, + "toplevel": toplevel, + "appId": canonicalId, + "title": title + }); + processedPinnedAppIds.add(canonicalId); + } + } + + function pushRunning(first) { + runningApps.forEach(toplevel => { + if (toplevel) { + // Use robust matching to check if pinned + const isPinned = isAppIdPinned(toplevel.appId, pinnedApps); + if (!first && isPinned && processedToplevels.has(toplevel)) { + return; // Already added by pushPinned() + } + pushApp((first && isPinned) ? "pinned-running" : "running", toplevel, toplevel.appId, toplevel.title); + } + }); + } + + function pushPinned() { + pinnedApps.forEach(pinnedAppId => { + // Find all running instances of this pinned app using robust matching + const matchingToplevels = runningApps.filter(app => app && normalizeAppId(app.appId) === normalizeAppId(pinnedAppId)); + + if (matchingToplevels.length > 0) { + // Add all running instances as pinned-running + matchingToplevels.forEach(toplevel => { + pushApp("pinned-running", toplevel, pinnedAppId, toplevel.title); + }); + } else { + // App is pinned but not running - add once + pushApp("pinned", null, pinnedAppId, pinnedAppId); + } + }); + } + + // if pinnedStatic then push all pinned and then all remaining running apps + if (Settings.data.dock.pinnedStatic) { + pushPinned(); + pushRunning(false); + + // else add all running apps and then remaining pinned apps + } else { + pushRunning(true); + pushPinned(); + } + + dockApps = sortDockApps(combined); + + // Sync session order if needed + if (!sessionAppOrder || sessionAppOrder.length === 0) { + sessionAppOrder = dockApps.map(getAppKey); + } else { + const currentKeys = new Set(dockApps.map(getAppKey)); + const existingKeys = new Set(); + const newOrder = []; + + // Keep existing keys that are still present + sessionAppOrder.forEach(key => { + if (currentKeys.has(key)) { + newOrder.push(key); + existingKeys.add(key); + } + }); + + // Add new keys at the end + dockApps.forEach(app => { + const key = getAppKey(app); + if (!existingKeys.has(key)) { + newOrder.push(key); + existingKeys.add(key); + } + }); + + if (JSON.stringify(newOrder) !== JSON.stringify(sessionAppOrder)) { + sessionAppOrder = newOrder; + } + } + } + + // Update dock apps when toplevels change + Connections { + target: ToplevelManager ? ToplevelManager.toplevels : null + function onValuesChanged() { + updateDockApps(); + } + } + + // Update dock apps when pinned apps change + Connections { + target: Settings.data.dock + function onPinnedAppsChanged() { + updateDockApps(); + } + function onOnlySameOutputChanged() { + updateDockApps(); + } + } + + // Initial update when component is ready + Component.onCompleted: { + if (ToplevelManager) { + updateDockApps(); + } + } + + // Refresh icons when DesktopEntries becomes available + Connections { + target: DesktopEntries.applications + function onValuesChanged() { + root.iconRevision++; + } + } + + Timer { + id: unloadTimer + interval: hideAnimationDuration + 50 + onTriggered: {} + } + + Timer { + id: hideTimer + interval: hideDelay + onTriggered: {} + } + + Timer { + id: showTimer + interval: showDelay + onTriggered: {} + } + + Timer { + id: hoverCloseTimer + interval: hideDelay + onTriggered: { + if (root.menuHovered || (root.currentContextMenu && root.currentContextMenu.visible)) { + restart(); + return; + } + root.isDockHovered = false; + root.close(); + } + } panelContent: Item { id: panelContent property bool allowAttach: true - property real contentPreferredWidth: 300 - property real contentPreferredHeight: 50 - Settings.data.bar.frameThickness + property real frameThickness: isFramed ? Settings.data.bar.frameThickness : 0 + property real contentPreferredWidth: Math.round(dockContainerWrapper.width) - (isVertical ? frameThickness : 0) + property real contentPreferredHeight: Math.round(dockContainerWrapper.height) - (!isVertical ? frameThickness : 0) - // Detect mouse exit to close panel - MouseArea { - anchors.fill: parent - hoverEnabled: true - onEntered: { - root.isDockHovered = true; - } - onExited: { - root.isDockHovered = false; - root.close(); + // Detect hover over panel content (including DockContent) + HoverHandler { + id: dockHoverHandler + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + onHoveredChanged: { + root.isDockHovered = hovered; + if (hovered) { + hoverCloseTimer.stop(); + } else { + if (root.menuHovered || (root.currentContextMenu && root.currentContextMenu.visible)) { + hoverCloseTimer.stop(); + } else { + hoverCloseTimer.restart(); + } + } } } - Rectangle { - anchors.centerIn: parent.centerIn - color: "darkred" - radius: 24 - width: 300 - height: 50 + Item { + id: dockContainerWrapper + readonly property real frameThickness: isFramed ? Settings.data.bar.frameThickness : 0 + width: dockContent.dockContainer.width + height: dockContent.dockContainer.height + anchors.top: root.dockPosition === "bottom" ? parent.top : undefined + anchors.bottom: root.dockPosition === "top" ? parent.bottom : undefined + anchors.left: root.dockPosition === "right" ? parent.left : undefined + anchors.right: root.dockPosition === "left" ? parent.right : undefined - Text { - anchors.centerIn: parent - text: "Static Dock" - color: Settings.data.colorSchemes.darkMode ? "#cdd6f4" : "#4c4f69" + DockContent { + id: dockContent + anchors.fill: parent + dockRoot: root + extraTop: 0 + extraBottom: 0 + extraLeft: 0 + extraRight: 0 } } }