import QtQuick import Quickshell import Quickshell.Hyprland import qs.Common import qs.Services Item { id: root property var contextMenu: null property bool requestDockShow: false property int pinnedAppCount: 0 property bool groupByApp: false property bool isVertical: false property var dockScreen: null property real iconSize: 40 property int draggedIndex: -1 property int dropTargetIndex: -1 property bool suppressShiftAnimation: false property int maxVisibleApps: SettingsData.dockMaxVisibleApps property int maxVisibleRunningApps: SettingsData.dockMaxVisibleRunningApps property bool overflowExpanded: false property int overflowItemCount: 0 property bool draggingPinned: false property real maxAvailableLength: 0 // Calculate if scrolling is needed (use childrenRect for accurate measurement) readonly property bool canScroll: { if (!root.isVertical) { return layoutFlow.childrenRect.width > availableScreenWidth; } else { return layoutFlow.childrenRect.height > availableScreenHeight; } } // Available space calculations - 10% padding for nice margins when scrolling readonly property real availableScreenWidth: { if (!root.isVertical && maxAvailableLength > 0) { return maxAvailableLength * 0.9; // 10% padding on sides } if (dockScreen && dockScreen.geometry && dockScreen.geometry.width) { return dockScreen.geometry.width - 200; } return 1720; } readonly property real availableScreenHeight: { if (root.isVertical && maxAvailableLength > 0) { return maxAvailableLength * 0.9; // 10% padding } if (dockScreen && dockScreen.geometry && dockScreen.geometry.height) { return dockScreen.geometry.height - 100; } return 980; } clip: false implicitWidth: isVertical ? appLayout.height : appLayout.width implicitHeight: isVertical ? appLayout.width : appLayout.height function dockIndexToPinnedIndex(dockIndex) { if (!SettingsData.dockLauncherEnabled) { return dockIndex; } const launcherPos = SessionData.dockLauncherPosition; if (dockIndex < launcherPos) { return dockIndex; } else { return dockIndex - 1; } } function movePinnedApp(fromDockIndex, toDockIndex) { console.warn("movePinnedApp: dock", fromDockIndex, "->", toDockIndex); const fromPinnedIndex = dockIndexToPinnedIndex(fromDockIndex); const toPinnedIndex = dockIndexToPinnedIndex(toDockIndex); console.warn(" Converted to pinned indices:", fromPinnedIndex, "->", toPinnedIndex); if (fromPinnedIndex === toPinnedIndex) { console.warn(" Same pinned index, skipping"); return; } const currentPinned = [...(SessionData.pinnedApps || [])]; console.warn(" Current pinned count:", currentPinned.length); if (fromPinnedIndex < 0 || fromPinnedIndex >= currentPinned.length || toPinnedIndex < 0 || toPinnedIndex >= currentPinned.length) { console.warn(" Invalid pinned indices! from:", fromPinnedIndex, "to:", toPinnedIndex, "length:", currentPinned.length); return; } const movedApp = currentPinned.splice(fromPinnedIndex, 1)[0]; console.warn(" Moving app:", movedApp); currentPinned.splice(toPinnedIndex, 0, movedApp); SessionData.setPinnedApps(currentPinned); console.warn(" Move complete"); } function movePinnedAppByPinnedIndex(fromPinnedIndex, toPinnedIndex) { console.warn("movePinnedAppByPinnedIndex:", fromPinnedIndex, "->", toPinnedIndex); if (fromPinnedIndex === toPinnedIndex) { console.warn(" Same index, skipping"); return; } const currentPinned = [...(SessionData.pinnedApps || [])]; console.warn(" Current pinned count:", currentPinned.length); if (fromPinnedIndex < 0 || fromPinnedIndex >= currentPinned.length || toPinnedIndex < 0 || toPinnedIndex >= currentPinned.length) { console.warn(" Invalid indices! from:", fromPinnedIndex, "to:", toPinnedIndex, "length:", currentPinned.length); return; } const movedApp = currentPinned.splice(fromPinnedIndex, 1)[0]; console.warn(" Moving app:", movedApp); currentPinned.splice(toPinnedIndex, 0, movedApp); SessionData.setPinnedApps(currentPinned); console.warn(" Move complete"); } function pinnedIndexForDockIndex(dockIndex) { const items = repeater.dockItems || []; let pinnedIndex = 0; for (let i = 0; i < items.length; i++) { const item = items[i]; if (item.type === "separator" || item.type === "overflow-toggle" || item.type === "launcher") { continue; } if (!(item.type === "pinned" || item.type === "grouped") || !item.isPinned) { continue; } if (i === dockIndex) { return pinnedIndex; } pinnedIndex++; } return -1; } Item { id: appLayout width: layoutFlickable.width height: layoutFlickable.height // Clip when scrolling to prevent apps from overlapping dock bounds clip: root.canScroll // Anchoring for proper dock positioning anchors.horizontalCenter: root.isVertical ? undefined : parent.horizontalCenter anchors.verticalCenter: root.isVertical ? parent.verticalCenter : undefined anchors.left: root.isVertical && SettingsData.dockPosition === SettingsData.Position.Left ? parent.left : undefined anchors.right: root.isVertical && SettingsData.dockPosition === SettingsData.Position.Right ? parent.right : undefined anchors.top: root.isVertical ? undefined : parent.top Flickable { id: layoutFlickable width: { if (!root.isVertical) { const contentWidth = layoutFlow.childrenRect.width; return Math.min(contentWidth, root.availableScreenWidth); } return layoutFlow.childrenRect.width; } height: { if (root.isVertical) { const contentHeight = layoutFlow.childrenRect.height; return Math.min(contentHeight, root.availableScreenHeight); } return layoutFlow.childrenRect.height; } contentWidth: layoutFlow.childrenRect.width contentHeight: layoutFlow.childrenRect.height // Don't clip - let indicators extend beyond. Parent appLayout clips for scrolling. clip: false flickableDirection: root.isVertical ? Flickable.VerticalFlick : Flickable.HorizontalFlick boundsBehavior: Flickable.StopAtBounds // Smooth scrolling maximumFlickVelocity: 2500 flickDeceleration: 1500 Flow { id: layoutFlow flow: root.isVertical ? Flow.TopToBottom : Flow.LeftToRight spacing: Math.min(8, Math.max(4, root.iconSize * 0.08)) Repeater { id: repeater property var dockItems: [] model: ScriptModel { values: repeater.dockItems objectProp: "uniqueKey" } Component.onCompleted: updateModel() function isOnScreen(toplevel, screenName) { if (!toplevel.screens) return false; for (let i = 0; i < toplevel.screens.length; i++) { if (toplevel.screens[i]?.name === screenName) return true; } return false; } function getCoreAppData(appId) { if (typeof AppSearchService === "undefined") return null; const coreApps = AppSearchService.coreApps || []; for (let i = 0; i < coreApps.length; i++) { const app = coreApps[i]; if (app.builtInPluginId === appId) { return app; } } return null; } function getCoreAppDataByTitle(windowTitle) { if (typeof AppSearchService === "undefined" || !windowTitle) return null; const coreApps = AppSearchService.coreApps || []; for (let i = 0; i < coreApps.length; i++) { const app = coreApps[i]; if (app.name === windowTitle) { return app; } } return null; } function insertLauncher(targetArray) { if (!SettingsData.dockLauncherEnabled) return; const launcherItem = { uniqueKey: "launcher_button", type: "launcher", appId: "__LAUNCHER__", toplevel: null, isPinned: true, isRunning: false }; const pos = Math.max(0, Math.min(SessionData.dockLauncherPosition, targetArray.length)); targetArray.splice(pos, 0, launcherItem); } function updateModel() { const items = []; const pinnedApps = [...(SessionData.pinnedApps || [])]; const allToplevels = CompositorService.sortedToplevels; const sortedToplevels = (SettingsData.dockIsolateDisplays && root.dockScreen) ? allToplevels.filter(t => isOnScreen(t, root.dockScreen.name)) : allToplevels; const runningAppIds = new Set(); const windowItems = []; if (!root.groupByApp) { sortedToplevels.forEach((toplevel, index) => { let uniqueKey = "window_" + index; if (CompositorService.isHyprland && Hyprland.toplevels) { const hyprlandToplevels = Array.from(Hyprland.toplevels.values); for (let i = 0; i < hyprlandToplevels.length; i++) { if (hyprlandToplevels[i].wayland === toplevel) { uniqueKey = "window_" + hyprlandToplevels[i].address; break; } } } const rawAppId = toplevel.appId || "unknown"; const moddedAppId = Paths.moddedAppId(rawAppId); let coreAppData = null; let isCoreApp = false; if (rawAppId === "org.quickshell") { coreAppData = getCoreAppDataByTitle(toplevel.title); if (coreAppData) { isCoreApp = true; } } const finalAppId = isCoreApp ? coreAppData.builtInPluginId : moddedAppId; windowItems.push({ uniqueKey: uniqueKey, type: "window", appId: finalAppId, toplevel: toplevel, isPinned: false, isRunning: true, isCoreApp: isCoreApp, coreAppData: coreAppData }); runningAppIds.add(finalAppId); }); } if (root.groupByApp) { const appGroups = new Map(); pinnedApps.forEach(rawAppId => { const appId = Paths.moddedAppId(rawAppId); const coreAppData = getCoreAppData(appId); appGroups.set(appId, { appId: appId, isPinned: true, windows: [], isCoreApp: coreAppData !== null, coreAppData: coreAppData }); }); sortedToplevels.forEach((toplevel, index) => { const rawAppId = toplevel.appId || "unknown"; let appId = Paths.moddedAppId(rawAppId); let coreAppData = null; if (rawAppId === "org.quickshell") { coreAppData = getCoreAppDataByTitle(toplevel.title); if (coreAppData) { appId = coreAppData.builtInPluginId; } } if (!appGroups.has(appId)) { appGroups.set(appId, { appId: appId, isPinned: false, windows: [], isCoreApp: coreAppData !== null, coreAppData: coreAppData }); } appGroups.get(appId).windows.push({ toplevel: toplevel, index: index }); }); const pinnedGroups = []; const unpinnedGroups = []; Array.from(appGroups.entries()).forEach(([appId, group]) => { const firstWindow = group.windows.length > 0 ? group.windows[0] : null; const item = { uniqueKey: "grouped_" + appId, type: "grouped", appId: appId, toplevel: firstWindow ? firstWindow.toplevel : null, isPinned: group.isPinned, isRunning: group.windows.length > 0, windowCount: group.windows.length, allWindows: group.windows, isCoreApp: group.isCoreApp || false, coreAppData: group.coreAppData || null }; if (group.isPinned) { pinnedGroups.push(item); } else { unpinnedGroups.push(item); } }); pinnedGroups.forEach(item => items.push(item)); insertLauncher(items); if (pinnedGroups.length > 0 && unpinnedGroups.length > 0) { items.push({ uniqueKey: "separator_grouped", type: "separator", appId: "__SEPARATOR__", toplevel: null, isPinned: false, isRunning: false }); } unpinnedGroups.forEach(item => items.push(item)); root.pinnedAppCount = pinnedGroups.length + (SettingsData.dockLauncherEnabled ? 1 : 0); } else { const remainingWindowItems = windowItems.slice(); pinnedApps.forEach(rawAppId => { const appId = Paths.moddedAppId(rawAppId); const coreAppData = getCoreAppData(appId); const matchIndex = remainingWindowItems.findIndex(item => item.appId === appId); if (matchIndex !== -1) { const windowItem = remainingWindowItems.splice(matchIndex, 1)[0]; windowItem.isPinned = true; if (!windowItem.isCoreApp && coreAppData) { windowItem.isCoreApp = true; windowItem.coreAppData = coreAppData; } items.push(windowItem); } else { items.push({ uniqueKey: "pinned_" + appId, type: "pinned", appId: appId, toplevel: null, isPinned: true, isRunning: runningAppIds.has(appId), isCoreApp: coreAppData !== null, coreAppData: coreAppData }); } }); root.pinnedAppCount = pinnedApps.length + (SettingsData.dockLauncherEnabled ? 1 : 0); insertLauncher(items); if (pinnedApps.length > 0 && remainingWindowItems.length > 0) { items.push({ uniqueKey: "separator_ungrouped", type: "separator", appId: "__SEPARATOR__", toplevel: null, isPinned: false, isRunning: false }); } remainingWindowItems.forEach(item => items.push(item)); } // Overflow logic const countableItems = items.filter(item => (item.type === "pinned" || item.type === "grouped" || item.type === "window") && item.isPinned && item.appId !== "__LAUNCHER__"); const hideRunningItems = root.maxVisibleRunningApps === 0; let runningItems = []; if (!hideRunningItems) { if (root.groupByApp) { runningItems = items.filter(item => item.type === "grouped" && item.isRunning && !item.isPinned); } else { runningItems = items.filter(item => item.type === "window" && item.isRunning && !item.isPinned); } } let uniqueRunningItems = runningItems; let duplicateRunningItems = []; if (!root.groupByApp && runningItems.length > 0) { const pinnedAppIds = new Set(items.filter(item => item.isPinned).map(item => item.appId)); const seenRunningIds = new Set(); uniqueRunningItems = []; duplicateRunningItems = []; for (let i = 0; i < runningItems.length; i++) { const item = runningItems[i]; if (pinnedAppIds.has(item.appId) || seenRunningIds.has(item.appId)) { duplicateRunningItems.push(item); continue; } seenRunningIds.add(item.appId); uniqueRunningItems.push(item); } } const pinnedOverflowNeeded = root.maxVisibleApps > 0 && countableItems.length > root.maxVisibleApps; const runningOverflowNeeded = !hideRunningItems && root.maxVisibleRunningApps > 0 && uniqueRunningItems.length > root.maxVisibleRunningApps; const overflowNeeded = pinnedOverflowNeeded || runningOverflowNeeded; if (overflowNeeded) { const visibleCountable = pinnedOverflowNeeded ? countableItems.slice(0, root.maxVisibleApps) : countableItems.slice(0, countableItems.length); const overflowCountable = pinnedOverflowNeeded ? countableItems.slice(root.maxVisibleApps) : []; const visibleRunning = !hideRunningItems ? uniqueRunningItems.slice(0, root.maxVisibleRunningApps) : []; const overflowRunning = !hideRunningItems ? uniqueRunningItems.slice(root.maxVisibleRunningApps) : []; const combinedOverflowRunning = duplicateRunningItems.concat(overflowRunning); const finalItems = []; // Add items in order, preserving launcher position let visibleIndex = 0; for (let i = 0; i < items.length; i++) { const item = items[i]; if (item.type === "launcher") { finalItems.push(item); } else if (visibleIndex < visibleCountable.length && item.uniqueKey === visibleCountable[visibleIndex].uniqueKey) { finalItems.push(item); visibleIndex++; } } const totalOverflowCount = overflowCountable.length + combinedOverflowRunning.length; const hasSeparator = items.some(item => item.type === "separator"); const shouldShowSeparator = hasSeparator && (visibleRunning.length > 0 || totalOverflowCount > 0); if (shouldShowSeparator) { finalItems.push({ uniqueKey: "separator", type: "separator", appId: "__SEPARATOR__", toplevel: null, isPinned: false, isRunning: false }); } for (let i = 0; i < visibleRunning.length; i++) { finalItems.push(visibleRunning[i]); } if (totalOverflowCount > 0) { finalItems.push({ uniqueKey: "overflow_toggle", type: "overflow-toggle", appId: "__OVERFLOW_TOGGLE__", toplevel: null, isPinned: false, isRunning: false, overflowCount: totalOverflowCount }); } for (let i = 0; i < overflowCountable.length; i++) { const item = overflowCountable[i]; const overflowItem = { uniqueKey: item.uniqueKey, type: item.type, appId: item.appId, toplevel: item.toplevel, isPinned: item.isPinned, isRunning: item.isRunning, windowCount: item.windowCount, allWindows: item.allWindows, isCoreApp: item.isCoreApp, coreAppData: item.coreAppData, isInOverflow: true }; finalItems.push(overflowItem); } for (let i = 0; i < combinedOverflowRunning.length; i++) { const item = combinedOverflowRunning[i]; const overflowItem = { uniqueKey: item.uniqueKey, type: item.type, appId: item.appId, toplevel: item.toplevel, isPinned: false, isRunning: true, windowCount: item.windowCount, allWindows: item.allWindows, isCoreApp: item.isCoreApp, coreAppData: item.coreAppData, isInOverflow: true }; finalItems.push(overflowItem); } root.overflowItemCount = totalOverflowCount; root.pinnedAppCount = countableItems.length; dockItems = finalItems; } else { root.overflowItemCount = 0; root.pinnedAppCount = countableItems.length; if (hideRunningItems) { const filteredItems = items.filter(item => !((item.type === "window" || item.type === "grouped") && item.isRunning && !item.isPinned) && item.type !== "separator"); dockItems = filteredItems; } else { dockItems = items; } } } delegate: Item { id: delegateItem property var dockButton: itemData.type === "launcher" ? launcherButton : button property var itemData: modelData readonly property bool isOverflowToggle: itemData.type === "overflow-toggle" readonly property bool isInOverflow: itemData.isInOverflow === true clip: false z: (itemData.type === "launcher" ? launcherButton.dragging : button.dragging) ? 100 : 0 // Overflow items: hidden when collapsed, visible when expanded visible: !isInOverflow || root.overflowExpanded // Overflow items collapse to 0 size when hidden width: (isInOverflow && !root.overflowExpanded) ? 0 : (itemData.type === "separator" ? (root.isVertical ? root.iconSize : 8) : (root.isVertical ? root.iconSize : root.iconSize * 1.2)) height: (isInOverflow && !root.overflowExpanded) ? 0 : (itemData.type === "separator" ? (root.isVertical ? 8 : root.iconSize) : (root.isVertical ? root.iconSize * 1.2 : root.iconSize)) opacity: (isInOverflow && !root.overflowExpanded) ? 0 : 1 scale: (isInOverflow && !root.overflowExpanded) ? 0.8 : 1 Behavior on opacity { NumberAnimation { duration: Theme.shortDuration easing.type: Easing.OutCubic } } Behavior on scale { NumberAnimation { duration: Theme.shortDuration easing.type: Easing.OutCubic } } // No Behavior on width/height - they must change immediately // so Flow layout properly excludes hidden overflow items // Visual smoothness comes from opacity and scale animations property real shiftOffset: { if (root.draggedIndex < 0 || !itemData.isPinned || itemData.type === "separator") return 0; const myIdx = root.draggingPinned ? root.pinnedIndexForDockIndex(model.index) : model.index; if (myIdx < 0) return 0; if (myIdx === root.draggedIndex) return 0; const dragIdx = root.draggedIndex; const dropIdx = root.dropTargetIndex; const shiftAmount = root.iconSize * 1.2 + layoutFlow.spacing; if (dropIdx < 0) return 0; if (dragIdx < dropIdx && myIdx > dragIdx && myIdx <= dropIdx) return -shiftAmount; if (dragIdx > dropIdx && myIdx >= dropIdx && myIdx < dragIdx) return shiftAmount; return 0; } transform: Translate { x: root.isVertical ? 0 : delegateItem.shiftOffset y: root.isVertical ? delegateItem.shiftOffset : 0 Behavior on x { enabled: !root.suppressShiftAnimation NumberAnimation { duration: 150 easing.type: Easing.OutCubic } } Behavior on y { enabled: !root.suppressShiftAnimation NumberAnimation { duration: 150 easing.type: Easing.OutCubic } } } Rectangle { visible: itemData.type === "separator" width: root.isVertical ? root.iconSize * 0.5 : 2 height: root.isVertical ? 2 : root.iconSize * 0.5 color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.3) radius: 1 anchors.centerIn: parent } DockOverflowButton { id: overflowButton visible: isOverflowToggle anchors.centerIn: parent width: delegateItem.width height: delegateItem.height actualIconSize: root.iconSize overflowCount: itemData.overflowCount || 0 overflowExpanded: root.overflowExpanded isVertical: root.isVertical onClicked: root.overflowExpanded = !root.overflowExpanded } DockLauncherButton { id: launcherButton visible: itemData.type === "launcher" anchors.centerIn: parent width: delegateItem.width height: delegateItem.height actualIconSize: root.iconSize dockApps: root index: model.index } DockAppButton { id: button visible: !isOverflowToggle && itemData.type !== "separator" && itemData.type !== "launcher" anchors.centerIn: parent width: delegateItem.width height: delegateItem.height actualIconSize: root.iconSize appData: itemData contextMenu: root.contextMenu dockApps: root index: model.index parentDockScreen: root.dockScreen showWindowTitle: itemData?.type === "window" || itemData?.type === "grouped" windowTitle: { const title = itemData?.toplevel?.title || "(Unnamed)"; return title.length > 50 ? title.substring(0, 47) + "..." : title; } } } } } } } Connections { target: CompositorService function onToplevelsChanged() { repeater.updateModel(); } } Connections { target: SessionData function onPinnedAppsChanged() { root.suppressShiftAnimation = true; root.draggedIndex = -1; root.dropTargetIndex = -1; root.draggingPinned = false; repeater.updateModel(); Qt.callLater(() => { root.suppressShiftAnimation = false; }); } } onGroupByAppChanged: repeater.updateModel() // Don't rebuild model on overflow toggle - delegates react to overflowExpanded via bindings // Model structure doesn't change, only delegate visibility changes Connections { target: SettingsData function onDockIsolateDisplaysChanged() { repeater.updateModel(); } function onDockLauncherEnabledChanged() { root.suppressShiftAnimation = true; root.draggedIndex = -1; root.dropTargetIndex = -1; repeater.updateModel(); Qt.callLater(() => { root.suppressShiftAnimation = false; }); } } Connections { target: SessionData function onDockLauncherPositionChanged() { root.suppressShiftAnimation = true; root.draggedIndex = -1; root.dropTargetIndex = -1; repeater.updateModel(); Qt.callLater(() => { root.suppressShiftAnimation = false; }); } } Connections { target: SettingsData function onDockMaxVisibleAppsChanged() { repeater.updateModel(); } } Connections { target: SettingsData function onDockMaxVisibleRunningAppsChanged() { repeater.updateModel(); } } }