Group dock icons by application in convergence mode

Switch TasksModel from GroupDisabled to GroupApplications so
multiple windows of the same app share a single dock icon.
Replace the per-task windowCountForTask() helper with the
built-in WinIdList role.

The thumbnail popup now shows a horizontal row of per-window
previews.  Clicking a grouped icon toggles the popup instead
of activating a single window.  "Close All" replaces the
close action for multi-window groups.
This commit is contained in:
Marco Allegretti 2026-04-11 13:25:01 +02:00
parent 6fd5a59a43
commit 86b34878c7

View file

@ -129,19 +129,7 @@ MouseArea {
filterHidden: false filterHidden: false
virtualDesktop: virtualDesktopInfo.currentDesktop virtualDesktop: virtualDesktopInfo.currentDesktop
activity: activityInfo.currentActivity activity: activityInfo.currentActivity
groupMode: TaskManager.TasksModel.GroupDisabled groupMode: TaskManager.TasksModel.GroupApplications
}
// Count how many windows share the same AppId as the task at the given index
function windowCountForTask(taskIndex) {
var appId = tasksModel.data(tasksModel.makeModelIndex(taskIndex), TaskManager.AbstractTasksModel.AppId)
if (!appId) return 1
var count = 0
for (var i = 0; i < tasksModel.rowCount(); i++) {
if (tasksModel.data(tasksModel.makeModelIndex(i), TaskManager.AbstractTasksModel.AppId) === appId)
count++
}
return count
} }
acceptedButtons: Qt.LeftButton | Qt.RightButton acceptedButtons: Qt.LeftButton | Qt.RightButton
@ -480,8 +468,9 @@ MouseArea {
id: thumbnailPopup id: thumbnailPopup
property var targetDelegate: null property var targetDelegate: null
property string windowTitle: "" property int taskIndex: -1
property string windowUuid: "" property var windowIds: []
property bool isGroup: false
property bool popupHovered: false property bool popupHovered: false
function open() { visible = true } function open() { visible = true }
@ -490,7 +479,15 @@ MouseArea {
flags: Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowDoesNotAcceptFocus flags: Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowDoesNotAcceptFocus
color: "transparent" color: "transparent"
width: Kirigami.Units.gridUnit * 16
readonly property real thumbWidth: windowIds.length <= 1
? Kirigami.Units.gridUnit * 16
: Kirigami.Units.gridUnit * 12
width: Math.max(Kirigami.Units.gridUnit * 8,
windowIds.length * thumbWidth
+ Math.max(0, windowIds.length - 1) * Kirigami.Units.smallSpacing
+ 2 * Kirigami.Units.smallSpacing)
height: popupContent.implicitHeight + 2 * Kirigami.Units.smallSpacing height: popupContent.implicitHeight + 2 * Kirigami.Units.smallSpacing
// Position above the hovered dock icon, in global coordinates // Position above the hovered dock icon, in global coordinates
@ -507,8 +504,10 @@ MouseArea {
onVisibleChanged: { onVisibleChanged: {
if (!visible) { if (!visible) {
windowUuid = "" windowIds = []
targetDelegate = null targetDelegate = null
taskIndex = -1
isGroup = false
} }
} }
@ -522,66 +521,99 @@ MouseArea {
border.width: 1 border.width: 1
radius: Kirigami.Units.cornerRadius radius: Kirigami.Units.cornerRadius
MouseArea { // HoverHandler for popup-level hover tracking (does not
id: popupHoverArea // consume mouse events, so clicks still reach delegates).
anchors.fill: parent HoverHandler {
hoverEnabled: true id: popupHoverHandler
onHoveredChanged: {
onContainsMouseChanged: { thumbnailPopup.popupHovered = hovered
thumbnailPopup.popupHovered = containsMouse if (hovered) {
if (containsMouse) {
thumbnailHideTimer.stop() thumbnailHideTimer.stop()
} else if (root.hoveredTaskIndex < 0) { } else if (root.hoveredTaskIndex < 0) {
thumbnailHideTimer.restart() thumbnailHideTimer.restart()
} }
} }
}
onClicked: { Row {
if (thumbnailPopup.targetDelegate) { id: popupContent
tasksModel.requestActivate( anchors.fill: parent
tasksModel.makeModelIndex(thumbnailPopup.targetDelegate.index)) anchors.margins: Kirigami.Units.smallSpacing
thumbnailPopup.close() spacing: Kirigami.Units.smallSpacing
}
}
Column { Repeater {
id: popupContent model: thumbnailPopup.windowIds.length
anchors.fill: parent
anchors.margins: Kirigami.Units.smallSpacing
spacing: Kirigami.Units.smallSpacing
Item { delegate: MouseArea {
width: parent.width id: thumbEntry
height: width * 9 / 16 width: thumbnailPopup.thumbWidth
height: thumbColumn.implicitHeight
hoverEnabled: true
Loader { readonly property string childUuid: thumbnailPopup.windowIds[index] || ""
id: pipeWireLoader readonly property string childTitle: {
active: thumbnailPopup.visible if (!thumbnailPopup.isGroup)
&& thumbnailPopup.windowUuid !== "" return tasksModel.data(tasksModel.makeModelIndex(thumbnailPopup.taskIndex), 0) || ""
return tasksModel.data(tasksModel.makeModelIndex(thumbnailPopup.taskIndex, index), 0) || ""
}
onClicked: {
var idx = thumbnailPopup.isGroup
? tasksModel.makeModelIndex(thumbnailPopup.taskIndex, index)
: tasksModel.makeModelIndex(thumbnailPopup.taskIndex)
tasksModel.requestActivate(idx)
thumbnailPopup.close()
}
Rectangle {
anchors.fill: parent anchors.fill: parent
sourceComponent: PipeWireThumbnail { radius: Kirigami.Units.cornerRadius
windowUuid: thumbnailPopup.windowUuid color: thumbEntry.containsMouse
? Qt.rgba(Kirigami.Theme.highlightColor.r,
Kirigami.Theme.highlightColor.g,
Kirigami.Theme.highlightColor.b, 0.15)
: "transparent"
}
Column {
id: thumbColumn
width: parent.width
spacing: Kirigami.Units.smallSpacing
Item {
width: parent.width
height: width * 9 / 16
Loader {
id: thumbPipeWireLoader
active: thumbnailPopup.visible
&& thumbEntry.childUuid !== ""
anchors.fill: parent
sourceComponent: PipeWireThumbnail {
windowUuid: thumbEntry.childUuid
}
}
Kirigami.Icon {
anchors.centerIn: parent
width: Kirigami.Units.iconSizes.huge
height: width
source: thumbnailPopup.targetDelegate
? thumbnailPopup.targetDelegate.model.decoration
: ""
visible: !thumbPipeWireLoader.item
|| !thumbPipeWireLoader.item.hasThumbnail
}
}
PC3.Label {
width: parent.width
text: thumbEntry.childTitle
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
maximumLineCount: 1
} }
} }
Kirigami.Icon {
anchors.centerIn: parent
width: Kirigami.Units.iconSizes.huge
height: width
source: thumbnailPopup.targetDelegate
? thumbnailPopup.targetDelegate.model.decoration
: ""
visible: !pipeWireLoader.item
|| !pipeWireLoader.item.hasThumbnail
}
}
PC3.Label {
width: parent.width
text: thumbnailPopup.windowTitle
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
maximumLineCount: 1
} }
} }
} }
@ -642,7 +674,10 @@ MouseArea {
spacing: Kirigami.Units.smallSpacing / 2 spacing: Kirigami.Units.smallSpacing / 2
Repeater { Repeater {
model: root.windowCountForTask(taskDelegate.index) model: {
var ids = taskDelegate.model.WinIdList
return ids ? ids.length : 1
}
Rectangle { Rectangle {
width: Kirigami.Units.smallSpacing * 1.5 width: Kirigami.Units.smallSpacing * 1.5
@ -666,17 +701,32 @@ MouseArea {
thumbnailShowTimer.stop() thumbnailShowTimer.stop()
taskContextMenu.open(); taskContextMenu.open();
} else { } else {
thumbnailPopup.close() var winIds = taskDelegate.model.WinIdList
tasksModel.requestActivate(tasksModel.makeModelIndex(taskDelegate.index)); if (winIds && winIds.length > 1) {
// Multiple windows: toggle thumbnail popup
if (thumbnailPopup.opened && thumbnailPopup.taskIndex === taskDelegate.index) {
thumbnailPopup.close()
} else {
thumbnailPopup.targetDelegate = taskDelegate
thumbnailPopup.taskIndex = taskDelegate.index
thumbnailPopup.windowIds = winIds
thumbnailPopup.isGroup = taskDelegate.model.IsGroupParent === true
thumbnailPopup.open()
}
} else {
thumbnailPopup.close()
tasksModel.requestActivate(tasksModel.makeModelIndex(taskDelegate.index));
}
} }
} }
onContainsMouseChanged: { onContainsMouseChanged: {
if (containsMouse) { if (containsMouse) {
thumbnailHideTimer.stop() thumbnailHideTimer.stop()
thumbnailPopup.targetDelegate = taskDelegate thumbnailPopup.targetDelegate = taskDelegate
thumbnailPopup.windowTitle = taskDelegate.model.display || "" thumbnailPopup.taskIndex = taskDelegate.index
var winIds = taskDelegate.model.WinIdList var winIds = taskDelegate.model.WinIdList
thumbnailPopup.windowUuid = (winIds && winIds.length > 0) ? winIds[0] : "" thumbnailPopup.windowIds = winIds ? winIds : []
thumbnailPopup.isGroup = taskDelegate.model.IsGroupParent === true
root.hoveredTaskIndex = taskDelegate.index root.hoveredTaskIndex = taskDelegate.index
if (!thumbnailPopup.opened) { if (!thumbnailPopup.opened) {
thumbnailShowTimer.restart() thumbnailShowTimer.restart()
@ -717,11 +767,15 @@ MouseArea {
PC3.MenuItem { PC3.MenuItem {
icon.name: taskDelegate.model.IsMaximized ? "window-restore" : "window-maximize" icon.name: taskDelegate.model.IsMaximized ? "window-restore" : "window-maximize"
text: taskDelegate.model.IsMaximized ? i18n("Restore") : i18n("Maximize") text: taskDelegate.model.IsMaximized ? i18n("Restore") : i18n("Maximize")
visible: taskDelegate.model.IsGroupParent !== true
onClicked: tasksModel.requestToggleMaximized(tasksModel.makeModelIndex(taskDelegate.index)) onClicked: tasksModel.requestToggleMaximized(tasksModel.makeModelIndex(taskDelegate.index))
} }
PC3.MenuItem { PC3.MenuItem {
icon.name: "window-close" icon.name: "window-close"
text: i18n("Close") text: {
var ids = taskDelegate.model.WinIdList
return (ids && ids.length > 1) ? i18n("Close All") : i18n("Close")
}
onClicked: tasksModel.requestClose(tasksModel.makeModelIndex(taskDelegate.index)) onClicked: tasksModel.requestClose(tasksModel.makeModelIndex(taskDelegate.index))
} }
} }