From 6a7c68c6e33fc9937719697d8e1089f7b607145d Mon Sep 17 00:00:00 2001 From: Dadangdut33 <57717531+Dadangdut33@users.noreply.github.com> Date: Thu, 19 Feb 2026 23:30:24 +0700 Subject: [PATCH] feat: implement group the same apps in dock with configurable settings --- Modules/Dock/Dock.qml | 106 ++++- Modules/Dock/DockContent.qml | 240 ++++++++--- Modules/Dock/DockMenu.qml | 538 +++++++++++++++++++----- Modules/Panels/Dock/StaticDockPanel.qml | 106 ++++- 4 files changed, 827 insertions(+), 163 deletions(-) diff --git a/Modules/Dock/Dock.qml b/Modules/Dock/Dock.qml index e75a27508..f0595290c 100644 --- a/Modules/Dock/Dock.qml +++ b/Modules/Dock/Dock.qml @@ -50,6 +50,9 @@ Loader { function onOnlySameOutputChanged() { updateDockApps(); } + function onGroupAppsChanged() { + updateDockApps(); + } } // Initial update when component is ready @@ -125,6 +128,7 @@ Loader { // Combined model of running apps and pinned apps property var dockApps: [] + property var groupCycleIndices: ({}) // Track the session order of apps (transient reordering) property var sessionAppOrder: [] @@ -160,6 +164,10 @@ Loader { if (!appData) return null; + if (Settings.data.dock.groupApps) { + return appData.appId; + } + // 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; @@ -291,6 +299,91 @@ Loader { return appId; } + function getToplevelsForEntry(appData) { + if (!appData) + return []; + + if (appData.toplevels && appData.toplevels.length > 0) { + return appData.toplevels.filter(toplevel => toplevel && (!Settings.data.dock.onlySameOutput || !toplevel.screens || toplevel.screens.includes(modelData))); + } + + if (!appData.toplevel) + return []; + + if (Settings.data.dock.onlySameOutput && appData.toplevel.screens && !appData.toplevel.screens.includes(modelData)) + return []; + + return [appData.toplevel]; + } + + function getPrimaryToplevelForEntry(appData) { + const toplevels = getToplevelsForEntry(appData); + if (toplevels.length === 0) + return null; + + if (ToplevelManager && ToplevelManager.activeToplevel && toplevels.includes(ToplevelManager.activeToplevel)) + return ToplevelManager.activeToplevel; + + return toplevels[0]; + } + + // Build grouped render model without mutating the raw toplevel list. + function buildGroupedDockApps(apps) { + if (!Settings.data.dock.groupApps) { + return apps.map(app => { + const entry = Object.assign({}, app); + entry.toplevels = getToplevelsForEntry(app); + return entry; + }); + } + + const grouped = []; + const groupedById = new Map(); + + apps.forEach(app => { + const appId = app.appId; + const toplevels = getToplevelsForEntry(app); + const existing = groupedById.get(appId); + + if (existing) { + toplevels.forEach(toplevel => { + if (!existing.toplevels.includes(toplevel)) { + existing.toplevels.push(toplevel); + } + }); + if (app.type === "pinned" || app.type === "pinned-running") { + existing.isPinned = true; + } + } else { + const entry = { + "type": app.type, + "appId": appId, + "title": app.title, + "toplevels": toplevels.slice(), + "isPinned": app.type === "pinned" || app.type === "pinned-running" + }; + grouped.push(entry); + groupedById.set(appId, entry); + } + }); + + grouped.forEach(entry => { + entry.toplevel = getPrimaryToplevelForEntry(entry); + if (entry.toplevels.length > 0 && entry.isPinned) { + entry.type = "pinned-running"; + } else if (entry.toplevels.length > 0) { + entry.type = "running"; + } else { + entry.type = "pinned"; + } + if (entry.toplevel && entry.toplevel.title && entry.toplevel.title.trim() !== "") { + entry.title = entry.toplevel.title; + } + }); + + return grouped; + } + // Function to update the combined dock apps model function updateDockApps() { const runningApps = ToplevelManager ? (ToplevelManager.toplevels.values || []) : []; @@ -315,6 +408,7 @@ Loader { combined.push({ "type": appType, "toplevel": toplevel, + "toplevels": toplevel ? [toplevel] : [], "appId": canonicalId, "title": title }); @@ -327,6 +421,7 @@ Loader { combined.push({ "type": appType, "toplevel": toplevel, + "toplevels": [], "appId": canonicalId, "title": title }); @@ -375,7 +470,16 @@ Loader { pushPinned(); } - dockApps = sortDockApps(combined); + const sortedApps = sortDockApps(combined); + dockApps = buildGroupedDockApps(sortedApps); + const cycleState = root.groupCycleIndices || {}; + const nextCycleState = {}; + dockApps.forEach(app => { + if (app && app.appId && cycleState[app.appId] !== undefined) { + nextCycleState[app.appId] = cycleState[app.appId]; + } + }); + root.groupCycleIndices = nextCycleState; // Sync session order if needed // Instead of resetting everything when length changes, we reconcile the keys diff --git a/Modules/Dock/DockContent.qml b/Modules/Dock/DockContent.qml index 3f781e121..75fa2b68e 100644 --- a/Modules/Dock/DockContent.qml +++ b/Modules/Dock/DockContent.qml @@ -120,6 +120,66 @@ Item { return ThemeIcons.iconForAppId(appData.appId?.toLowerCase()); } + function getValidToplevels(appData) { + if (!appData || !ToplevelManager || !ToplevelManager.toplevels) + return []; + const source = appData.toplevels && appData.toplevels.length > 0 ? appData.toplevels : (appData.toplevel ? [appData.toplevel] : []); + const allToplevels = ToplevelManager.toplevels.values || []; + return source.filter(toplevel => toplevel && allToplevels.includes(toplevel)); + } + + function getPrimaryToplevel(appData) { + const toplevels = getValidToplevels(appData); + if (toplevels.length === 0) + return null; + if (ToplevelManager && ToplevelManager.activeToplevel && toplevels.includes(ToplevelManager.activeToplevel)) + return ToplevelManager.activeToplevel; + return toplevels[0]; + } + + function launchAppById(appId) { + if (!appId) + return; + + const app = ThemeIcons.findAppEntry(appId); + if (!app) { + Logger.w("Dock", `Could not find desktop entry for pinned app: ${appId}`); + return; + } + + if (Settings.data.appLauncher.customLaunchPrefixEnabled && Settings.data.appLauncher.customLaunchPrefix) { + const prefix = Settings.data.appLauncher.customLaunchPrefix.split(" "); + + if (app.runInTerminal) { + const terminal = Settings.data.appLauncher.terminalCommand.split(" "); + const command = prefix.concat(terminal.concat(app.command)); + Quickshell.execDetached(command); + } else { + const command = prefix.concat(app.command); + Quickshell.execDetached(command); + } + } else if (Settings.data.appLauncher.useApp2Unit && ProgramCheckerService.app2unitAvailable && app.id) { + Logger.d("Dock", `Using app2unit for: ${app.id}`); + if (app.runInTerminal) + Quickshell.execDetached(["app2unit", "--", app.id + ".desktop"]); + else + Quickshell.execDetached(["app2unit", "--"].concat(app.command)); + } else { + if (app.runInTerminal) { + Logger.d("Dock", "Executing terminal app manually: " + app.name); + const terminal = Settings.data.appLauncher.terminalCommand.split(" "); + const command = terminal.concat(app.command); + CompositorService.spawn(command); + } else if (app.command && app.command.length > 0) { + CompositorService.spawn(app.command); + } else if (app.execute) { + app.execute(); + } else { + Logger.w("Dock", `Could not launch: ${app.name}. No valid launch method.`); + } + } + } + // Use GridLayout for flexible horizontal/vertical arrangement GridLayout { id: dockLayout @@ -329,15 +389,23 @@ Item { Layout.preferredHeight: dockRoot.isVertical ? dockRoot.iconSize : dockRoot.iconSize + indicatorMargin * 2 Layout.alignment: Qt.AlignCenter - property bool isActive: modelData.toplevel && ToplevelManager.activeToplevel && ToplevelManager.activeToplevel === modelData.toplevel + property var toplevels: dock.getValidToplevels(modelData) + property bool isActive: ToplevelManager && ToplevelManager.activeToplevel && toplevels.includes(ToplevelManager.activeToplevel) property bool hovered: appMouseArea.containsMouse property string appId: modelData ? modelData.appId : "" + property int groupedCount: toplevels.length + property int focusedWindowIndex: { + if (!ToplevelManager || !ToplevelManager.activeToplevel) + return -1; + return toplevels.indexOf(ToplevelManager.activeToplevel); + } + property string groupedIndicatorText: focusedWindowIndex >= 0 ? (focusedWindowIndex + 1) + "/" + groupedCount : groupedCount.toString() property string appTitle: { if (!modelData) return ""; - // For running apps, use the toplevel title directly (reactive) - if (modelData.toplevel) { - const toplevelTitle = modelData.toplevel.title || ""; + const primaryToplevel = dock.getPrimaryToplevel(modelData); + if (primaryToplevel) { + const toplevelTitle = primaryToplevel.title || ""; // If title is "Loading..." or empty, use desktop entry name if (!toplevelTitle || toplevelTitle === "Loading..." || toplevelTitle.trim() === "") { return dockRoot.getAppNameFromDesktopEntry(modelData.appId) || modelData.appId; @@ -347,7 +415,10 @@ Item { // For pinned apps that aren't running, use the stored title return modelData.title || modelData.appId || ""; } - property bool isRunning: modelData && (modelData.type === "running" || modelData.type === "pinned-running") + property bool isRunning: toplevels.length > 0 + readonly property bool baseIndicatorVisible: Settings.data.dock.inactiveIndicators ? isRunning : isActive + // Grouped indicators should be visible whenever grouped windows are running, even if none is focused. + readonly property bool showGroupedIndicator: Settings.data.dock.groupApps && groupedCount > 1 && isRunning // Store index for drag-and-drop property int modelIndex: index @@ -619,66 +690,50 @@ Item { dockRoot.closeAllContextMenus(); // Hide tooltip when showing context menu TooltipService.hideImmediately(); - contextMenu.show(appButton, modelData.toplevel || modelData, targetScreen); + contextMenu.show(appButton, modelData, targetScreen); return; } // Close any existing context menu for non-right-click actions dockRoot.closeAllContextMenus(); - // Check if toplevel is still valid (not a stale reference) - const isValidToplevel = modelData?.toplevel && ToplevelManager && ToplevelManager.toplevels.values.includes(modelData.toplevel); + const runningToplevels = dock.getValidToplevels(modelData); + const primaryToplevel = dock.getPrimaryToplevel(modelData); - if (mouse.button === Qt.MiddleButton && isValidToplevel && modelData.toplevel.close) { - modelData.toplevel.close(); - Qt.callLater(dockRoot.updateDockApps); // Force immediate dock update + if (mouse.button === Qt.MiddleButton) { + if (primaryToplevel && primaryToplevel.close) { + primaryToplevel.close(); + Qt.callLater(dockRoot.updateDockApps); + } } else if (mouse.button === Qt.LeftButton) { - if (isValidToplevel && modelData.toplevel.activate) { - // Running app - activate it - modelData.toplevel.activate(); - } else if (modelData?.appId) { - // Pinned app not running - launch it - // Use ThemeIcons to robustly find the desktop entry - const app = ThemeIcons.findAppEntry(modelData.appId); + if (runningToplevels.length === 0) { + dock.launchAppById(modelData?.appId); + return; + } - if (!app) { - Logger.w("Dock", `Could not find desktop entry for pinned app: ${modelData.appId}`); - return; + if (!Settings.data.dock.groupApps || runningToplevels.length <= 1) { + if (primaryToplevel && primaryToplevel.activate) { + primaryToplevel.activate(); } + return; + } - if (Settings.data.appLauncher.customLaunchPrefixEnabled && Settings.data.appLauncher.customLaunchPrefix) { - // Use custom launch prefix - const prefix = Settings.data.appLauncher.customLaunchPrefix.split(" "); - - if (app.runInTerminal) { - const terminal = Settings.data.appLauncher.terminalCommand.split(" "); - const command = prefix.concat(terminal.concat(app.command)); - Quickshell.execDetached(command); - } else { - const command = prefix.concat(app.command); - Quickshell.execDetached(command); - } - } else if (Settings.data.appLauncher.useApp2Unit && ProgramCheckerService.app2unitAvailable && app.id) { - Logger.d("Dock", `Using app2unit for: ${app.id}`); - if (app.runInTerminal) - Quickshell.execDetached(["app2unit", "--", app.id + ".desktop"]); - else - Quickshell.execDetached(["app2unit", "--"].concat(app.command)); - } else { - // Fallback logic when app2unit is not used - if (app.runInTerminal) { - Logger.d("Dock", "Executing terminal app manually: " + app.name); - const terminal = Settings.data.appLauncher.terminalCommand.split(" "); - const command = terminal.concat(app.command); - CompositorService.spawn(command); - } else if (app.command && app.command.length > 0) { - CompositorService.spawn(app.command); - } else if (app.execute) { - app.execute(); - } else { - Logger.w("Dock", `Could not launch: ${app.name}. No valid launch method.`); - } + const clickAction = Settings.data.dock.groupClickAction || "cycle"; + if (clickAction === "list") { + const targetScreen = dockRoot.modelData || dockRoot.screen || null; + TooltipService.hideImmediately(); + // Left-click list should always open the grouped window list view. + contextMenu.show(appButton, modelData, targetScreen, "list"); + } else { + const appKey = modelData?.appId || ""; + const state = dockRoot.groupCycleIndices || {}; + const nextIndex = (state[appKey] || 0) % runningToplevels.length; + const nextToplevel = runningToplevels[nextIndex]; + if (nextToplevel && nextToplevel.activate) { + nextToplevel.activate(); } + state[appKey] = (nextIndex + 1) % runningToplevels.length; + dockRoot.groupCycleIndices = Object.assign({}, state); } } } @@ -686,7 +741,7 @@ Item { // Active indicator - positioned at the edge of the delegate area Rectangle { - visible: Settings.data.dock.inactiveIndicators ? isRunning : isActive + visible: baseIndicatorVisible && !showGroupedIndicator width: dockRoot.isVertical ? indicatorMargin * 0.6 : dockRoot.iconSize * 0.2 height: dockRoot.isVertical ? dockRoot.iconSize * 0.2 : indicatorMargin * 0.6 color: Color.mPrimary @@ -707,6 +762,83 @@ Item { anchors.leftMargin: dockRoot.isVertical && dockRoot.dockPosition === "left" ? 2 : 0 anchors.rightMargin: dockRoot.isVertical && dockRoot.dockPosition === "right" ? 2 : 0 } + + Loader { + id: groupedIndicatorLoader + active: showGroupedIndicator + anchors.bottom: !dockRoot.isVertical && dockRoot.dockPosition === "bottom" ? parent.bottom : undefined + anchors.top: !dockRoot.isVertical && dockRoot.dockPosition === "top" ? parent.top : undefined + anchors.left: dockRoot.isVertical && dockRoot.dockPosition === "left" ? parent.left : undefined + anchors.right: dockRoot.isVertical && dockRoot.dockPosition === "right" ? parent.right : undefined + anchors.horizontalCenter: dockRoot.isVertical ? undefined : parent.horizontalCenter + anchors.verticalCenter: dockRoot.isVertical ? parent.verticalCenter : undefined + anchors.bottomMargin: !dockRoot.isVertical && dockRoot.dockPosition === "bottom" ? 1 : 0 + anchors.topMargin: !dockRoot.isVertical && dockRoot.dockPosition === "top" ? 1 : 0 + anchors.leftMargin: dockRoot.isVertical && dockRoot.dockPosition === "left" ? 1 : 0 + anchors.rightMargin: dockRoot.isVertical && dockRoot.dockPosition === "right" ? 1 : 0 + + sourceComponent: Settings.data.dock.groupIndicatorStyle === "dots" ? groupDotsIndicatorComponent : groupNumberIndicatorComponent + } + + Component { + id: groupNumberIndicatorComponent + Rectangle { + radius: Style.radiusS + color: Qt.alpha(Color.mSurface, 0.9) + border.color: Qt.alpha(Color.mOutline, 0.7) + border.width: Style.borderS + width: Math.max(14, numberLabel.implicitWidth + Style.marginXS) + height: Math.max(10, numberLabel.implicitHeight + 2) + + NText { + id: numberLabel + anchors.centerIn: parent + text: appButton.groupedIndicatorText + pointSize: Style.fontSizeXS + color: appButton.focusedWindowIndex >= 0 ? Color.mPrimary : Color.mOnSurfaceVariant + } + } + } + + Component { + id: groupDotsIndicatorComponent + Item { + readonly property int maxVisibleDots: 5 + readonly property int totalCount: Math.max(0, appButton.groupedCount) + readonly property int focusedIndex: appButton.focusedWindowIndex >= 0 ? appButton.focusedWindowIndex : 0 + readonly property int visibleCount: Math.min(totalCount, maxVisibleDots) + readonly property int dotSize: Math.max(2, Math.round(dockRoot.iconSize * 0.1)) + readonly property int dotSpacing: Math.max(1, Math.round(dotSize * 0.7)) + readonly property int pitch: dotSize + dotSpacing + readonly property int windowStart: { + if (totalCount <= maxVisibleDots) + return 0; + const centeredStart = focusedIndex - Math.floor(maxVisibleDots / 2); + const maxStart = totalCount - maxVisibleDots; + return Math.max(0, Math.min(maxStart, centeredStart)); + } + readonly property bool hasHiddenLeft: windowStart > 0 + readonly property bool hasHiddenRight: (windowStart + visibleCount) < totalCount + width: dockRoot.isVertical ? dotSize : (visibleCount * dotSize + Math.max(0, visibleCount - 1) * dotSpacing) + height: dockRoot.isVertical ? (visibleCount * dotSize + Math.max(0, visibleCount - 1) * dotSpacing) : dotSize + + Repeater { + model: parent.visibleCount + delegate: Rectangle { + readonly property int absoluteIndex: parent.windowStart + index + readonly property bool isFocusedDot: appButton.focusedWindowIndex >= 0 && absoluteIndex === appButton.focusedWindowIndex + readonly property bool isOverflowHint: (index === 0 && parent.hasHiddenLeft) || (index === parent.visibleCount - 1 && parent.hasHiddenRight) + width: isOverflowHint && !isFocusedDot ? Math.max(2, Math.round(parent.dotSize * 0.72)) : parent.dotSize + height: width + radius: width / 2 + x: dockRoot.isVertical ? Math.round((parent.dotSize - width) / 2) : (index * parent.pitch + Math.round((parent.dotSize - width) / 2)) + y: dockRoot.isVertical ? (index * parent.pitch + Math.round((parent.dotSize - width) / 2)) : Math.round((parent.dotSize - width) / 2) + color: isFocusedDot ? Color.mPrimary : Qt.alpha(Color.mOutline, 0.9) + opacity: isOverflowHint && !isFocusedDot ? 0.55 : 1.0 + } + } + } + } } } diff --git a/Modules/Dock/DockMenu.qml b/Modules/Dock/DockMenu.qml index 27ce5995b..1e678da6e 100644 --- a/Modules/Dock/DockMenu.qml +++ b/Modules/Dock/DockMenu.qml @@ -13,6 +13,7 @@ PopupWindow { id: root property var toplevel: null + property var appData: null property Item anchorItem: null property ShellScreen targetScreen: null @@ -21,7 +22,7 @@ PopupWindow { property int launcherWidgetIndex: -1 property var launcherWidgetSettings: ({}) - property bool hovered: menuMouseArea.containsMouse + property bool hovered: menuHoverHandler.hovered property var onAppClosed: null // Callback function for when an app is closed property bool canAutoClose: false @@ -33,9 +34,37 @@ PopupWindow { signal requestClose property real menuContentWidth: 160 + property real menuMinWidth: 120 + property real menuMaxWidth: 360 + property real menuMaxHeight: Math.max(180, Math.min(420, Math.round((targetScreen ? targetScreen.height : 600) * 0.3))) + property int separatorCompactHeight: 8 + property string forcedGroupMenuMode: "" + readonly property int separatorIndex: { + for (let i = 0; i < root.items.length; i++) { + if (root.items[i] && root.items[i].separator === true) + return i; + } + return -1; + } + readonly property bool splitExtendedLayout: separatorIndex >= 0 + readonly property var scrollItems: splitExtendedLayout ? root.items.slice(0, separatorIndex) : root.items + readonly property var fixedItems: splitExtendedLayout ? root.items.slice(separatorIndex + 1) : [] + readonly property real menuInnerHeight: Math.max(0, implicitHeight - Style.marginXL) + readonly property real fixedActionsHeight: listHeight(fixedItems) + readonly property real separatorBlockHeight: splitExtendedLayout ? separatorCompactHeight : 0 + readonly property real scrollAreaHeight: splitExtendedLayout + ? Math.max(0, menuInnerHeight - fixedActionsHeight - separatorBlockHeight) + : menuInnerHeight + readonly property bool listOverflowing: menuFlick && menuFlick.contentHeight > menuFlick.height + readonly property real menuBodyHeight: { + if (splitExtendedLayout) { + return listHeight(scrollItems) + separatorBlockHeight + fixedActionsHeight; + } + return listHeight(root.items); + } implicitWidth: menuContentWidth + (Style.marginXL) - implicitHeight: (root.items.length * 32) + (Style.marginXL) + implicitHeight: Math.min(menuBodyHeight + (Style.marginXL), menuMaxHeight) color: "transparent" visible: false @@ -74,8 +103,50 @@ PopupWindow { } } } - // Apply a reasonable minimum width (like 120px) - menuContentWidth = Math.max(120, Math.ceil(maxWidth)); + // Keep menu readable without allowing extremely wide labels. + menuContentWidth = Math.max(menuMinWidth, Math.min(menuMaxWidth, Math.ceil(maxWidth))); + } + + function getCurrentAppId() { + return appData?.appId || toplevel?.appId || ""; + } + + function getValidToplevels() { + if (!ToplevelManager || !ToplevelManager.toplevels) + return []; + const source = appData?.toplevels && appData.toplevels.length > 0 ? appData.toplevels : (toplevel ? [toplevel] : []); + const allToplevels = ToplevelManager.toplevels.values || []; + return source.filter(window => window && allToplevels.includes(window)); + } + + function getPrimaryToplevel() { + const windows = getValidToplevels(); + if (windows.length === 0) + return null; + if (ToplevelManager && ToplevelManager.activeToplevel && windows.includes(ToplevelManager.activeToplevel)) + return ToplevelManager.activeToplevel; + return windows[0]; + } + + function isItemActionable(index) { + if (index < 0 || index >= root.items.length) + return false; + const item = root.items[index]; + return item && typeof item.action === "function"; + } + + function rowHeightForItem(item) { + return item && item.separator === true ? 16 : 32; + } + + function listHeight(items) { + let total = 0; + if (!items) + return total; + for (let i = 0; i < items.length; i++) { + total += rowHeightForItem(items[i]); + } + return total; } function initItems() { @@ -107,47 +178,90 @@ PopupWindow { return; } - // Is this a running app? - const isRunning = root.toplevel && ToplevelManager && ToplevelManager.toplevels.values.includes(root.toplevel); - - // Is this a pinned app? - const isPinned = root.toplevel && root.isAppPinned(root.toplevel.appId); + const windows = getValidToplevels(); + const primaryToplevel = getPrimaryToplevel(); + const appId = getCurrentAppId(); + const isRunning = windows.length > 0; + const isPinned = isAppPinned(appId); + const grouped = Settings.data.dock.groupApps && windows.length > 1; + const rawGroupMenuMode = forcedGroupMenuMode || Settings.data.dock.groupContextMenuMode || "extended"; + const menuModeForGroup = grouped ? ((rawGroupMenuMode === "list" || rawGroupMenuMode === "extended") ? rawGroupMenuMode : "extended") : "single"; var next = []; - if (isRunning) { - // Focus item + + if (!grouped || menuModeForGroup === "single") { + if (isRunning) { + next.push({ + "icon": "eye", + "text": I18n.tr("common.focus"), + "action": function () { + handleFocus(primaryToplevel); + } + }); + } + next.push({ - "icon": "eye", - "text": I18n.tr("common.focus"), + "icon": !isPinned ? "pin" : "unpin", + "text": !isPinned ? I18n.tr("common.pin") : I18n.tr("common.unpin"), "action": function () { - handleFocus(); + handlePin(appId); } }); + + if (isRunning) { + next.push({ + "icon": "close", + "text": I18n.tr("common.close"), + "action": function () { + handleClose(primaryToplevel); + } + }); + } + } else { + windows.forEach((window, index) => { + const windowTitle = (window.title && window.title.trim() !== "") ? window.title : (appId || ("Window " + (index + 1))); + next.push({ + "icon": window === ToplevelManager?.activeToplevel ? "circle-filled" : "square-rounded", + "text": windowTitle, + "action": function () { + handleFocus(window); + } + }); + }); + + if (menuModeForGroup === "extended") { + next.push({ + "separator": true + }); + next.push({ + "icon": "eye", + "text": I18n.tr("common.focus"), + "action": function () { + handleFocus(primaryToplevel); + } + }); + next.push({ + "icon": !isPinned ? "pin" : "unpin", + "text": !isPinned ? I18n.tr("common.pin") : I18n.tr("common.unpin"), + "action": function () { + handlePin(appId); + } + }); + next.push({ + "icon": "close", + "text": I18n.tr("common.close") + " All", + "action": function () { + handleCloseAll(windows); + } + }); + } } - // Pin/Unpin item - next.push({ - "icon": !isPinned ? "pin" : "unpin", - "text": !isPinned ? I18n.tr("common.pin") : I18n.tr("common.unpin"), - "action": function () { - handlePin(); - } - }); + // Keep grouped list mode as a clean window switcher. + const canAddDesktopActions = !grouped || menuModeForGroup === "extended"; - if (isRunning) { - // Close item - next.push({ - "icon": "close", - "text": I18n.tr("common.close"), - "action": function () { - handleClose(); - } - }); - } - - // Create a menu entry for each app-specific action definied in its .desktop file - if (typeof DesktopEntries !== 'undefined' && DesktopEntries.byId && root.toplevel?.appId) { - const appId = root.toplevel.appId; + // Create a menu entry for each app-specific action defined in its .desktop file + if (canAddDesktopActions && typeof DesktopEntries !== 'undefined' && DesktopEntries.byId && appId) { const entry = (DesktopEntries.heuristicLookup) ? DesktopEntries.heuristicLookup(appId) : DesktopEntries.byId(appId); if (entry != null) { entry.actions.forEach(function (action) { @@ -284,7 +398,7 @@ PopupWindow { } } - function show(item, toplevelData, screen) { + function show(item, toplevelData, screen, groupModeOverride) { if (!item) { return; } @@ -294,8 +408,19 @@ PopupWindow { // Then set up new data anchorItem = item; - toplevel = toplevelData; + if (toplevelData && typeof toplevelData === "object" && (toplevelData.appId !== undefined || toplevelData.toplevels !== undefined)) { + appData = toplevelData; + toplevel = toplevelData.toplevel || null; + } else { + appData = toplevelData ? { + "appId": toplevelData.appId, + "toplevel": toplevelData, + "toplevels": toplevelData ? [toplevelData] : [] + } : null; + toplevel = toplevelData; + } targetScreen = screen || null; + forcedGroupMenuMode = groupModeOverride || ""; initItems(); visible = true; @@ -303,52 +428,117 @@ PopupWindow { gracePeriodTimer.restart(); } - function hide() { - visible = false; - root.items.length = 0; - menuContentWidth = 120; // Reset to minimum - } - // Helper function to determine which menu item is under the mouse function getHoveredItem(mouseY) { - const itemHeight = 32; const startY = Style.marginM; - const relativeY = mouseY - startY; + const localY = mouseY - startY; - if (relativeY < 0) + if (localY < 0) return -1; - const itemIndex = Math.floor(relativeY / itemHeight); - return itemIndex >= 0 && itemIndex < root.items.length ? itemIndex : -1; + function findIndexInList(items, relativeY, baseIndex) { + let offset = 0; + for (let i = 0; i < items.length; i++) { + const h = rowHeightForItem(items[i]); + if (relativeY >= offset && relativeY < offset + h) + return baseIndex + i; + offset += h; + } + return -1; + } + + if (splitExtendedLayout) { + if (localY < scrollAreaHeight) { + return findIndexInList(scrollItems, localY + (menuFlick ? menuFlick.contentY : 0), 0); + } + if (localY < scrollAreaHeight + separatorBlockHeight) { + return -1; + } + return findIndexInList(fixedItems, localY - scrollAreaHeight - separatorBlockHeight, separatorIndex + 1); + } else { + return findIndexInList(scrollItems, localY + (menuFlick ? menuFlick.contentY : 0), 0); + } } - function handleFocus() { - if (root.toplevel?.activate) { - root.toplevel.activate(); - } + function fixedItemGlobalIndex(localIndex) { + if (!splitExtendedLayout) + return localIndex; + return separatorIndex + 1 + localIndex; + } + + function isScrollableHovered(mouseY) { + const localY = mouseY - Style.marginM; + return localY >= 0 && localY < scrollAreaHeight; + } + + function onWheelScroll(deltaY) { + if (!menuFlick || menuFlick.contentHeight <= menuFlick.height) + return; + const nextY = menuFlick.contentY - deltaY; + menuFlick.contentY = Math.max(0, Math.min(nextY, menuFlick.contentHeight - menuFlick.height)); + } + + function resetMenuState() { + root.items.length = 0; + root.appData = null; + root.toplevel = null; + root.forcedGroupMenuMode = ""; + menuContentWidth = menuMinWidth; + hoveredItem = -1; + if (menuFlick) + menuFlick.contentY = 0; + } + + function hide() { + visible = false; + resetMenuState(); + } + + function hideWithoutReset() { + visible = false; + } + + function closeAndReset() { + hide(); root.requestClose(); } - function handlePin() { - if (root.toplevel?.appId) { - root.toggleAppPin(root.toplevel.appId); + function handleFocus(targetToplevel) { + if (targetToplevel?.activate) { + targetToplevel.activate(); } - root.requestClose(); + closeAndReset(); } - function handleClose() { - // Check if toplevel is still valid before trying to close it - const isValidToplevel = root.toplevel && ToplevelManager && ToplevelManager.toplevels.values.includes(root.toplevel); + function handlePin(appId) { + if (appId) { + root.toggleAppPin(appId); + } + closeAndReset(); + } - if (isValidToplevel && root.toplevel.close) { - root.toplevel.close(); - // Trigger immediate dock update callback if provided + function handleClose(targetToplevel) { + const isValidToplevel = targetToplevel && ToplevelManager && ToplevelManager.toplevels.values.includes(targetToplevel); + + if (isValidToplevel && targetToplevel.close) { + targetToplevel.close(); if (root.onAppClosed && typeof root.onAppClosed === "function") { Qt.callLater(root.onAppClosed); } } - root.hide(); - root.requestClose(); + closeAndReset(); + } + + function handleCloseAll(windows) { + windows.forEach(window => { + if (window && ToplevelManager && ToplevelManager.toplevels.values.includes(window) && window.close) { + window.close(); + } + }); + if (root.onAppClosed && typeof root.onAppClosed === "function") { + Qt.callLater(root.onAppClosed); + } + closeAndReset(); } function handleLauncherSettings() { @@ -357,7 +547,7 @@ PopupWindow { panel.requestedTab = SettingsPanel.Tab.Launcher; panel.toggle(); } - root.requestClose(); + closeAndReset(); } function handleDockSettings() { @@ -366,14 +556,14 @@ PopupWindow { panel.requestedTab = SettingsPanel.Tab.Dock; panel.toggle(); } - root.requestClose(); + closeAndReset(); } function handleLauncherWidgetSettings() { if (targetScreen && launcherWidgetSection && launcherWidgetIndex >= 0) { BarService.openWidgetSettings(targetScreen, launcherWidgetSection, launcherWidgetIndex, "Launcher", launcherWidgetSettings || {}); } - root.requestClose(); + closeAndReset(); } // Short delay to ignore spurious events @@ -383,7 +573,7 @@ PopupWindow { repeat: false onTriggered: { root.canAutoClose = true; - if (!menuMouseArea.containsMouse) { + if (!menuHoverHandler.hovered) { closeTimer.start(); } } @@ -395,7 +585,7 @@ PopupWindow { repeat: false running: false onTriggered: { - root.hide(); + root.hideWithoutReset(); } } @@ -406,56 +596,164 @@ PopupWindow { border.color: Color.mOutline border.width: Style.borderS - // Single MouseArea to handle both auto-close and menu interactions - MouseArea { - id: menuMouseArea - anchors.fill: parent - hoverEnabled: true - cursorShape: root.hoveredItem >= 0 ? Qt.PointingHandCursor : Qt.ArrowCursor - - onEntered: { - closeTimer.stop(); - } - - onExited: { - root.hoveredItem = -1; - if (root.canAutoClose) { - // Only close if grace period has passed - closeTimer.start(); + HoverHandler { + id: menuHoverHandler + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + onHoveredChanged: { + if (hovered) { + closeTimer.stop(); + } else { + root.hoveredItem = -1; + if (root.canAutoClose) { + closeTimer.start(); + } } } - - onPositionChanged: mouse => { - root.hoveredItem = root.getHoveredItem(mouse.y); - } - - onClicked: mouse => { - const clickedItem = root.getHoveredItem(mouse.y); - if (clickedItem >= 0) { - root.items[clickedItem].action.call(); - } - } } - ColumnLayout { - id: contextMenuColumn + WheelHandler { + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + onWheel: event => { + if (!root.isScrollableHovered(event.y)) + return; + const delta = event.pixelDelta.y !== 0 ? event.pixelDelta.y : event.angleDelta.y / 2; + root.onWheelScroll(delta); + event.accepted = true; + } + } + + Flickable { + id: menuFlick anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top - anchors.margins: Style.marginM + anchors.leftMargin: Style.marginM + anchors.rightMargin: Style.marginM + anchors.topMargin: Style.marginM + height: root.scrollAreaHeight + clip: true + contentWidth: width + contentHeight: scrollColumn.height + flickableDirection: Flickable.VerticalFlick + boundsBehavior: Flickable.StopAtBounds + interactive: contentHeight > height + + ScrollBar.vertical: ScrollBar { + id: menuScrollBar + policy: ScrollBar.AsNeeded + visible: root.listOverflowing + interactive: true + hoverEnabled: true + } + + Column { + id: scrollColumn + width: menuFlick.width + spacing: 0 + + Repeater { + model: root.scrollItems + + Rectangle { + readonly property bool isSeparator: modelData && modelData.separator === true + width: scrollColumn.width + height: root.rowHeightForItem(modelData) + color: (!isSeparator && root.hoveredItem === index) ? Color.mHover : "transparent" + radius: Style.radiusXS + + Row { + id: rowLayout + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Style.marginS + anchors.rightMargin: Style.marginS + spacing: Style.marginS + visible: !isSeparator + + NIcon { + icon: modelData.icon + pointSize: Style.fontSizeL + color: root.hoveredItem === index ? Color.mOnHover : Color.mOnSurfaceVariant + visible: icon !== "" + anchors.verticalCenter: parent.verticalCenter + } + + NText { + text: modelData.text + pointSize: Style.fontSizeS + color: root.hoveredItem === index ? Color.mOnHover : Color.mOnSurfaceVariant + anchors.verticalCenter: parent.verticalCenter + width: rowLayout.width - ((modelData.icon && modelData.icon !== "") ? (Style.fontSizeL + Style.marginS) : 0) + elide: Text.ElideRight + } + } + + MouseArea { + anchors.fill: parent + enabled: !parent.isSeparator && root.isItemActionable(index) + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton + + onEntered: { + root.hoveredItem = index; + } + + onExited: { + if (root.hoveredItem === index) { + root.hoveredItem = -1; + } + } + + onClicked: { + if (root.isItemActionable(index)) { + root.items[index].action.call(); + } + } + } + } + } + } + } + + Rectangle { + visible: root.splitExtendedLayout + anchors.left: parent.left + anchors.right: parent.right + anchors.top: menuFlick.bottom + anchors.leftMargin: Style.marginS + anchors.rightMargin: Style.marginS + height: Style.borderS + color: Qt.alpha(Color.mOutline, 0.7) + radius: Style.radiusXS + } + + Column { + id: fixedColumn + visible: root.splitExtendedLayout && root.fixedItems.length > 0 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: Style.marginM + anchors.rightMargin: Style.marginM + anchors.bottomMargin: Style.marginM + anchors.top: menuFlick.bottom + anchors.topMargin: root.separatorBlockHeight spacing: 0 Repeater { - model: root.items + model: root.fixedItems Rectangle { - Layout.fillWidth: true - height: 32 - color: root.hoveredItem === index ? Color.mHover : "transparent" + readonly property int globalIndex: root.fixedItemGlobalIndex(index) + width: fixedColumn.width + height: root.rowHeightForItem(modelData) + color: root.hoveredItem === globalIndex ? Color.mHover : "transparent" radius: Style.radiusXS Row { - id: rowLayout + id: fixedRowLayout anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter @@ -466,7 +764,7 @@ PopupWindow { NIcon { icon: modelData.icon pointSize: Style.fontSizeL - color: root.hoveredItem === index ? Color.mOnHover : Color.mOnSurfaceVariant + color: root.hoveredItem === parent.globalIndex ? Color.mOnHover : Color.mOnSurfaceVariant visible: icon !== "" anchors.verticalCenter: parent.verticalCenter } @@ -474,8 +772,34 @@ PopupWindow { NText { text: modelData.text pointSize: Style.fontSizeS - color: root.hoveredItem === index ? Color.mOnHover : Color.mOnSurfaceVariant + color: root.hoveredItem === parent.globalIndex ? Color.mOnHover : Color.mOnSurfaceVariant anchors.verticalCenter: parent.verticalCenter + width: fixedRowLayout.width - ((modelData.icon && modelData.icon !== "") ? (Style.fontSizeL + Style.marginS) : 0) + elide: Text.ElideRight + } + } + + MouseArea { + anchors.fill: parent + enabled: root.isItemActionable(parent.globalIndex) + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton + + onEntered: { + root.hoveredItem = parent.globalIndex; + } + + onExited: { + if (root.hoveredItem === parent.globalIndex) { + root.hoveredItem = -1; + } + } + + onClicked: { + if (root.isItemActionable(parent.globalIndex)) { + root.items[parent.globalIndex].action.call(); + } } } } diff --git a/Modules/Panels/Dock/StaticDockPanel.qml b/Modules/Panels/Dock/StaticDockPanel.qml index 4e2d2c4a7..b3f9b81d4 100644 --- a/Modules/Panels/Dock/StaticDockPanel.qml +++ b/Modules/Panels/Dock/StaticDockPanel.qml @@ -35,6 +35,7 @@ SmartPanel { // Combined model of running apps and pinned apps property var dockApps: [] + property var groupCycleIndices: ({}) // Track the session order of apps (transient reordering) property var sessionAppOrder: [] @@ -90,6 +91,10 @@ SmartPanel { if (!appData) return null; + if (Settings.data.dock.groupApps) { + return appData.appId; + } + // 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; @@ -221,6 +226,91 @@ SmartPanel { return appId; } + function getToplevelsForEntry(appData) { + if (!appData) + return []; + + if (appData.toplevels && appData.toplevels.length > 0) { + return appData.toplevels.filter(toplevel => toplevel && (!Settings.data.dock.onlySameOutput || !toplevel.screens || toplevel.screens.includes(screen))); + } + + if (!appData.toplevel) + return []; + + if (Settings.data.dock.onlySameOutput && appData.toplevel.screens && !appData.toplevel.screens.includes(screen)) + return []; + + return [appData.toplevel]; + } + + function getPrimaryToplevelForEntry(appData) { + const toplevels = getToplevelsForEntry(appData); + if (toplevels.length === 0) + return null; + + if (ToplevelManager && ToplevelManager.activeToplevel && toplevels.includes(ToplevelManager.activeToplevel)) + return ToplevelManager.activeToplevel; + + return toplevels[0]; + } + + // Build grouped render model without mutating the raw toplevel list. + function buildGroupedDockApps(apps) { + if (!Settings.data.dock.groupApps) { + return apps.map(app => { + const entry = Object.assign({}, app); + entry.toplevels = getToplevelsForEntry(app); + return entry; + }); + } + + const grouped = []; + const groupedById = new Map(); + + apps.forEach(app => { + const appId = app.appId; + const toplevels = getToplevelsForEntry(app); + const existing = groupedById.get(appId); + + if (existing) { + toplevels.forEach(toplevel => { + if (!existing.toplevels.includes(toplevel)) { + existing.toplevels.push(toplevel); + } + }); + if (app.type === "pinned" || app.type === "pinned-running") { + existing.isPinned = true; + } + } else { + const entry = { + "type": app.type, + "appId": appId, + "title": app.title, + "toplevels": toplevels.slice(), + "isPinned": app.type === "pinned" || app.type === "pinned-running" + }; + grouped.push(entry); + groupedById.set(appId, entry); + } + }); + + grouped.forEach(entry => { + entry.toplevel = getPrimaryToplevelForEntry(entry); + if (entry.toplevels.length > 0 && entry.isPinned) { + entry.type = "pinned-running"; + } else if (entry.toplevels.length > 0) { + entry.type = "running"; + } else { + entry.type = "pinned"; + } + if (entry.toplevel && entry.toplevel.title && entry.toplevel.title.trim() !== "") { + entry.title = entry.toplevel.title; + } + }); + + return grouped; + } + // Function to update the combined dock apps model function updateDockApps() { const runningApps = ToplevelManager ? (ToplevelManager.toplevels.values || []) : []; @@ -245,6 +335,7 @@ SmartPanel { combined.push({ "type": appType, "toplevel": toplevel, + "toplevels": toplevel ? [toplevel] : [], "appId": canonicalId, "title": title }); @@ -257,6 +348,7 @@ SmartPanel { combined.push({ "type": appType, "toplevel": toplevel, + "toplevels": [], "appId": canonicalId, "title": title }); @@ -305,7 +397,16 @@ SmartPanel { pushPinned(); } - dockApps = sortDockApps(combined); + const sortedApps = sortDockApps(combined); + dockApps = buildGroupedDockApps(sortedApps); + const cycleState = root.groupCycleIndices || {}; + const nextCycleState = {}; + dockApps.forEach(app => { + if (app && app.appId && cycleState[app.appId] !== undefined) { + nextCycleState[app.appId] = cycleState[app.appId]; + } + }); + root.groupCycleIndices = nextCycleState; // Sync session order if needed if (!sessionAppOrder || sessionAppOrder.length === 0) { @@ -355,6 +456,9 @@ SmartPanel { function onOnlySameOutputChanged() { updateDockApps(); } + function onGroupAppsChanged() { + updateDockApps(); + } } // Initial update when component is ready