import QtQuick import QtQuick.Controls import QtQuick.Effects import QtQuick.Layouts import Quickshell import Quickshell.Wayland import Quickshell.Widgets import qs.Commons import qs.Services.Compositor import qs.Services.System import qs.Services.UI import qs.Widgets Loader { active: Settings.data.dock.enabled sourceComponent: Variants { model: Quickshell.screens delegate: Item { id: root required property ShellScreen modelData property bool barIsReady: modelData ? BarService.isBarReady(modelData.name) : false Connections { target: BarService function onBarReadyChanged(screenName) { if (screenName === modelData.name) { barIsReady = true; } } } // 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 and names when DesktopEntries becomes available (or updates) Connections { target: DesktopEntries.applications function onValuesChanged() { root.iconRevision++; updateDockApps(); } } // Shared properties between peek and dock windows readonly property string displayMode: Settings.data.dock.displayMode readonly property bool autoHide: displayMode === "auto_hide" readonly property bool exclusive: displayMode === "exclusive" readonly property bool isStaticMode: Settings.data.dock.dockType === "static" 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))) readonly property int peekHeight: 1 readonly property int indicatorThickness: 3 readonly property int iconSize: Math.round(12 + 24 * (Settings.data.dock.size ?? 1)) readonly property int floatingMargin: Settings.data.dock.floatingRatio * Style.marginL readonly property int maxWidth: modelData ? modelData.width * 0.8 : 1000 readonly property int maxHeight: modelData ? modelData.height * 0.8 : 1000 // Dock position properties readonly property string dockPosition: Settings.data.dock.position readonly property bool isVertical: dockPosition === "left" || dockPosition === "right" // Bar detection and positioning properties readonly property bool hasBar: modelData && modelData.name ? (Settings.data.bar.monitors.includes(modelData.name) || (Settings.data.bar.monitors.length === 0)) : false readonly property bool barAtSameEdge: hasBar && Settings.getBarPositionForScreen(modelData?.name) === dockPosition readonly property int barHeight: Style.getBarHeightForScreen(modelData?.name) readonly property bool showFrameIndicator: isStaticMode && Settings.data.bar.barType === "framed" && hasBar && hidden && Settings.data.dock.showFrameIndicator readonly property int frameIndicatorLength: { const count = dockApps.length; if (count <= 0) return 0; const spacing = Style.marginS; const layoutLength = (iconSize * count) + (spacing * Math.max(0, count - 1)); const padded = layoutLength + Style.marginXL; return Math.min(padded, isVertical ? maxHeight : maxWidth); } // 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: [] // 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 // 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(); } } } // Revision counter to force icon re-evaluation property int iconRevision: 0 // Function to close any open context menu function closeAllContextMenus() { if (currentContextMenu && currentContextMenu.visible) { currentContextMenu.hide(); } } function getStaticDockPanel() { return PanelService.getPanel("staticDockPanel", modelData, false); } 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; // Already processed this toplevel instance } if (Settings.data.dock.onlySameOutput && toplevel.screens && !toplevel.screens.includes(modelData)) { return; // Filtered out by onlySameOutput setting } 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; // Already processed this pinned app } 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, getAppNameFromDesktopEntry(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 // Instead of resetting everything when length changes, we reconcile the keys 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; } } } // Timer to unload dock after hide animation completes Timer { id: unloadTimer interval: hideAnimationDuration + 50 // Add small buffer onTriggered: { if (hidden && autoHide) { dockLoaded = false; } } } property alias hideTimer: hideTimer property alias showTimer: showTimer property alias unloadTimer: unloadTimer // Timer for auto-hide delay Timer { id: hideTimer interval: hideDelay onTriggered: { // do not hide if dragging if (root.dragSourceIndex !== -1) { return; } // Force menuHovered to false if no menu is current or visible if (!root.currentContextMenu || !root.currentContextMenu.visible) { menuHovered = false; } 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; } if (panel) panel.close(); } else { closeAllContextMenus(); } hidden = true; unloadTimer.restart(); // Start unload timer when hiding } else if (autoHide && !dockHovered && !peekHovered) { // Restart timer if menu is closing (handles race condition) restart(); } } } // Timer for show delay Timer { id: showTimer interval: showDelay onTriggered: { if (autoHide) { if (isStaticMode) { const panel = getStaticDockPanel(); if (panel && !panel.isPanelOpen) panel.open(); } else { dockLoaded = true; // Load dock immediately } hidden = false; // Then trigger show animation unloadTimer.stop(); // Cancel any pending unload } } } // Watch for autoHide setting changes onAutoHideChanged: { if (!autoHide) { hidden = false; dockLoaded = true; hideTimer.stop(); showTimer.stop(); unloadTimer.stop(); } else { hidden = true; unloadTimer.restart(); // Schedule unload after animation } } // PEEK WINDOW Loader { active: (barIsReady || !hasBar) && modelData && (Settings.data.dock.monitors.length === 0 || Settings.data.dock.monitors.includes(modelData.name)) && (autoHide || isStaticMode) sourceComponent: PanelWindow { id: peekWindow screen: modelData // Dynamic anchors based on dock position anchors.top: dockPosition === "top" || isVertical anchors.bottom: dockPosition === "bottom" || isVertical anchors.left: dockPosition === "left" || !isVertical anchors.right: dockPosition === "right" || !isVertical focusable: false color: "transparent" // When bar is at same edge, position peek window past the bar so it receives mouse events margins.top: dockPosition === "top" && barAtSameEdge && !showFrameIndicator ? (barHeight + (Settings.data.bar.floating ? Settings.data.bar.marginVertical : 0)) : 0 margins.bottom: dockPosition === "bottom" && barAtSameEdge && !showFrameIndicator ? (barHeight + (Settings.data.bar.floating ? Settings.data.bar.marginVertical : 0)) : 0 margins.left: dockPosition === "left" && barAtSameEdge && !showFrameIndicator ? (barHeight + (Settings.data.bar.floating ? Settings.data.bar.marginHorizontal : 0)) : 0 margins.right: dockPosition === "right" && barAtSameEdge && !showFrameIndicator ? (barHeight + (Settings.data.bar.floating ? Settings.data.bar.marginHorizontal : 0)) : 0 WlrLayershell.namespace: "noctalia-dock-peek-" + (screen?.name || "unknown") WlrLayershell.exclusionMode: ExclusionMode.Ignore // Larger peek area when bar is at same edge, normal 1px otherwise implicitHeight: showFrameIndicator ? (isVertical ? Math.round(modelData?.height || 0) : indicatorThickness) : (barAtSameEdge && !isVertical ? indicatorThickness : peekHeight) implicitWidth: showFrameIndicator ? (isVertical ? indicatorThickness : Math.round(modelData?.width || 0)) : (barAtSameEdge && isVertical ? indicatorThickness : peekHeight) Rectangle { anchors.centerIn: parent width: isVertical ? indicatorThickness : frameIndicatorLength height: isVertical ? frameIndicatorLength : indicatorThickness radius: indicatorThickness color: Qt.alpha(Color.mPrimary, 0.6) opacity: showFrameIndicator && frameIndicatorLength > 0 ? 1 : 0 visible: opacity > 0 Behavior on opacity { NumberAnimation { duration: Style.animationFast easing.type: Easing.InOutQuad } } } MouseArea { id: peekArea anchors.fill: parent hoverEnabled: true onEntered: { peekHovered = true; if (isStaticMode && !autoHide) { const panel = getStaticDockPanel(); if (panel && !panel.isPanelOpen) panel.open(); return; } if (hidden) { showTimer.start(); } } onExited: { peekHovered = false; showTimer.stop(); if (!hidden && !dockHovered && !anyAppHovered && !menuHovered) { hideTimer.restart(); } } } } } // Force dock reload when position changes to fix anchor/layout issues // Force dock reload when position/mode changes to fix anchor/layout issues property bool _reloading: false function handleReload() { if (!autoHide && dockLoaded && !_reloading) { _reloading = true; // Brief unload/reload cycle to reset layout Qt.callLater(() => { dockLoaded = false; Qt.callLater(() => { dockLoaded = true; _reloading = false; }); }); } } onDockPositionChanged: handleReload() onExclusiveChanged: handleReload() Loader { id: dockWindowLoader active: Settings.data.dock.enabled && !isStaticMode && (barIsReady || !hasBar) && modelData && (Settings.data.dock.monitors.length === 0 || Settings.data.dock.monitors.includes(modelData.name)) && dockLoaded && ToplevelManager && (dockApps.length > 0) sourceComponent: PanelWindow { id: dockWindow screen: modelData focusable: false color: "transparent" WlrLayershell.namespace: "noctalia-dock-" + (screen?.name || "unknown") WlrLayershell.exclusionMode: exclusive ? ExclusionMode.Auto : ExclusionMode.Ignore implicitWidth: dockContainerWrapper.width implicitHeight: dockContainerWrapper.height // Position based on dock setting anchors.top: dockPosition === "top" anchors.bottom: dockPosition === "bottom" anchors.left: dockPosition === "left" anchors.right: dockPosition === "right" // Offset past bar when at same edge (skip bar offset if dock is exclusive - exclusion zones stack) margins.top: dockPosition === "top" ? (barAtSameEdge && !exclusive ? barHeight + (Settings.data.bar.floating ? Settings.data.bar.marginVertical : 0) + floatingMargin : floatingMargin) : 0 margins.bottom: dockPosition === "bottom" ? (barAtSameEdge && !exclusive ? barHeight + (Settings.data.bar.floating ? Settings.data.bar.marginVertical : 0) + floatingMargin : floatingMargin) : 0 margins.left: dockPosition === "left" ? (barAtSameEdge && !exclusive ? barHeight + (Settings.data.bar.floating ? Settings.data.bar.marginHorizontal : 0) + floatingMargin : floatingMargin) : 0 margins.right: dockPosition === "right" ? (barAtSameEdge && !exclusive ? barHeight + (Settings.data.bar.floating ? Settings.data.bar.marginHorizontal : 0) + floatingMargin : floatingMargin) : 0 // Container wrapper for animations Item { id: dockContainerWrapper // Helper properties for orthogonal bar detection readonly property string screenBarPosition: Settings.getBarPositionForScreen(modelData?.name) readonly property bool barOnLeft: hasBar && screenBarPosition === "left" && !Settings.data.bar.floating readonly property bool barOnRight: hasBar && screenBarPosition === "right" && !Settings.data.bar.floating readonly property bool barOnTop: hasBar && screenBarPosition === "top" && !Settings.data.bar.floating readonly property bool barOnBottom: hasBar && screenBarPosition === "bottom" && !Settings.data.bar.floating // Calculate padding needed to shift center to match exclusive mode readonly property int extraTop: (isVertical && !exclusive && barOnTop) ? barHeight : 0 readonly property int extraBottom: (isVertical && !exclusive && barOnBottom) ? barHeight : 0 readonly property int extraLeft: (!isVertical && !exclusive && barOnLeft) ? barHeight : 0 readonly property int extraRight: (!isVertical && !exclusive && barOnRight) ? barHeight : 0 width: dockContent.dockContainer.width + extraLeft + extraRight height: dockContent.dockContainer.height + extraTop + extraBottom anchors.horizontalCenter: isVertical ? undefined : parent.horizontalCenter anchors.verticalCenter: isVertical ? parent.verticalCenter : undefined anchors.top: dockPosition === "top" ? parent.top : undefined anchors.bottom: dockPosition === "bottom" ? parent.bottom : undefined anchors.left: dockPosition === "left" ? parent.left : undefined anchors.right: dockPosition === "right" ? parent.right : undefined opacity: hidden ? 0 : 1 scale: hidden ? 0.85 : 1 Behavior on opacity { NumberAnimation { duration: hidden ? hideAnimationDuration : showAnimationDuration easing.type: Easing.InOutQuad } } Behavior on scale { NumberAnimation { duration: hidden ? hideAnimationDuration : showAnimationDuration easing.type: hidden ? Easing.InQuad : Easing.OutBack easing.overshoot: hidden ? 0 : 1.05 } } DockContent { id: dockContent anchors.fill: parent dockRoot: root extraTop: dockContainerWrapper.extraTop extraBottom: dockContainerWrapper.extraBottom extraLeft: dockContainerWrapper.extraLeft extraRight: dockContainerWrapper.extraRight } } } } } } }