Merge pull request #1900 from Dadangdut33/dock-group-apps

Feat(dock): Add option to group same apps in dock
This commit is contained in:
Lysec
2026-02-20 15:23:29 +01:00
committed by GitHub
8 changed files with 924 additions and 163 deletions
+14
View File
@@ -1055,6 +1055,20 @@
"appearance-hide-show-speed-label": "Hide/show speed",
"appearance-icon-size-description": "Adjust the overall size of the dock.",
"appearance-icon-size-label": "Dock size",
"appearance-group-apps-description": "Group multiple windows from the same app into one dock entry.",
"appearance-group-apps-label": "Group same apps",
"appearance-group-click-action-description": "Choose what left-click does for grouped apps.",
"appearance-group-click-action-label": "Grouped app click action",
"appearance-group-click-action-cycle": "Cycle windows",
"appearance-group-click-action-list": "Open window list",
"appearance-group-context-menu-mode-description": "Choose how the context menu behaves for grouped apps.",
"appearance-group-context-menu-mode-label": "Grouped app menu mode",
"appearance-group-context-menu-mode-list": "Window list",
"appearance-group-context-menu-mode-extended": "Extended",
"appearance-group-indicator-style-label": "Grouped indicator style",
"appearance-group-indicator-style-description": "Choose how grouped running indicators display focused window state.",
"appearance-group-indicator-style-number": "Number",
"appearance-group-indicator-style-dots": "Dots",
"appearance-inactive-indicators-description": "Display indicator pills for all apps, not just the currently active one.",
"appearance-inactive-indicators-label": "Running indicators",
"appearance-launcher-position-description": "Choose where the launcher icon appears in the dock.",
+5 -1
View File
@@ -335,6 +335,10 @@
"launcherPosition": "end",
"pinnedStatic": false,
"inactiveIndicators": false,
"groupApps": false,
"groupContextMenuMode": "extended",
"groupClickAction": "cycle",
"groupIndicatorStyle": "dots",
"deadOpacity": 0.6,
"animationSpeed": 1,
"sitOnFrame": false,
@@ -496,4 +500,4 @@
"gridSnap": false,
"monitorWidgets": []
}
}
}
+4
View File
@@ -538,6 +538,10 @@ Singleton {
property bool pinnedStatic: false
property bool inactiveIndicators: false
property bool groupApps: false
property string groupContextMenuMode: "extended" // "list", "extended"
property string groupClickAction: "cycle" // "cycle", "list"
property string groupIndicatorStyle: "dots" // "number", "dots"
property double deadOpacity: 0.6
property real animationSpeed: 1.0 // Speed multiplier for hide/show animations (0.1 = slowest, 2.0 = fastest)
property bool sitOnFrame: false
+105 -1
View File
@@ -50,6 +50,9 @@ Loader {
function onOnlySameOutputChanged() {
updateDockApps();
}
function onGroupAppsChanged() {
updateDockApps();
}
}
// Initial update when component is ready
@@ -142,6 +145,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: []
@@ -177,6 +181,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;
@@ -308,6 +316,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 || []) : [];
@@ -332,6 +425,7 @@ Loader {
combined.push({
"type": appType,
"toplevel": toplevel,
"toplevels": toplevel ? [toplevel] : [],
"appId": canonicalId,
"title": title
});
@@ -344,6 +438,7 @@ Loader {
combined.push({
"type": appType,
"toplevel": toplevel,
"toplevels": [],
"appId": canonicalId,
"title": title
});
@@ -392,7 +487,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
+186 -54
View File
@@ -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
@@ -337,15 +397,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;
@@ -355,7 +423,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
@@ -627,66 +698,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);
}
}
}
@@ -694,7 +749,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
@@ -715,6 +770,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
}
}
}
}
}
}
+437 -106
View File
@@ -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() {
@@ -100,47 +171,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) {
@@ -277,7 +391,7 @@ PopupWindow {
}
}
function show(item, toplevelData, screen) {
function show(item, toplevelData, screen, groupModeOverride) {
if (!item) {
return;
}
@@ -287,8 +401,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;
@@ -296,52 +421,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() {
@@ -350,7 +540,7 @@ PopupWindow {
panel.requestedTab = SettingsPanel.Tab.Launcher;
panel.toggle();
}
root.requestClose();
closeAndReset();
}
function handleDockSettings() {
@@ -359,7 +549,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 || {});
}
closeAndReset();
}
// Short delay to ignore spurious events
@@ -369,7 +566,7 @@ PopupWindow {
repeat: false
onTriggered: {
root.canAutoClose = true;
if (!menuMouseArea.containsMouse) {
if (!menuHoverHandler.hovered) {
closeTimer.start();
}
}
@@ -381,7 +578,7 @@ PopupWindow {
repeat: false
running: false
onTriggered: {
root.hide();
root.hideWithoutReset();
}
}
@@ -392,56 +589,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
@@ -452,7 +757,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
}
@@ -460,8 +765,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();
}
}
}
}
+105 -1
View File
@@ -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
@@ -202,6 +202,74 @@ ColumnLayout {
onToggled: checked => Settings.data.dock.pinnedStatic = checked
}
NToggle {
label: I18n.tr("panels.dock.appearance-group-apps-label")
description: I18n.tr("panels.dock.appearance-group-apps-description")
checked: Settings.data.dock.groupApps
defaultValue: Settings.getDefaultValue("dock.groupApps")
onToggled: checked => Settings.data.dock.groupApps = checked
}
NComboBox {
Layout.fillWidth: true
visible: Settings.data.dock.groupApps
label: I18n.tr("panels.dock.appearance-group-click-action-label")
description: I18n.tr("panels.dock.appearance-group-click-action-description")
model: [
{
"key": "cycle",
"name": I18n.tr("panels.dock.appearance-group-click-action-cycle")
},
{
"key": "list",
"name": I18n.tr("panels.dock.appearance-group-click-action-list")
}
]
currentKey: Settings.data.dock.groupClickAction
defaultValue: Settings.getDefaultValue("dock.groupClickAction")
onSelected: key => Settings.data.dock.groupClickAction = key
}
NComboBox {
Layout.fillWidth: true
visible: Settings.data.dock.groupApps
label: I18n.tr("panels.dock.appearance-group-context-menu-mode-label")
description: I18n.tr("panels.dock.appearance-group-context-menu-mode-description")
model: [
{
"key": "list",
"name": I18n.tr("panels.dock.appearance-group-context-menu-mode-list")
},
{
"key": "extended",
"name": I18n.tr("panels.dock.appearance-group-context-menu-mode-extended")
}
]
currentKey: Settings.data.dock.groupContextMenuMode
defaultValue: Settings.getDefaultValue("dock.groupContextMenuMode")
onSelected: key => Settings.data.dock.groupContextMenuMode = key
}
NComboBox {
Layout.fillWidth: true
visible: Settings.data.dock.groupApps
label: I18n.tr("panels.dock.appearance-group-indicator-style-label")
description: I18n.tr("panels.dock.appearance-group-indicator-style-description")
model: [
{
"key": "number",
"name": I18n.tr("panels.dock.appearance-group-indicator-style-number")
},
{
"key": "dots",
"name": I18n.tr("panels.dock.appearance-group-indicator-style-dots")
}
]
currentKey: Settings.data.dock.groupIndicatorStyle
defaultValue: Settings.getDefaultValue("dock.groupIndicatorStyle")
onSelected: key => Settings.data.dock.groupIndicatorStyle = key
}
NToggle {
label: I18n.tr("panels.dock.monitors-only-same-monitor-label")
description: I18n.tr("panels.dock.monitors-only-same-monitor-description")