mirror of
https://github.com/noctalia-dev/noctalia-shell.git
synced 2026-05-11 17:08:27 +08:00
Merge pull request #1900 from Dadangdut33/dock-group-apps
Feat(dock): Add option to group same apps in dock
This commit is contained in:
@@ -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.",
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user