diff --git a/quickshell/Common/SessionData.qml b/quickshell/Common/SessionData.qml index cb88e99b..577c2f4b 100644 --- a/quickshell/Common/SessionData.qml +++ b/quickshell/Common/SessionData.qml @@ -83,6 +83,7 @@ Singleton { property string nightModeLocationProvider: "" property var pinnedApps: [] + property var barPinnedApps: [] property int dockLauncherPosition: 0 property var hiddenTrayIds: [] property var recentColors: [] @@ -784,6 +785,32 @@ Singleton { return appId && pinnedApps.indexOf(appId) !== -1; } + function setBarPinnedApps(apps) { + barPinnedApps = apps; + saveSettings(); + } + + function addBarPinnedApp(appId) { + if (!appId) + return; + var currentPinned = [...barPinnedApps]; + if (currentPinned.indexOf(appId) === -1) { + currentPinned.push(appId); + setBarPinnedApps(currentPinned); + } + } + + function removeBarPinnedApp(appId) { + if (!appId) + return; + var currentPinned = barPinnedApps.filter(id => id !== appId); + setBarPinnedApps(currentPinned); + } + + function isBarPinnedApp(appId) { + return appId && barPinnedApps.indexOf(appId) !== -1; + } + function hideTrayId(trayId) { if (!trayId) return; diff --git a/quickshell/Common/settings/SessionSpec.js b/quickshell/Common/settings/SessionSpec.js index f0a862d4..4acb24f1 100644 --- a/quickshell/Common/settings/SessionSpec.js +++ b/quickshell/Common/settings/SessionSpec.js @@ -39,6 +39,7 @@ var SPEC = { weatherCoordinates: { def: "40.7128,-74.0060" }, pinnedApps: { def: [] }, + barPinnedApps: { def: [] }, dockLauncherPosition: { def: 0 }, hiddenTrayIds: { def: [] }, recentColors: { def: [] }, diff --git a/quickshell/Modules/DankBar/DankBarContent.qml b/quickshell/Modules/DankBar/DankBarContent.qml index b3a9670a..3f813553 100644 --- a/quickshell/Modules/DankBar/DankBarContent.qml +++ b/quickshell/Modules/DankBar/DankBarContent.qml @@ -302,6 +302,7 @@ Item { "workspaceSwitcher": workspaceSwitcherComponent, "focusedWindow": focusedWindowComponent, "runningApps": runningAppsComponent, + "appsDock": appsDockComponent, "clock": clockComponent, "music": mediaComponent, "weather": weatherComponent, @@ -343,6 +344,7 @@ Item { "workspaceSwitcherComponent": workspaceSwitcherComponent, "focusedWindowComponent": focusedWindowComponent, "runningAppsComponent": runningAppsComponent, + "appsDockComponent": appsDockComponent, "clockComponent": clockComponent, "mediaComponent": mediaComponent, "weatherComponent": weatherComponent, @@ -660,6 +662,21 @@ Item { } } + Component { + id: appsDockComponent + + AppsDock { + widgetThickness: barWindow.widgetThickness + barThickness: barWindow.effectiveBarThickness + barSpacing: barConfig?.spacing ?? 4 + section: topBarContent.getWidgetSection(parent) + parentScreen: barWindow.screen + topBar: topBarContent + barConfig: topBarContent.barConfig + isAutoHideBar: topBarContent.barConfig?.autoHide ?? false + } + } + Component { id: clockComponent diff --git a/quickshell/Modules/DankBar/WidgetHost.qml b/quickshell/Modules/DankBar/WidgetHost.qml index e3fdf4a5..bef4ec11 100644 --- a/quickshell/Modules/DankBar/WidgetHost.qml +++ b/quickshell/Modules/DankBar/WidgetHost.qml @@ -242,7 +242,8 @@ Loader { "colorPicker": components.colorPickerComponent, "systemUpdate": components.systemUpdateComponent, "layout": components.layoutComponent, - "powerMenuButton": components.powerMenuButtonComponent + "powerMenuButton": components.powerMenuButtonComponent, + "appsDock": components.appsDockComponent }; if (componentMap[widgetId]) { diff --git a/quickshell/Modules/DankBar/Widgets/AppsDock.qml b/quickshell/Modules/DankBar/Widgets/AppsDock.qml new file mode 100644 index 00000000..971b93cf --- /dev/null +++ b/quickshell/Modules/DankBar/Widgets/AppsDock.qml @@ -0,0 +1,867 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Effects +import Quickshell +import Quickshell.Wayland +import Quickshell.Widgets +import qs.Common +import qs.Services +import qs.Widgets + +Item { + id: root + + property var widgetData: null + property var barConfig: null + property bool isVertical: axis?.isVertical ?? false + property var axis: null + property string section: "left" + property var parentScreen + property var hoveredItem: null + property var topBar: null + property real widgetThickness: 30 + property real barThickness: 48 + property real barSpacing: 4 + property bool isAutoHideBar: false + readonly property real horizontalPadding: (barConfig?.noBackground ?? false) ? 2 : Theme.spacingS + property Item windowRoot: (Window.window ? Window.window.contentItem : null) + + property int draggedIndex: -1 + property int dropTargetIndex: -1 + property bool suppressShiftAnimation: false + property int pinnedAppCount: 0 + + readonly property real effectiveBarThickness: { + if (barThickness > 0 && barSpacing > 0) { + return barThickness + barSpacing; + } + const innerPadding = barConfig?.innerPadding ?? 4; + const spacing = barConfig?.spacing ?? 4; + return Math.max(26 + innerPadding * 0.6, Theme.barHeight - 4 - (8 - innerPadding)) + spacing; + } + + readonly property var barBounds: { + if (!parentScreen || !barConfig) { + return { + "x": 0, + "y": 0, + "width": 0, + "height": 0, + "wingSize": 0 + }; + } + const barPosition = axis.edge === "left" ? 2 : (axis.edge === "right" ? 3 : (axis.edge === "top" ? 0 : 1)); + return SettingsData.getBarBounds(parentScreen, effectiveBarThickness, barPosition, barConfig); + } + + readonly property real barY: barBounds.y + + readonly property real minTooltipY: { + if (!parentScreen || !isVertical) { + return 0; + } + + if (isAutoHideBar) { + return 0; + } + + if (parentScreen.y > 0) { + return effectiveBarThickness; + } + + return 0; + } + + // --- Dock Logic Helpers --- + function movePinnedApp(fromDockIndex, toDockIndex) { + if (fromDockIndex === toDockIndex) + return; + + const currentPinned = [...(SessionData.barPinnedApps || [])]; + if (fromDockIndex < 0 || fromDockIndex >= currentPinned.length || toDockIndex < 0 || toDockIndex >= currentPinned.length) { + return; + } + + const movedApp = currentPinned.splice(fromDockIndex, 1)[0]; + currentPinned.splice(toDockIndex, 0, movedApp); + + SessionData.setBarPinnedApps(currentPinned); + } + + property int _desktopEntriesUpdateTrigger: 0 + property int _toplevelsUpdateTrigger: 0 + property int _appIdSubstitutionsTrigger: 0 + + Connections { + target: CompositorService + function onToplevelsChanged() { + _toplevelsUpdateTrigger++; + updateModel(); + } + } + + Connections { + target: DesktopEntries + function onApplicationsChanged() { + _desktopEntriesUpdateTrigger++; + } + } + + Connections { + target: SettingsData + function onAppIdSubstitutionsChanged() { + _appIdSubstitutionsTrigger++; + updateModel(); + } + function onRunningAppsCurrentWorkspaceChanged() { + updateModel(); + } + } + + Connections { + target: SessionData + function onBarPinnedAppsChanged() { + root.suppressShiftAnimation = true; + root.draggedIndex = -1; + root.dropTargetIndex = -1; + updateModel(); + Qt.callLater(() => { + root.suppressShiftAnimation = false; + }); + } + } + + property var dockItems: [] + + 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++) { + if (coreApps[i].builtInPluginId === appId) + return coreApps[i]; + } + return null; + } + + function getCoreAppDataByTitle(windowTitle) { + if (typeof AppSearchService === "undefined" || !windowTitle) + return null; + const coreApps = AppSearchService.coreApps || []; + for (let i = 0; i < coreApps.length; i++) { + if (coreApps[i].name === windowTitle) + return coreApps[i]; + } + return null; + } + + function updateModel() { + const items = []; + const pinnedApps = [...(SessionData.barPinnedApps || [])]; + _toplevelsUpdateTrigger; + const allToplevels = CompositorService.sortedToplevels; + + let sortedToplevels = allToplevels; + if (SettingsData.runningAppsCurrentWorkspace && parentScreen) { + sortedToplevels = CompositorService.filterCurrentWorkspace(allToplevels, parentScreen.name) || []; + } + + 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, + windowTitle: toplevel.title + }); + }); + + 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)); + + 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; + dockItems = items; + } + + Component.onCompleted: updateModel() + + readonly property int calculatedSize: { + const count = dockItems.length; + if (count === 0) + return 0; + + if (widgetData?.runningAppsCompactMode !== undefined ? widgetData.runningAppsCompactMode : SettingsData.runningAppsCompactMode) { + return count * 24 + (count - 1) * Theme.spacingXS + horizontalPadding * 2; + } else { + return count * (24 + Theme.spacingXS + 120) + (count - 1) * Theme.spacingXS + horizontalPadding * 2; + } + } + + readonly property real realCalculatedSize: { + let total = horizontalPadding * 2; + const compact = (widgetData?.runningAppsCompactMode !== undefined ? widgetData.runningAppsCompactMode : SettingsData.runningAppsCompactMode); + + for (let i = 0; i < dockItems.length; i++) { + const item = dockItems[i]; + let itemSize = 0; + if (item.type === "separator") { + itemSize = 8; + } else { + itemSize = compact ? 24 : (24 + Theme.spacingXS + 120); + } + + total += itemSize; + if (i < dockItems.length - 1) + total += Theme.spacingXS; + } + return total; + } + + width: dockItems.length > 0 ? (isVertical ? barThickness : realCalculatedSize) : 0 + height: dockItems.length > 0 ? (isVertical ? realCalculatedSize : barThickness) : 0 + visible: dockItems.length > 0 + + Item { + id: visualBackground + width: root.isVertical ? root.widgetThickness : root.realCalculatedSize + height: root.isVertical ? root.realCalculatedSize : root.widgetThickness + anchors.centerIn: parent + clip: false + + Rectangle { + id: outline + anchors.centerIn: parent + width: { + const borderWidth = (barConfig?.widgetOutlineEnabled ?? false) ? (barConfig?.widgetOutlineThickness ?? 1) : 0; + return parent.width + borderWidth * 2; + } + height: { + const borderWidth = (barConfig?.widgetOutlineEnabled ?? false) ? (barConfig?.widgetOutlineThickness ?? 1) : 0; + return parent.height + borderWidth * 2; + } + radius: (barConfig?.noBackground ?? false) ? 0 : Theme.cornerRadius + color: "transparent" + border.width: (barConfig?.widgetOutlineEnabled ?? false) ? (barConfig?.widgetOutlineThickness ?? 1) : 0 + border.color: { + if (!(barConfig?.widgetOutlineEnabled ?? false)) { + return "transparent"; + } + const colorOption = barConfig?.widgetOutlineColor || "primary"; + const opacity = barConfig?.widgetOutlineOpacity ?? 1.0; + switch (colorOption) { + case "surfaceText": + return Theme.withAlpha(Theme.surfaceText, opacity); + case "secondary": + return Theme.withAlpha(Theme.secondary, opacity); + case "primary": + return Theme.withAlpha(Theme.primary, opacity); + default: + return Theme.withAlpha(Theme.primary, opacity); + } + } + } + + Rectangle { + id: background + anchors.fill: parent + radius: (barConfig?.noBackground ?? false) ? 0 : Theme.cornerRadius + color: { + if (dockItems.length === 0) + return "transparent"; + if ((barConfig?.noBackground ?? false)) + return "transparent"; + + const baseColor = Theme.widgetBaseBackgroundColor; + const transparency = (root.barConfig && root.barConfig.widgetTransparency !== undefined) ? root.barConfig.widgetTransparency : 1.0; + if (Theme.widgetBackgroundHasAlpha) { + return Qt.rgba(baseColor.r, baseColor.g, baseColor.b, baseColor.a * transparency); + } + return Theme.withAlpha(baseColor, transparency); + } + } + } + + Loader { + id: layoutLoader + anchors.centerIn: parent + sourceComponent: root.isVertical ? columnLayout : rowLayout + } + + Component { + id: rowLayout + Row { + spacing: Theme.spacingXS + + Repeater { + id: repeater + model: ScriptModel { + values: root.dockItems + objectProp: "uniqueKey" + } + + delegate: dockDelegate + } + } + } + + Component { + id: columnLayout + Column { + spacing: Theme.spacingXS + + Repeater { + model: ScriptModel { + values: root.dockItems + objectProp: "uniqueKey" + } + delegate: dockDelegate + } + } + } + + Loader { + id: tooltipLoader + active: false + sourceComponent: DankTooltip {} + } + + Component { + id: dockDelegate + Item { + id: delegateItem + property bool isSeparator: modelData.type === "separator" + + readonly property real visualSize: isSeparator ? 8 : ((widgetData?.runningAppsCompactMode !== undefined ? widgetData.runningAppsCompactMode : SettingsData.runningAppsCompactMode) ? 24 : (24 + Theme.spacingXS + 120)) + readonly property real visualWidth: root.isVertical ? root.barThickness : visualSize + readonly property real visualHeight: root.isVertical ? visualSize : root.barThickness + + width: visualWidth + height: visualHeight + + z: (dragHandler.dragging) ? 100 : 0 + + // --- Drag and Drop Shift Animation Logic --- + property real shiftOffset: { + if (root.draggedIndex < 0 || !modelData.isPinned || isSeparator) + return 0; + if (index === root.draggedIndex) + return 0; + + const dragIdx = root.draggedIndex; + const dropIdx = root.dropTargetIndex; + const myIdx = index; + const shiftAmount = visualSize + Theme.spacingXS; + + 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: isSeparator + width: root.isVertical ? root.barThickness * 0.6 : 2 + height: root.isVertical ? 2 : root.barThickness * 0.6 + color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.3) + radius: 1 + anchors.centerIn: parent + } + + Item { + id: appItem + visible: !isSeparator + anchors.fill: parent + + property bool isFocused: { + if (modelData.type === "grouped") { + return modelData.allWindows.some(w => w.toplevel && w.toplevel.activated); + } + return modelData.toplevel ? modelData.toplevel.activated : false; + } + + property var appId: modelData.appId + property int windowCount: modelData.windowCount || (modelData.isRunning ? 1 : 0) + property string windowTitle: { + if (modelData.type === "grouped") { + const active = modelData.allWindows.find(w => w.toplevel && w.toplevel.activated); + if (active) + return active.windowTitle || "(Unnamed)"; + if (modelData.allWindows.length > 0) + return modelData.allWindows[0].windowTitle || "(Unnamed)"; + return ""; + } + return modelData.toplevel ? (modelData.toplevel.title || "(Unnamed)") : ""; + } + + property string tooltipText: { + root._desktopEntriesUpdateTrigger; + const moddedId = Paths.moddedAppId(appId); + const desktopEntry = moddedId ? DesktopEntries.heuristicLookup(moddedId) : null; + const appName = appId ? Paths.getAppName(appId, desktopEntry) : "Unknown"; + + if (modelData.type === "grouped" && windowCount > 1) { + return appName + " (" + windowCount + " windows)"; + } + return appName + (windowTitle ? " • " + windowTitle : ""); + } + + transform: Translate { + x: (dragHandler.dragging && !root.isVertical) ? dragHandler.dragAxisOffset : 0 + y: (dragHandler.dragging && root.isVertical) ? dragHandler.dragAxisOffset : 0 + } + + Rectangle { + id: visualContent + width: root.isVertical ? 24 : delegateItem.visualSize + height: root.isVertical ? delegateItem.visualSize : 24 + anchors.centerIn: parent + radius: Theme.cornerRadius + color: { + if (appItem.isFocused) { + return mouseArea.containsMouse ? Theme.primarySelected : Theme.withAlpha(Theme.primary, 0.2); + } + return mouseArea.containsMouse ? Theme.widgetBaseHoverColor : "transparent"; + } + + border.width: dragHandler.dragging ? 2 : 0 + border.color: Theme.primary + opacity: dragHandler.dragging ? 0.8 : 1.0 + + AppIconRenderer { + id: coreIcon + readonly property bool isCompact: (widgetData?.runningAppsCompactMode !== undefined ? widgetData.runningAppsCompactMode : SettingsData.runningAppsCompactMode) + anchors.left: (root.isVertical || isCompact) ? undefined : parent.left + anchors.leftMargin: (root.isVertical || isCompact) ? 0 : Theme.spacingXS + anchors.top: (root.isVertical && !isCompact) ? parent.top : undefined + anchors.topMargin: (root.isVertical && !isCompact) ? Theme.spacingXS : 0 + anchors.centerIn: (root.isVertical || isCompact) ? parent : undefined + + iconSize: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.noBackground) + materialIconSizeAdjustment: 0 + iconValue: { + if (!modelData || !modelData.isCoreApp || !modelData.coreAppData) + return ""; + const appId = modelData.coreAppData.id || modelData.coreAppData.builtInPluginId; + if ((appId === "dms_settings" || appId === "dms_notepad" || appId === "dms_sysmon") && modelData.coreAppData.cornerIcon) { + return "material:" + modelData.coreAppData.cornerIcon; + } + return modelData.coreAppData.icon || ""; + } + colorOverride: Theme.widgetIconColor + fallbackText: "?" + visible: iconValue !== "" + z: 2 + } + + IconImage { + id: iconImg + readonly property bool isCompact: (widgetData?.runningAppsCompactMode !== undefined ? widgetData.runningAppsCompactMode : SettingsData.runningAppsCompactMode) + anchors.left: (root.isVertical || isCompact) ? undefined : parent.left + anchors.leftMargin: (root.isVertical || isCompact) ? 0 : Theme.spacingXS + anchors.top: (root.isVertical && !isCompact) ? parent.top : undefined + anchors.topMargin: (root.isVertical && !isCompact) ? Theme.spacingXS : 0 + anchors.centerIn: (root.isVertical || isCompact) ? parent : undefined + + width: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.noBackground) + height: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.noBackground) + source: { + root._desktopEntriesUpdateTrigger; + root._appIdSubstitutionsTrigger; + if (!appItem.appId) + return ""; + if (modelData.isCoreApp) + return ""; // Explicitly skip if core app to avoid flickering or wrong look ups + const moddedId = Paths.moddedAppId(appItem.appId); + const desktopEntry = DesktopEntries.heuristicLookup(moddedId); + return Paths.getAppIcon(appItem.appId, desktopEntry); + } + smooth: true + mipmap: true + asynchronous: true + visible: status === Image.Ready && !coreIcon.visible + layer.enabled: appItem.appId === "org.quickshell" + layer.smooth: true + layer.mipmap: true + layer.effect: MultiEffect { + saturation: 0 + colorization: 1 + colorizationColor: Theme.primary + } + z: 2 + } + + DankIcon { + readonly property bool isCompact: (widgetData?.runningAppsCompactMode !== undefined ? widgetData.runningAppsCompactMode : SettingsData.runningAppsCompactMode) + anchors.left: (root.isVertical || isCompact) ? undefined : parent.left + anchors.leftMargin: (root.isVertical || isCompact) ? 0 : Theme.spacingXS + anchors.top: (root.isVertical && !isCompact) ? parent.top : undefined + anchors.topMargin: (root.isVertical && !isCompact) ? Theme.spacingXS : 0 + anchors.centerIn: (root.isVertical || isCompact) ? parent : undefined + + size: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.noBackground) + name: "sports_esports" + color: Theme.widgetTextColor + visible: !iconImg.visible && !coreIcon.visible && Paths.isSteamApp(appItem.appId) + } + + Text { + anchors.centerIn: parent + visible: !iconImg.visible && !coreIcon.visible && !Paths.isSteamApp(appItem.appId) + text: { + root._desktopEntriesUpdateTrigger; + if (!appItem.appId) + return "?"; + const moddedId = Paths.moddedAppId(appItem.appId); + const desktopEntry = DesktopEntries.heuristicLookup(moddedId); + const appName = Paths.getAppName(appItem.appId, desktopEntry); + return appName.charAt(0).toUpperCase(); + } + font.pixelSize: 10 + color: Theme.widgetTextColor + } + + Rectangle { + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.rightMargin: (widgetData?.runningAppsCompactMode !== undefined ? widgetData.runningAppsCompactMode : SettingsData.runningAppsCompactMode) ? -2 : 2 + anchors.bottomMargin: -2 + width: 14 + height: 14 + radius: 7 + color: Theme.primary + visible: modelData.type === "grouped" && appItem.windowCount > 1 + z: 10 + + StyledText { + anchors.centerIn: parent + text: appItem.windowCount > 9 ? "9+" : appItem.windowCount + font.pixelSize: 9 + color: Theme.surface + } + } + + StyledText { + visible: !root.isVertical && !(widgetData?.runningAppsCompactMode !== undefined ? widgetData.runningAppsCompactMode : SettingsData.runningAppsCompactMode) + anchors.left: iconImg.right + anchors.leftMargin: Theme.spacingXS + anchors.right: parent.right + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + text: appItem.windowTitle || appItem.appId + font.pixelSize: Theme.barTextSize(barThickness, barConfig?.fontScale) + color: Theme.widgetTextColor + elide: Text.ElideRight + maximumLineCount: 1 + } + + Rectangle { + visible: modelData.isRunning + width: root.isVertical ? 2 : 20 + height: root.isVertical ? 20 : 2 + radius: 1 + color: appItem.isFocused ? Theme.primary : Theme.surfaceText + opacity: appItem.isFocused ? 1 : 0.5 + + anchors.bottom: root.isVertical ? undefined : parent.bottom + anchors.right: root.isVertical ? parent.right : undefined + anchors.horizontalCenter: root.isVertical ? undefined : parent.horizontalCenter + anchors.verticalCenter: root.isVertical ? parent.verticalCenter : undefined + + anchors.margins: 0 + z: 5 + } + } + } + + // Handler for Drag Logic + Item { + id: dragHandler + anchors.fill: parent + property bool dragging: false + property point dragStartPos: Qt.point(0, 0) + property real dragAxisOffset: 0 + property bool longPressing: false + + Timer { + id: longPressTimer + interval: 500 + repeat: false + onTriggered: { + if (modelData.isPinned) { + dragHandler.longPressing = true; + } + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: dragHandler.longPressing ? Qt.DragMoveCursor : Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + + onPressed: mouse => { + if (mouse.button === Qt.LeftButton && modelData.isPinned) { + dragHandler.dragStartPos = Qt.point(mouse.x, mouse.y); + longPressTimer.start(); + } + } + + onReleased: mouse => { + longPressTimer.stop(); + const wasDragging = dragHandler.dragging; + const didReorder = wasDragging && root.dropTargetIndex >= 0 && root.dropTargetIndex !== root.draggedIndex; + + if (didReorder) { + root.movePinnedApp(root.draggedIndex, root.dropTargetIndex); + } + + dragHandler.longPressing = false; + dragHandler.dragging = false; + dragHandler.dragAxisOffset = 0; + root.draggedIndex = -1; + root.dropTargetIndex = -1; + + if (wasDragging || mouse.button !== Qt.LeftButton) + return; + + if (wasDragging || mouse.button !== Qt.LeftButton) + return; + + if (modelData.type === "grouped") { + if (modelData.windowCount === 0) { + if (modelData.isCoreApp && modelData.coreAppData) { + AppSearchService.executeCoreApp(modelData.coreAppData); + } else { + const moddedId = Paths.moddedAppId(modelData.appId); + const desktopEntry = DesktopEntries.heuristicLookup(moddedId); + if (desktopEntry) + SessionService.launchDesktopEntry(desktopEntry); + } + } else if (modelData.windowCount === 1) { + if (modelData.allWindows[0].toplevel) + modelData.allWindows[0].toplevel.activate(); + } else { + let currentIndex = -1; + for (var i = 0; i < modelData.allWindows.length; i++) { + if (modelData.allWindows[i].toplevel.activated) { + currentIndex = i; + break; + } + } + const nextIndex = (currentIndex + 1) % modelData.allWindows.length; + modelData.allWindows[nextIndex].toplevel.activate(); + } + } + } + + onPositionChanged: mouse => { + if (dragHandler.longPressing && !dragHandler.dragging) { + const distance = Math.sqrt(Math.pow(mouse.x - dragHandler.dragStartPos.x, 2) + Math.pow(mouse.y - dragHandler.dragStartPos.y, 2)); + if (distance > 5) { + dragHandler.dragging = true; + root.draggedIndex = index; + root.dropTargetIndex = index; + } + } + + if (!dragHandler.dragging) + return; + + const axisOffset = root.isVertical ? (mouse.y - dragHandler.dragStartPos.y) : (mouse.x - dragHandler.dragStartPos.x); + dragHandler.dragAxisOffset = axisOffset; + + const itemSize = (root.isVertical ? delegateItem.height : delegateItem.width) + Theme.spacingXS; + const slotOffset = Math.round(axisOffset / itemSize); + const newTargetIndex = Math.max(0, Math.min(root.pinnedAppCount - 1, index + slotOffset)); + + if (newTargetIndex !== root.dropTargetIndex) { + root.dropTargetIndex = newTargetIndex; + } + } + + onEntered: { + root.hoveredItem = delegateItem; + if (isSeparator) + return; + + tooltipLoader.active = true; + if (tooltipLoader.item) { + if (root.isVertical) { + const globalPos = delegateItem.mapToGlobal(delegateItem.width / 2, delegateItem.height / 2); + const screenX = root.parentScreen ? root.parentScreen.x : 0; + const screenY = root.parentScreen ? root.parentScreen.y : 0; + const relativeY = globalPos.y - screenY; + const tooltipX = root.axis?.edge === "left" ? (Theme.barHeight + (barConfig?.spacing ?? 4) + Theme.spacingXS) : (root.parentScreen.width - Theme.barHeight - (barConfig?.spacing ?? 4) - Theme.spacingXS); + const isLeft = root.axis?.edge === "left"; + const adjustedY = relativeY + root.minTooltipY; + const finalX = screenX + tooltipX; + tooltipLoader.item.show(appItem.tooltipText, finalX, adjustedY, root.parentScreen, isLeft, !isLeft); + } else { + const globalPos = delegateItem.mapToGlobal(delegateItem.width / 2, delegateItem.height); + const screenHeight = root.parentScreen ? root.parentScreen.height : Screen.height; + const isBottom = root.axis?.edge === "bottom"; + const tooltipY = isBottom ? (screenHeight - Theme.barHeight - (barConfig?.spacing ?? 4) - Theme.spacingXS - 35) : (Theme.barHeight + (barConfig?.spacing ?? 4) + Theme.spacingXS); + tooltipLoader.item.show(appItem.tooltipText, globalPos.x, tooltipY, root.parentScreen, false, false); + } + } + } + onExited: { + if (root.hoveredItem === delegateItem) { + root.hoveredItem = null; + if (tooltipLoader.item) + tooltipLoader.item.hide(); + tooltipLoader.active = false; + } + } + + onClicked: mouse => { + if (mouse.button === Qt.RightButton) { + if (tooltipLoader.item) { + tooltipLoader.item.hide(); + } + tooltipLoader.active = false; + contextMenuLoader.active = true; + + if (contextMenuLoader.item) { + const globalPos = delegateItem.mapToGlobal(delegateItem.width / 2, delegateItem.height / 2); + + const isBarVertical = root.axis?.isVertical ?? false; + const barEdge = root.axis?.edge ?? "top"; + + let x = globalPos.x; + let y = globalPos.y; + + if (barEdge === "bottom") { + y = (root.parentScreen ? root.parentScreen.height : Screen.height) - root.effectiveBarThickness; + } else if (barEdge === "top") { + y = root.effectiveBarThickness; + } else if (barEdge === "left") { + x = root.effectiveBarThickness; + } else if (barEdge === "right") { + x = (root.parentScreen ? root.parentScreen.width : Screen.width) - root.effectiveBarThickness; + } + + const shouldHidePin = modelData.appId === "org.quickshell"; + const moddedId = Paths.moddedAppId(modelData.appId); + const desktopEntry = moddedId ? DesktopEntries.heuristicLookup(moddedId) : null; + + contextMenuLoader.item.showAt(x, y, isBarVertical, barEdge, modelData, shouldHidePin, desktopEntry, root.parentScreen); + } + } + } + } + } + } + } + + Loader { + id: contextMenuLoader + active: false + source: "AppsDockContextMenu.qml" + } +} diff --git a/quickshell/Modules/DankBar/Widgets/AppsDockContextMenu.qml b/quickshell/Modules/DankBar/Widgets/AppsDockContextMenu.qml new file mode 100644 index 00000000..48ed1422 --- /dev/null +++ b/quickshell/Modules/DankBar/Widgets/AppsDockContextMenu.qml @@ -0,0 +1,431 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland +import Quickshell.Widgets +import qs.Common +import qs.Services +import qs.Widgets + +PanelWindow { + id: root + + WlrLayershell.namespace: "dms:dock-context-menu" + + property var appData: null + property var anchorItem: null + property int margin: 10 + property bool hidePin: false + property var desktopEntry: null + property bool isDmsWindow: appData?.appId === "org.quickshell" + + property bool isVertical: false + property string edge: "top" + property point anchorPos: Qt.point(0, 0) + + function showAt(x, y, vertical, barEdge, data, hidePinOption, entry, targetScreen) { + if (targetScreen) { + root.screen = targetScreen; + } + + anchorPos = Qt.point(x, y); + isVertical = vertical ?? false; + edge = barEdge ?? "top"; + + appData = data; + hidePin = hidePinOption || false; + desktopEntry = entry || null; + + visible = true; + } + + function close() { + visible = false; + } + + screen: null + visible: false + WlrLayershell.layer: WlrLayershell.Overlay + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + color: "transparent" + anchors { + top: true + left: true + right: true + bottom: true + } + + Rectangle { + id: menuContainer + + x: { + if (root.isVertical) { + if (root.edge === "left") { + return Math.min(root.width - width - 10, root.anchorPos.x); + } else { + return Math.max(10, root.anchorPos.x - width); + } + } else { + const left = 10; + const right = root.width - width - 10; + const want = root.anchorPos.x - width / 2; + return Math.max(left, Math.min(right, want)); + } + } + y: { + if (root.isVertical) { + const top = 10; + const bottom = root.height - height - 10; + const want = root.anchorPos.y - height / 2; + return Math.max(top, Math.min(bottom, want)); + } else { + if (root.edge === "top") { + return Math.min(root.height - height - 10, root.anchorPos.y); + } else { + return Math.max(10, root.anchorPos.y - height); + } + } + } + + width: Math.min(400, Math.max(180, menuColumn.implicitWidth + Theme.spacingS * 2)) + height: Math.max(60, menuColumn.implicitHeight + Theme.spacingS * 2) + color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) + radius: Theme.cornerRadius + border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08) + border.width: 1 + + opacity: root.visible ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.emphasizedEasing + } + } + + Rectangle { + anchors.fill: parent + anchors.topMargin: 4 + anchors.leftMargin: 2 + anchors.rightMargin: -2 + anchors.bottomMargin: -4 + radius: parent.radius + color: Qt.rgba(0, 0, 0, 0.15) + z: -1 + } + + Column { + id: menuColumn + width: parent.width - Theme.spacingS * 2 + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: Theme.spacingS + spacing: 1 + + // Window list for grouped apps + Repeater { + model: { + if (!root.appData || root.appData.type !== "grouped") + return []; + + const toplevels = []; + const allToplevels = ToplevelManager.toplevels.values; + for (let i = 0; i < allToplevels.length; i++) { + const toplevel = allToplevels[i]; + if (toplevel.appId === root.appData.appId) { + toplevels.push(toplevel); + } + } + return toplevels; + } + + Rectangle { + width: parent.width + height: 28 + radius: Theme.cornerRadius + color: windowArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent" + + StyledText { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingS + anchors.right: closeButton.left + anchors.rightMargin: Theme.spacingXS + anchors.verticalCenter: parent.verticalCenter + text: (modelData && modelData.title) ? modelData.title : I18n.tr("(Unnamed)") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + font.weight: Font.Normal + elide: Text.ElideRight + wrapMode: Text.NoWrap + } + + Rectangle { + id: closeButton + anchors.right: parent.right + anchors.rightMargin: Theme.spacingXS + anchors.verticalCenter: parent.verticalCenter + width: 20 + height: 20 + radius: 10 + color: closeMouseArea.containsMouse ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.2) : "transparent" + + DankIcon { + anchors.centerIn: parent + name: "close" + size: 12 + color: closeMouseArea.containsMouse ? Theme.error : Theme.surfaceText + } + + MouseArea { + id: closeMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (modelData && modelData.close) { + modelData.close(); + } + root.close(); + } + } + } + + MouseArea { + id: windowArea + anchors.fill: parent + anchors.rightMargin: 24 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (modelData && modelData.activate) { + modelData.activate(); + } + root.close(); + } + } + } + } + + Rectangle { + visible: { + if (!root.appData) + return false; + if (root.appData.type !== "grouped") + return false; + return root.appData.windowCount > 0; + } + width: parent.width + height: 1 + color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2) + } + + Repeater { + model: root.desktopEntry && root.desktopEntry.actions ? root.desktopEntry.actions : [] + + Rectangle { + width: parent.width + height: 28 + radius: Theme.cornerRadius + color: actionArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent" + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingS + anchors.right: parent.right + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingXS + + Item { + anchors.verticalCenter: parent.verticalCenter + width: 16 + height: 16 + visible: modelData.icon && modelData.icon !== "" + + IconImage { + anchors.fill: parent + source: modelData.icon ? Quickshell.iconPath(modelData.icon, true) : "" + smooth: true + asynchronous: true + visible: status === Image.Ready + } + } + + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: modelData.name || "" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + font.weight: Font.Normal + elide: Text.ElideRight + wrapMode: Text.NoWrap + } + } + + MouseArea { + id: actionArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (modelData) { + SessionService.launchDesktopAction(root.desktopEntry, modelData); + } + root.close(); + } + } + } + } + + Rectangle { + visible: { + if (!root.desktopEntry?.actions || root.desktopEntry.actions.length === 0) { + return false; + } + return !root.hidePin || (!root.isDmsWindow && root.desktopEntry && SessionService.nvidiaCommand); + } + width: parent.width + height: 1 + color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2) + } + + Rectangle { + visible: !root.hidePin + width: parent.width + height: 28 + radius: Theme.cornerRadius + color: pinArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent" + + StyledText { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingS + anchors.right: parent.right + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + text: root.appData && root.appData.isPinned ? I18n.tr("Unpin from Dock") : I18n.tr("Pin to Dock") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + font.weight: Font.Normal + elide: Text.ElideRight + wrapMode: Text.NoWrap + } + + MouseArea { + id: pinArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (!root.appData) { + return; + } + if (root.appData.isPinned) { + SessionData.removeBarPinnedApp(root.appData.appId); + } else { + SessionData.addBarPinnedApp(root.appData.appId); + } + root.close(); + } + } + } + + Rectangle { + visible: { + const hasNvidia = !root.isDmsWindow && root.desktopEntry && SessionService.nvidiaCommand; + const hasWindow = root.appData && (root.appData.type === "window" || (root.appData.type === "grouped" && root.appData.windowCount > 0)); + const hasPinOption = !root.hidePin; + const hasContentAbove = hasPinOption || hasNvidia; + return hasContentAbove && hasWindow; + } + width: parent.width + height: 1 + color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2) + } + + Rectangle { + visible: !root.isDmsWindow && root.desktopEntry && SessionService.nvidiaCommand + width: parent.width + height: 28 + radius: Theme.cornerRadius + color: nvidiaArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent" + + StyledText { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingS + anchors.right: parent.right + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + text: I18n.tr("Launch on dGPU") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + font.weight: Font.Normal + elide: Text.ElideRight + wrapMode: Text.NoWrap + } + + MouseArea { + id: nvidiaArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (root.desktopEntry) { + SessionService.launchDesktopEntry(root.desktopEntry, true); + } + root.close(); + } + } + } + + Rectangle { + visible: root.appData && (root.appData.type === "window" || (root.appData.type === "grouped" && root.appData.windowCount > 0)) + width: parent.width + height: 28 + radius: Theme.cornerRadius + color: closeArea.containsMouse ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12) : "transparent" + + StyledText { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingS + anchors.right: parent.right + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + text: { + if (root.appData && root.appData.type === "grouped") { + return I18n.tr("Close All Windows"); + } + return I18n.tr("Close Window"); + } + font.pixelSize: Theme.fontSizeSmall + color: closeArea.containsMouse ? Theme.error : Theme.surfaceText + font.weight: Font.Normal + elide: Text.ElideRight + wrapMode: Text.NoWrap + } + + MouseArea { + id: closeArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (root.appData?.type === "window") { + root.appData?.toplevel?.close(); + } else if (root.appData?.type === "grouped") { + root.appData?.allWindows?.forEach(window => window.toplevel?.close()); + } + root.close(); + } + } + } + } + } + + MouseArea { + anchors.fill: parent + z: -1 + onClicked: root.close() + } +} diff --git a/quickshell/Modules/Settings/WidgetsTab.qml b/quickshell/Modules/Settings/WidgetsTab.qml index 857a2a29..e7d41f07 100644 --- a/quickshell/Modules/Settings/WidgetsTab.qml +++ b/quickshell/Modules/Settings/WidgetsTab.qml @@ -68,6 +68,13 @@ Item { "icon": "apps", "enabled": true }, + { + "id": "appsDock", + "text": I18n.tr("Apps Dock"), + "description": I18n.tr("Pinned and running apps with drag-and-drop"), + "icon": "dock_to_bottom", + "enabled": true + }, { "id": "clock", "text": I18n.tr("Clock"), diff --git a/quickshell/Services/AppSearchService.qml b/quickshell/Services/AppSearchService.qml index b389e2bc..406e4428 100644 --- a/quickshell/Services/AppSearchService.qml +++ b/quickshell/Services/AppSearchService.qml @@ -219,7 +219,8 @@ Singleton { action: plugin.action, categories: plugin.categories, isCore: true, - builtInPluginId: pluginId + builtInPluginId: pluginId, + cornerIcon: plugin.cornerIcon }); } return apps;