From 7b67f1b5b85ea23af2f636a402f5e0f965503b25 Mon Sep 17 00:00:00 2001 From: Wu JiangYu Date: Tue, 10 Nov 2026 16:52:48 +0800 Subject: [PATCH] feat: add taskbar overflow with cross-window positioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Add TaskOverflowController to move apps exceeding available dock space into an overflow popup, with recent-app shortcut and drag-out support 2. Introduce TaskOverflowButton UI with popup grid, tooltip, and translations for "Overflow area" 3. Support cross-window dock positioning in DockPositioner/ DockPanelPositioner so tooltips, previews, and popups anchor to items even when delegate windows differ from the panel window 4. Add trailingExtent handling in OverflowContainer and skip spacing for zero-width/hidden delegates 5. Fix MenuHelper to set activeMenu before opening to avoid race with close handlers 6. Center-alignment spacing fix in main.qml using centerAlignmentEdgeSpacing (14) and dockEffectiveCenterSpace 7. Extend AppItem with dragEnabled, dragSource, restorePositionAfterDrag, previewAnchor, updateIconGeometry, and Overflow-specific behaviors; add DockedRole to DockGlobalElementModel 8. Defer TextCalculator calculations via single-shot timer and publish consistent width results 9. Wire internal vs external DnD flows in TaskManager.qml: overflow popup dragging, reorder during overflow, undock on invalid drop, saveOrder on move Log: Added dock taskbar overflow area, overflow popup with recent app, improved cross-window tooltip/preview anchoring, and refined center- aligned dock spacing Influence: 1. Fill the taskbar until icons reach minimum size and verify the overflow button appears and remaining apps move into the popup 2. Click the overflow button to open/close popup, verify grid layout, scrolling, and tooltip "Overflow area" 3. Verify the recent app shortcut shown before the overflow button and that it updates on app activation 4. Drag apps from the overflow popup to the dock to move them back, and confirm undo/restore position after cancelling with Escape 5. Verify cross-window positioning of tooltips, window previews, and folder popups in horizontal and vertical docks 6. Test dock center alignment spacing at all four panel positions and both layouts (row/column) 7. Verify app reordering from taskbar and overflow, and saveDockElementsOrder persistence after restart 8. Test undocking by dragging an internal app out of the dock and re- docking an external app 9. Check that hidden overflow items do not leave extra spacing and animations are smooth 10. Verify TextCalculator schedule stability (no layout jitter when app count changes rapidly) feat: 新增任务栏溢出功能及跨窗口定位 1. 新增 TaskOverflowController,将超出可用空间的应用程序移入溢出弹窗,支 持最近应用快捷项及拖出 2. 新增 TaskOverflowButton 界面,含弹窗网格、悬浮提示及“溢出区域”多语言 翻译 3. 在 DockPositioner/DockPanelPositioner 中支持跨窗口 dock 定位,使提 示、预览及弹窗即使在委托窗口与面板窗口不同时也能正确锚定 4. OverflowContainer 新增 trailingExtent 处理,零宽度/隐藏委托不占额外 间距 5. 修复 MenuHelper 中先设置 activeMenu 再打开菜单,避免关闭回调竞态 6. main.qml 居中排列间距修复,使用 centerAlignmentEdgeSpacing(14)及 dockEffectiveCenterSpace 7. AppItem 扩展 dragEnabled、dragSource、restorePositionAfterDrag、 previewAnchor、updateIconGeometry 等属性;DockGlobalElementModel 新 增 DockedRole 8. TextCalculator 通过单次定时器延迟计算并保持宽度结果一致 9. TaskManager.qml 打通内部与外部拖放流程:溢出弹窗拖放、溢出时重排、无 效放置时取消驻留、移动后保存顺序 Log: 新增任务栏溢出区域、含最近应用的溢出弹窗、改进跨窗口提示/预览锚定, 并优化居中对齐的任务栏间距 Influence: 1. 填满任务栏直至图标缩至最小尺寸,验证溢出按钮出现且剩余应用进入弹窗 2. 点击溢出按钮打开/关闭弹窗,验证网格布局、滚动及“溢出区域”提示 3. 验证溢出按钮前的最近应用快捷项,并在应用激活时正确更新 4. 从溢出弹窗拖拽应用到任务栏以移回,验证取消(Esc)后位置恢复 5. 在水平和垂直任务栏下验证提示、窗口预览及文件夹弹窗的跨窗口定位 6. 在四个面板位置及两种布局(行/列)下测试居中对齐间距 7. 验证任务栏及溢出区域内的应用重排,以及重启后 saveDockElementsOrder 的 持久化 8. 测试将内部应用拖出任务栏取消驻留,以及外部应用重新驻留 9. 检查隐藏的溢出项不产生多余间距且动画流畅 10. 验证 TextCalculator 调度稳定性(应用数量频繁变化时无布局抖动) PMS: TASK-395671 --- panels/dock/MenuHelper.qml | 4 +- panels/dock/OverflowContainer.qml | 15 + panels/dock/dockpositioner.cpp | 71 ++- panels/dock/dockpositioner.h | 3 + panels/dock/package/main.qml | 23 +- panels/dock/taskmanager/CMakeLists.txt | 5 +- .../taskmanager/dockglobalelementmodel.cpp | 3 + panels/dock/taskmanager/package/AppItem.qml | 120 +++- .../dock/taskmanager/package/AppItemTitle.qml | 2 +- .../dock/taskmanager/package/FolderPopup.qml | 11 +- .../dock/taskmanager/package/TaskManager.qml | 218 ++++++- .../package/TaskOverflowButton.qml | 334 ++++++++++ panels/dock/taskmanager/taskmanager.cpp | 2 + .../taskmanager/taskoverflowcontroller.cpp | 585 ++++++++++++++++++ .../dock/taskmanager/taskoverflowcontroller.h | 200 ++++++ panels/dock/taskmanager/textcalculator.cpp | 35 +- panels/dock/taskmanager/textcalculator.h | 1 + .../org.deepin.ds.dock.taskmanager.ts | 7 + .../org.deepin.ds.dock.taskmanager_zh_CN.ts | 7 + .../org.deepin.ds.dock.taskmanager_zh_HK.ts | 7 + .../org.deepin.ds.dock.taskmanager_zh_TW.ts | 7 + 21 files changed, 1569 insertions(+), 91 deletions(-) create mode 100644 panels/dock/taskmanager/package/TaskOverflowButton.qml create mode 100644 panels/dock/taskmanager/taskoverflowcontroller.cpp create mode 100644 panels/dock/taskmanager/taskoverflowcontroller.h diff --git a/panels/dock/MenuHelper.qml b/panels/dock/MenuHelper.qml index 46775f090..4bacac408 100644 --- a/panels/dock/MenuHelper.qml +++ b/panels/dock/MenuHelper.qml @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2023 UnionTech Software Technology Co., Ltd. +// SPDX-FileCopyrightText: 2023-2026 UnionTech Software Technology Co., Ltd. // // SPDX-License-Identifier: GPL-3.0-or-later @@ -21,8 +21,8 @@ Item { if (activeMenu) { activeMenu.close() } - menu.open() activeMenu = menu + menu.open() } function closeMenu(menu: Menu) { menu.close() diff --git a/panels/dock/OverflowContainer.qml b/panels/dock/OverflowContainer.qml index eb3604fcd..c13558ad3 100644 --- a/panels/dock/OverflowContainer.qml +++ b/panels/dock/OverflowContainer.qml @@ -8,6 +8,7 @@ import QtQuick.Layouts 2.15 Item { id: root required property bool useColumnLayout + property real trailingExtent: 0 property alias model: listView.model property alias delegate: listView.delegate property alias spacing: listView.spacing @@ -31,6 +32,11 @@ Item { } function calculateImplicitWidth(prev, current) { + // Zero-width delegates are hidden (for example, items moved into the + // overflow popup) and must not leave an extra spacing slot behind. + if (current <= 0) { + return prev + } if (useColumnLayout) { return Math.max(prev, current) } else { @@ -42,6 +48,11 @@ Item { } function calculateImplicitHeight(prev, current) { + // Keep the vertical layout consistent with the horizontal one: hidden + // delegates contribute neither size nor spacing. + if (current <= 0) { + return prev + } if (useColumnLayout) { if (prev == 0) { return current @@ -61,6 +72,8 @@ Item { for (let child of listView.contentItem.visibleChildren) { width = calculateImplicitWidth(width, child.implicitWidth) } + if (!useColumnLayout && trailingExtent > 0) + width = calculateImplicitWidth(width, trailingExtent) // TODO: abvoe qt6.8 implicitSize to 0 will make size to 0 default. // so make minimum implicitSize to 1, find why and remove below return Math.max(width, 1) @@ -70,6 +83,8 @@ Item { for (let child of listView.contentItem.visibleChildren) { height = calculateImplicitHeight(height, child.implicitHeight) } + if (useColumnLayout && trailingExtent > 0) + height = calculateImplicitHeight(height, trailingExtent) return Math.max(height, 1) } } diff --git a/panels/dock/dockpositioner.cpp b/panels/dock/dockpositioner.cpp index fccc5a02e..df7d2a6ab 100644 --- a/panels/dock/dockpositioner.cpp +++ b/panels/dock/dockpositioner.cpp @@ -73,6 +73,34 @@ void DockPositioner::setBounding(const QRect &newBounding) emit boundingChanged(); } +bool DockPositioner::isCrossWindow() const +{ + const auto item = qobject_cast(parent()); + return item && item->window() && m_panel && m_panel->window() + && item->window() != m_panel->window(); +} + +QRect DockPositioner::effectiveBounding() const +{ + if (!isCrossWindow()) + return m_bounding; + + const auto item = qobject_cast(parent()); + const auto globalTopLeft = item->window()->mapToGlobal(m_bounding.topLeft()); + return QRect(globalTopLeft - dockGeometry().topLeft(), m_bounding.size()); +} + +QRect DockPositioner::attachedItemRectInDock() const +{ + const auto item = qobject_cast(parent()); + if (!item) + return {}; + + const auto globalTopLeft = item->mapToGlobal(QPointF(0, 0)); + const auto dockTopLeft = globalTopLeft.toPoint() - dockGeometry().topLeft(); + return QRect(dockTopLeft, QSize(qRound(item->width()), qRound(item->height()))); +} + int DockPositioner::x() const { return m_x; @@ -133,27 +161,28 @@ void DockPositioner::update() void DockPositioner::updatePosition() { + const auto bounding = effectiveBounding(); int xPosition = 0; int yPosition = 0; switch (dockPosition()) { case dock::Top: { - xPosition = m_bounding.x(); - yPosition = m_bounding.y(); + xPosition = bounding.x(); + yPosition = bounding.y(); break; } case dock::Right: { - xPosition = m_bounding.x() - m_bounding.width(); - yPosition = m_bounding.y(); + xPosition = bounding.x() - bounding.width(); + yPosition = bounding.y(); break; } case dock::Bottom: { - xPosition = m_bounding.x(); - yPosition = m_bounding.y() - m_bounding.height(); + xPosition = bounding.x(); + yPosition = bounding.y() - bounding.height(); break; } case dock::Left: { - xPosition = m_bounding.x(); - yPosition = m_bounding.y(); + xPosition = bounding.x(); + yPosition = bounding.y(); break; } default: @@ -222,29 +251,39 @@ void DockPanelPositioner::resetVertialOffset() void DockPanelPositioner::updatePosition() { const auto dockWindowRect = dockGeometry(); + const auto crossWindow = isCrossWindow(); + const auto anchorRect = crossWindow ? attachedItemRectInDock() : QRect(); int xPosition = 0; int yPosition = 0; int horizontalOffset = m_horizontalOffset == -1 ? m_bounding.width() / 2 : m_horizontalOffset; int vertialOffset = m_vertialOffset == -1 ? m_bounding.height() / 2 : m_vertialOffset; switch (dockPosition()) { case dock::Top: { - xPosition = m_bounding.x() - horizontalOffset; - yPosition = dockWindowRect.height() + 10; + xPosition = crossWindow ? anchorRect.center().x() - horizontalOffset + : m_bounding.x() - horizontalOffset; + yPosition = crossWindow ? anchorRect.y() + anchorRect.height() + 10 + : dockWindowRect.height() + 10; break; } case dock::Right: { - xPosition = -m_bounding.width() - 10; - yPosition = m_bounding.y() - vertialOffset; + xPosition = crossWindow ? anchorRect.left() - m_bounding.width() - 10 + : -m_bounding.width() - 10; + yPosition = crossWindow ? anchorRect.center().y() - vertialOffset + : m_bounding.y() - vertialOffset; break; } case dock::Bottom: { - xPosition = m_bounding.x() - horizontalOffset; - yPosition = -m_bounding.height() - 10; + xPosition = crossWindow ? anchorRect.center().x() - horizontalOffset + : m_bounding.x() - horizontalOffset; + yPosition = crossWindow ? anchorRect.top() - m_bounding.height() - 10 + : -m_bounding.height() - 10; break; } case dock::Left: { - xPosition = dockWindowRect.width() + 10; - yPosition = m_bounding.y() - vertialOffset; + xPosition = crossWindow ? anchorRect.x() + anchorRect.width() + 10 + : dockWindowRect.width() + 10; + yPosition = crossWindow ? anchorRect.center().y() - vertialOffset + : m_bounding.y() - vertialOffset; break; } default: diff --git a/panels/dock/dockpositioner.h b/panels/dock/dockpositioner.h index 07b0693b8..f9ae154e7 100644 --- a/panels/dock/dockpositioner.h +++ b/panels/dock/dockpositioner.h @@ -55,6 +55,9 @@ public slots: protected: void setScreen(QScreen *screen); + bool isCrossWindow() const; + QRect effectiveBounding() const; + QRect attachedItemRectInDock() const; QPointer m_screen; QPointer m_panel; QRect m_bounding {}; diff --git a/panels/dock/package/main.qml b/panels/dock/package/main.qml index e34e36a52..7174df502 100644 --- a/panels/dock/package/main.qml +++ b/panels/dock/package/main.qml @@ -49,11 +49,13 @@ Window { } } property int dockCenterPartCount: dockCenterPartModel.count + readonly property int centerAlignmentEdgeSpacing: 14 + readonly property int dockRawCenterSpace: { if (useColumnLayout) { return Screen.height - dockLeftPart.implicitHeight - dockRightPart.implicitHeight; } else { - let space = Screen.width - dockLeftPart.implicitWidth - dockRightPart.implicitWidth; + let space = Screen.width - dockLeftPart.implicitWidth - dockRightPart.implicitWidth - Math.ceil(gridLayout.columnSpacing); if (fashionDock.enabled && gridLayout) { // 时尚模式下,dockRightPart 在 gridLayout 右侧,之间需要扣除 dockSpacing // 同时保留左右悬浮间距,避免窗口铺满屏幕后圆角边框被屏幕边缘裁剪。 @@ -62,6 +64,17 @@ Window { return Math.max(0, space); } } + readonly property int dockEffectiveCenterSpace: { + if (Panel.itemAlignment !== Dock.CenterAlignment || fashionDock.enabled) { + return dockRawCenterSpace + } + + const screenExtent = useColumnLayout ? Screen.height : Screen.width + const leftExtent = useColumnLayout ? dockLeftPart.implicitHeight : dockLeftPart.implicitWidth + const rightExtent = useColumnLayout ? dockRightPart.implicitHeight : dockRightPart.implicitWidth + return Math.max(0, screenExtent - leftExtent - rightExtent + - centerAlignmentEdgeSpacing * 2) + } property int dockPartSpacing: gridLayout.columnSpacing // TODO @@ -653,8 +666,8 @@ Window { implicitHeight: centerLoader.implicitHeight Accessible.role: Accessible.Grouping Accessible.name: qsTr("Dock Center Area") - Layout.maximumWidth: useColumnLayout ? -1 : dockRawCenterSpace - Layout.maximumHeight: useColumnLayout ? dockRawCenterSpace : -1 + Layout.maximumWidth: useColumnLayout ? -1 : dockEffectiveCenterSpace + Layout.maximumHeight: useColumnLayout ? dockEffectiveCenterSpace : -1 onXChanged: dockCenterPartPosChanged() onYChanged: dockCenterPartPosChanged() Layout.leftMargin: { @@ -662,10 +675,10 @@ Window { return dockLeftPart.visible ? 0 : Math.max(10, Math.round(dockItemIconSize) / 3) } return !useColumnLayout && Panel.itemAlignment === Dock.CenterAlignment ? - Math.max(0, (dock.width - dockCenterPart.implicitWidth) / 2 - (dockLeftPart.implicitWidth + 20) + Math.min((dock.width - dockCenterPart.implicitWidth) / 2 - (dockRightPart.implicitWidth + 20), 0)) : 0 + Math.max(0, (dock.width - dockCenterPart.implicitWidth) / 2 - (dockLeftPart.implicitWidth + centerAlignmentEdgeSpacing) + Math.min((dock.width - dockCenterPart.implicitWidth) / 2 - (dockRightPart.implicitWidth + centerAlignmentEdgeSpacing), 0)) : 0 } Layout.topMargin: useColumnLayout && Panel.itemAlignment === Dock.CenterAlignment ? - Math.max(0, (dock.height - dockCenterPart.implicitHeight) / 2 - (dockLeftPart.implicitHeight + 20) + Math.min((dock.height - dockCenterPart.implicitHeight) / 2 - (dockRightPart.implicitHeight + 20), 0)) : 0 + Math.max(0, (dock.height - dockCenterPart.implicitHeight) / 2 - (dockLeftPart.implicitHeight + centerAlignmentEdgeSpacing) + Math.min((dock.height - dockCenterPart.implicitHeight) / 2 - (dockRightPart.implicitHeight + centerAlignmentEdgeSpacing), 0)) : 0 Behavior on Layout.leftMargin { enabled: !dock.isDragging && !Applet.isResizing diff --git a/panels/dock/taskmanager/CMakeLists.txt b/panels/dock/taskmanager/CMakeLists.txt index 808ac6da6..5eadc561e 100644 --- a/panels/dock/taskmanager/CMakeLists.txt +++ b/panels/dock/taskmanager/CMakeLists.txt @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: GPL-3.0-or-later -find_package(Qt${QT_VERSION_MAJOR} ${REQUIRED_QT_VERSION} REQUIRED COMPONENTS DBus WaylandClient) +find_package(Qt${QT_VERSION_MAJOR} ${REQUIRED_QT_VERSION} REQUIRED COMPONENTS DBus Quick WaylandClient) find_package(yaml-cpp REQUIRED) find_package(TreelandProtocols 0.9.0 REQUIRED) @@ -86,6 +86,8 @@ add_library(dock-taskmanager SHARED ${DBUS_INTERFACES} treelandwindowmonitor.h taskmanagersettings.cpp taskmanagersettings.h + taskoverflowcontroller.cpp + taskoverflowcontroller.h textcalculator.h textcalculator.cpp ) @@ -100,6 +102,7 @@ target_link_libraries(dock-taskmanager PRIVATE dde-shell-dock Qt${QT_VERSION_MAJOR}::WaylandClientPrivate Qt${QT_VERSION_MAJOR}::Concurrent + Qt${QT_VERSION_MAJOR}::Quick yaml-cpp PkgConfig::WaylandClient ) diff --git a/panels/dock/taskmanager/dockglobalelementmodel.cpp b/panels/dock/taskmanager/dockglobalelementmodel.cpp index e24461e1b..e49e50795 100644 --- a/panels/dock/taskmanager/dockglobalelementmodel.cpp +++ b/panels/dock/taskmanager/dockglobalelementmodel.cpp @@ -616,6 +616,9 @@ QVariant DockGlobalElementModel::data(const QModelIndex &index, int role) const return false; } + case TaskManager::DockedRole: + return model == nullptr || m_dockedElements.contains(std::make_tuple("desktop", id)); + case TaskManager::MenusRole: { return getMenus(index); } diff --git a/panels/dock/taskmanager/package/AppItem.qml b/panels/dock/taskmanager/package/AppItem.qml index 4cc7c3d3c..0386509ac 100644 --- a/panels/dock/taskmanager/package/AppItem.qml +++ b/panels/dock/taskmanager/package/AppItem.qml @@ -30,9 +30,15 @@ Item { property var folderPresentation: null readonly property bool isFolder: folderType !== "" property real blendOpacity: 1.0 + property bool dragEnabled: true + property string dragSource: "taskbar" + property bool restorePositionAfterDrag: false + property real dragStartX: 0 + property real dragStartY: 0 + property bool dragFinishedForPress: false signal dropFilesOnItem(itemId: string, files: list) - signal dragFinished() + signal dragFinished(int dropAction) // Binding Drag.active to mouseArea.drag.active would make Drag.active read // back the very property it drives (see visible/fixPosition below), @@ -42,18 +48,38 @@ Item { Drag.hotSpot.x: icon.width / 2 Drag.hotSpot.y: icon.height / 2 Drag.dragType: Drag.Automatic - Drag.mimeData: root.folderPresentation - ? { "text/x-dde-dock-dnd-folder": itemId, "text/x-dde-dock-dnd-source": "taskbar" } - : { "text/x-dde-dock-dnd-appid": itemId, "text/x-dde-dock-dnd-source": "taskbar", "text/x-dde-dock-dnd-winid": windows.length > 0 ? windows[0] : ""} Accessible.role: Accessible.Button Accessible.name: root.name Accessible.description: root.attention ? qsTr("Demands attention") : (root.active ? qsTr("Active") : "") + Drag.supportedActions: Qt.CopyAction | Qt.MoveAction + Drag.mimeData: root.folderPresentation + ? { "text/x-dde-dock-dnd-folder": itemId, "text/x-dde-dock-dnd-source": dragSource } + : { "text/x-dde-dock-dnd-appid": itemId, "text/x-dde-dock-dnd-source": dragSource, "text/x-dde-dock-dnd-winid": windows.length > 0 ? windows[0] : ""} + Drag.onDragFinished: function(dropAction) { + // MouseArea is still processing the initiating move while the native + // drag returns. Keep its target alive until that event has finished. + Qt.callLater(function() { + if (!root) + return + root.dragFinishedForPress = true + if (root.restorePositionAfterDrag) { + root.x = root.dragStartX + root.y = root.dragStartY + } + }) + Panel.contextDragging = false + root.dragFinished(dropAction) + } + property bool useColumnLayout: Panel.rootObject.useColumnLayout property real iconSize: Panel.rootObject.dockItemMaxSize * 9 / 14 property bool enableTitle: false property bool titleActive: enableTitle && titleLoader.active + property bool preservePanelPopupOnContextMenu: false + property bool updateIconGeometry: true + property Item previewAnchor: icon property var iconGlobalPoint: { var a = icon var x = 0, y = 0 @@ -441,6 +467,9 @@ Item { } FolderPopup { id: folderPopup + // An overflow item lives in the shared popup window. Position + // from the item before that window switches to folder content. + positionerAnchor: root folderName: root.name contents: root.applicationFolderContents presentation: root.folderPresentation @@ -493,7 +522,12 @@ Item { running: false repeat: false onTriggered: { - var pos = icon.mapToItem(null, 0, 0) + if (!root.updateIconGeometry) + return + const point = icon.mapToItem(null, 0, 0) + icon.DockPositioner.bounding = Qt.rect(point.x, point.y, 0, 0) + icon.DockPositioner.updatePosition() + const pos = Qt.point(icon.DockPositioner.x, icon.DockPositioner.y) taskmanager.Applet.requestUpdateWindowIconGeometry(root.modelIndex, Qt.rect(pos.x, pos.y, icon.width, icon.height), Panel.rootObject) } @@ -507,6 +541,8 @@ Item { property int xOffset: 0 property int yOffset: 0 onTriggered: { + xOffset = root.previewAnchor.DockPanelPositioner.x + yOffset = root.previewAnchor.DockPanelPositioner.y if (root.windows.length != 0 || Qt.platform.pluginName === "wayland") { // 使用基于 modelIndex 的预览API,确保精确匹配 taskmanager.Applet.requestPreview(root.modelIndex, Panel.rootObject, xOffset, yOffset, Panel.position); @@ -521,17 +557,9 @@ Item { return } - var itemPos = root.mapToItem(null, 0, 0) - let xOffset, yOffset, interval = 10 - if (Panel.position % 2 === 0) { - xOffset = itemPos.x + (root.width / 2) - yOffset = (Panel.position == 2 ? -interval : interval + Panel.dockSize) - } else { - xOffset = (Panel.position == 1 ? -interval : interval + Panel.dockSize) - yOffset = itemPos.y + (root.height / 2) - } - previewTimer.xOffset = xOffset - previewTimer.yOffset = yOffset + const point = root.previewAnchor.mapToItem(null, root.previewAnchor.width / 2, root.previewAnchor.height / 2) + root.previewAnchor.DockPanelPositioner.bounding = Qt.rect(point.x, point.y, 0, 0) + root.previewAnchor.DockPanelPositioner.updatePosition() previewTimer.start() } @@ -560,7 +588,9 @@ Item { } function requestAppItemMenu() { - Panel.requestClosePopup() + if (!root.preservePanelPopupOnContextMenu) { + Panel.requestClosePopup() + } if (root.isFolder) { if (folderUi.item) folderUi.item.contextMenu.openFor(root.folderType, root.itemId, root.name) return @@ -575,16 +605,14 @@ Item { anchors.fill: parent hoverEnabled: false acceptedButtons: Qt.LeftButton | Qt.RightButton - drag.target: root + preventStealing: root.dragEnabled + drag.target: root.dragEnabled && !root.dragFinishedForPress ? root : null drag.onActiveChanged: { + // Automatic drag enters the native event loop synchronously. Protect + // the popup from focus loss before starting it, not after it returns. + if (drag.active && folderUi.item) folderUi.item.popup.close() + Panel.contextDragging = drag.active root.Drag.active = drag.active - if (!drag.active) { - Panel.contextDragging = false - root.dragFinished() - return - } - if (folderUi.item) folderUi.item.popup.close() - Panel.contextDragging = true } // 记录是否是触摸长按导致的,防止在 onClicked 中重复处理 @@ -600,7 +628,10 @@ Item { } onPressed: function (mouse) { isTouchLongPressed = false - if (mouse.button === Qt.LeftButton) { + root.dragFinishedForPress = false + if (root.dragEnabled && mouse.button === Qt.LeftButton) { + root.dragStartX = root.x + root.dragStartY = root.y appItem.grabToImage(function(result) { root.Drag.imageSource = result.url; }) @@ -636,8 +667,8 @@ Item { Accessible.role: Accessible.ToolTip Accessible.id: "ToolTip" id: toolTip - toolTipX: DockPanelPositioner.x - toolTipY: DockPanelPositioner.y + toolTipX: icon.DockPanelPositioner.x + toolTipY: icon.DockPanelPositioner.y } PanelToolTip { @@ -645,8 +676,8 @@ Item { Accessible.id: "MoveToTrash" id: dragToolTip text: qsTr("Move to Trash") - toolTipX: DockPanelPositioner.x - toolTipY: DockPanelPositioner.y + toolTipX: icon.DockPanelPositioner.x + toolTipY: icon.DockPanelPositioner.y visible: false } @@ -654,9 +685,10 @@ Item { id: toolTipShowTimer interval: 50 onTriggered: { - var point = root.mapToItem(null, root.width / 2, root.height / 2) toolTip.text = root.itemId === "dde-trash" ? root.name + "-" + taskmanager.Applet.getTrashTipText() : root.name - toolTip.DockPanelPositioner.bounding = Qt.rect(point.x, point.y, toolTip.width, toolTip.height) + const point = icon.mapToItem(null, icon.width / 2, icon.height / 2) + icon.DockPanelPositioner.bounding = Qt.rect(point.x, point.y, + toolTip.width, toolTip.height) toolTip.open() } } @@ -678,8 +710,9 @@ Item { if (root.itemId === "dde-trash") { dragToolTipCloseTimer.stop() if (!dragToolTip.toolTipVisible) { - var point = root.mapToItem(null, root.width / 2, root.height / 2) - dragToolTip.DockPanelPositioner.bounding = Qt.rect(point.x, point.y, dragToolTip.width, dragToolTip.height) + const point = icon.mapToItem(null, icon.width / 2, icon.height / 2) + icon.DockPanelPositioner.bounding = Qt.rect(point.x, point.y, + dragToolTip.width, dragToolTip.height) dragToolTip.open() } } @@ -709,4 +742,23 @@ Item { onIconGlobalPointChanged: { updateWindowIconGeometryTimer.start() } + + onUpdateIconGeometryChanged: updateWindowIconGeometryTimer.start() + + // The recent shortcut can change apps while the pointer stays over it. + onItemIdChanged: { + if (hoverHandler.hovered) { + onExited() + onEntered() + } + } + + onVisibleChanged: { + if (!visible) { + toolTipShowTimer.stop() + toolTip.close() + if (hoverHandler.hovered || previewTimer.running) + closeItemPreview() + } + } } diff --git a/panels/dock/taskmanager/package/AppItemTitle.qml b/panels/dock/taskmanager/package/AppItemTitle.qml index 06c6806e9..e71146727 100644 --- a/panels/dock/taskmanager/package/AppItemTitle.qml +++ b/panels/dock/taskmanager/package/AppItemTitle.qml @@ -19,7 +19,7 @@ Item { implicitWidth: titleLoader.width implicitHeight: titleLoader.height - TextCalculator.text: root.text + TextCalculator.text: root.enabled ? root.text : "" Loader { id: titleLoader diff --git a/panels/dock/taskmanager/package/FolderPopup.qml b/panels/dock/taskmanager/package/FolderPopup.qml index 83103efc1..c3da5930e 100644 --- a/panels/dock/taskmanager/package/FolderPopup.qml +++ b/panels/dock/taskmanager/package/FolderPopup.qml @@ -14,6 +14,7 @@ PanelPopup { property var contents: [] property var presentation: null property point anchorPoint: Qt.point(0, 0) + property Item positionerAnchor: root property bool interactionActive: false signal activated(var entry) signal contextMenuRequested(var entry) @@ -46,11 +47,10 @@ PanelPopup { : Math.min(Math.min(3, rows) * cellHeight + 56, maximumHeight) contentReady: hasProviderContent ? contentLoader.status !== Loader.Loading : true popupX: Math.max(screenX - Panel.rootObject.x + 10, - Math.min(DockPanelPositioner.x, screenX + screenWidth - Panel.rootObject.x - width - 10)) + Math.min(positionerAnchor.DockPanelPositioner.x, screenX + screenWidth - Panel.rootObject.x - width - 10)) popupY: Math.max(screenY - Panel.rootObject.y + 10, - Math.min(DockPanelPositioner.y, screenY + screenHeight - Panel.rootObject.y - height - 10)) + Math.min(positionerAnchor.DockPanelPositioner.y, screenY + screenHeight - Panel.rootObject.y - height - 10)) closeOnInactive: !interactionActive && !Panel.contextDragging - DockPanelPositioner.bounding: Qt.rect(anchorPoint.x, anchorPoint.y, width, height) function openAt(point) { anchorPoint = point @@ -65,6 +65,11 @@ PanelPopup { Item { anchors.fill: parent + Binding { + target: root.positionerAnchor.DockPanelPositioner + property: "bounding" + value: Qt.rect(root.anchorPoint.x, root.anchorPoint.y, root.width, root.height) + } Loader { id: contentLoader objectName: "providerContentLoader" diff --git a/panels/dock/taskmanager/package/TaskManager.qml b/panels/dock/taskmanager/package/TaskManager.qml index c0e1bdaa9..2680c0dc8 100644 --- a/panels/dock/taskmanager/package/TaskManager.qml +++ b/panels/dock/taskmanager/package/TaskManager.qml @@ -22,7 +22,7 @@ ContainmentItem { function calcRemainingSpace(baseSize) { const otherCount = Panel.rootObject.dockCenterPartCount - 1; const otherOccupied = otherCount > 0 ? otherCount * baseSize * multitaskViewIconRatio : 0; - return Panel.rootObject.dockRawCenterSpace - otherOccupied; + return Panel.rootObject.dockEffectiveCenterSpace - otherOccupied; } property real remainingSpacesForTaskManager: fashionMode @@ -40,6 +40,9 @@ ContainmentItem { // Defined in main.qml as dockItemIconSize = dockItemMaxSize * 9 / 14. // At default dock size (56), icon = 36px; at min (37), icon ≈ 24px; at max (100), icon ≈ 64px. readonly property real iconWidthToMaxSizeRatio: 9 / 14 + readonly property int minimumDockItemSize: Math.max( + Dock.MIN_DOCK_SIZE, + Math.round(Panel.rootObject.dockSize * Dock.MIN_DOCK_SIZE / Dock.DEFAULT_DOCK_SIZE)) readonly property real startPadding: Math.max(0, appTitleSpacing - (Panel.rootObject.dockItemMaxSize * (multitaskViewIconRatio - iconWidthToMaxSizeRatio) / 2)) implicitWidth: { @@ -100,6 +103,20 @@ ContainmentItem { } property real blendOpacity: blendColorAlpha(D.DTK.themeType === D.ApplicationHelper.DarkType ? 0.25 : 1.0) + TaskOverflowController { + id: taskOverflow + // Window-split title layout has priority. Only fall back to adaptive icon + // shrinking and overflow when no title layout can fit the available space. + enabled: !textCalculator.enabled || textCalculator.totalWidth <= 0 + availableExtent: Math.max(0, taskmanager.calcRemainingSpace(Panel.rootObject.dockItemMaxSize) - taskmanager.startPadding) + minimumReached: Panel.rootObject.dockItemMaxSize <= taskmanager.minimumDockItemSize + 0.5 + fallbackItemExtent: Math.max(1, Panel.rootObject.dockItemMaxSize * taskmanager.iconWidthToMaxSizeRatio) + spacing: taskmanager.appTitleSpacing + overflowSidePadding: taskmanager.appTitleSpacing + 1 + visualModel: visualModel + dataModel: taskmanager.Applet.dataModel + } + TextCalculator { id: textCalculator enabled: !fashionMode && taskmanager.Applet.windowSplit && (Panel.position == Dock.Bottom || Panel.position == Dock.Top) @@ -126,6 +143,7 @@ ContainmentItem { anchors.topMargin: useColumnLayout ? taskmanager.startPadding : 0 useColumnLayout: taskmanager.useColumnLayout spacing: taskmanager.appTitleSpacing + trailingExtent: taskOverflow.trailingExtent remove: Transition { NumberAnimation { properties: "scale,opacity" @@ -137,6 +155,10 @@ ContainmentItem { model: DelegateModel { id: visualModel model: taskmanager.Applet.dataModel + function sourceIndexAt(visualIndex) { + // modelIndex() takes a source row, whereas items can be reordered. + return modelIndex(items.get(visualIndex).model.index) + } // 1:4 the distance between app : dock height; get width/height≈0.8 property real cellWidth: Panel.rootObject.dockItemMaxSize * 0.8 delegate: Item { @@ -163,6 +185,18 @@ ContainmentItem { return windows.length > 0 && launcherDndDropArea.launcherDndWinId !== windows[0] } + readonly property bool overflowActive: taskOverflow.popupCount > 0 + readonly property bool hiddenInOverflow: overflowActive + && DelegateModel.itemsIndex >= taskOverflow.visibleItemCount + visible: !hiddenInOverflow + + readonly property real normalDelegateWidth: useColumnLayout + ? taskmanager.implicitWidth + : appItem.implicitWidth + readonly property real normalDelegateHeight: useColumnLayout + ? Panel.rootObject.dockItemMaxSize * 9 / 14 + : taskmanager.implicitHeight + ListView.onAdd: NumberAnimation { target: delegateRoot properties: "scale,opacity" @@ -187,8 +221,8 @@ ContainmentItem { Behavior on opacity { NumberAnimation { duration: 200 } } Behavior on scale { NumberAnimation { duration: 200 } } - implicitWidth: useColumnLayout ? taskmanager.implicitWidth : appItem.implicitWidth - implicitHeight: useColumnLayout ? Panel.rootObject.dockItemMaxSize * 9 / 14 : taskmanager.implicitHeight + implicitWidth: hiddenInOverflow? 0 : normalDelegateWidth + implicitHeight: hiddenInOverflow? 0 : normalDelegateHeight property int visualIndex: DelegateModel.itemsIndex property var modelIndex: visualModel.modelIndex(index) @@ -204,8 +238,28 @@ ContainmentItem { y: delegateRoot.y width: delegateRoot.width height: delegateRoot.height - scale: delegateRoot.scale + readonly property bool hiddenByOverflow: delegateRoot.hiddenInOverflow + property real overflowTransitionScale: hiddenByOverflow ? 0.8 : 1.0 + scale: delegateRoot.scale * overflowTransitionScale + opacity: hiddenByOverflow ? 0 : 1 + visible: opacity > 0.001 property bool positionAnimationEnabled: false + + Behavior on opacity { + enabled: appItemRect.positionAnimationEnabled + NumberAnimation { + duration: 120 + easing.type: Easing.OutCubic + } + } + Behavior on overflowTransitionScale { + enabled: appItemRect.positionAnimationEnabled + NumberAnimation { + duration: 120 + easing.type: Easing.OutCubic + } + } + Behavior on x { enabled: appItemRect.positionAnimationEnabled NumberAnimation { @@ -250,6 +304,7 @@ ContainmentItem { blendOpacity: taskmanager.blendOpacity title: delegateRoot.title enableTitle: textCalculator.enabled + updateIconGeometry: !delegateRoot.hiddenInOverflow ListView.delayRemove: Drag.active Component.onCompleted: { dropFilesOnItem.connect(taskmanager.Applet.dropFilesOnItem) @@ -262,6 +317,15 @@ ContainmentItem { } } + TaskOverflowButton { + id: overflowButton + taskManagerItem: taskmanager + overflow: taskOverflow + onDragFinished: function(dropAction, itemId, canUndock) { + launcherDndDropArea.finishDrag(dropAction, itemId, canUndock) + } + } + DropArea { id: launcherDndDropArea anchors.fill: parent @@ -270,27 +334,125 @@ ContainmentItem { property string launcherDndDesktopId: "" property string launcherDndDragSource: "" property string launcherDndWinId: "" + property bool launcherDndDocked: false + property var pendingOverflowDrop: null + + function overflowTargetIndexAt(x, y) { + // Include gaps and the trailing shortcuts in the drop target. + // A full taskbar makes room by pushing its last app into overflow. + const axisPosition = taskmanager.useColumnLayout ? y : x + const index = Math.floor((axisPosition + taskOverflow.spacing / 2) + / (taskOverflow.fallbackItemExtent + taskOverflow.spacing)) + return Math.max(0, Math.min(index, taskOverflow.visibleItemCount - 1)) + } + + function saveOrder() { + let appIds = [] + for (let i = 0; i < visualModel.items.count; i++) { + appIds.push(visualModel.items.get(i).model.itemId) + } + taskmanager.Applet.saveDockElementsOrder(appIds) + } + + function moveApp(appId, winId, targetIndex) { + const currentIndex = taskmanager.Applet.windowSplit + ? taskmanager.findAppIndexByWindow(appId, winId) + : taskmanager.findAppIndex(appId) + if (currentIndex < 0 || targetIndex < 0) return false + if (currentIndex !== targetIndex) { + if (taskmanager.Applet.windowSplit) + taskmanager.Applet.moveItem(currentIndex, targetIndex) + else + visualModel.items.move(currentIndex, targetIndex) + taskOverflow.scheduleSync() + } + return true + } + + function finishDrag(dropAction, itemId, canUndock) { + const pending = pendingOverflowDrop + pendingOverflowDrop = null + const shouldUndock = taskOverflow.finishDrag(dropAction) + resetDndState() + // Both moves and unpinning can destroy the source. Commit them + // only after QDrag and the initiating MouseArea event return. + Qt.callLater(function() { + if (pending && dropAction === Qt.MoveAction) { + const targetIndex = Math.min(pending.targetIndex, taskOverflow.visibleItemCount - 1) + if (moveApp(pending.appId, pending.winId, targetIndex)) + saveOrder() + } else if (canUndock && shouldUndock) { + taskmanager.Applet.requestUndockByDesktopId(itemId) + } + }) + } function resetDndState() { launcherDndDesktopId = "" launcherDndDragSource = "" launcherDndWinId = "" + launcherDndDocked = false + } + + function isOverflowButtonIndex(targetIndex) { + return taskOverflow.popupCount > 0 + && (targetIndex < 0 || targetIndex >= taskOverflow.visibleItemCount) + } + + function targetIndexAt(x, y) { + return appContainer.indexAt(x, y) + } + + function isInternalDrag() { + return launcherDndDragSource === "taskbar" + || launcherDndDragSource === "overflow-popup" + } + + function requestDockIfNeeded() { + if (isInternalDrag() || launcherDndDocked) { + return true + } + + if (taskmanager.Applet.requestDockByDesktopId(launcherDndDesktopId) === false) { + resetDndState() + return false + } + + launcherDndDocked = true + return true } onEntered: function(drag) { let desktopId = drag.getDataAsString("text/x-dde-dock-dnd-folder") || drag.getDataAsString("text/x-dde-dock-dnd-appid") + if (!desktopId) { + drag.accepted = false + resetDndState() + return + } launcherDndDragSource = drag.getDataAsString("text/x-dde-dock-dnd-source") launcherDndWinId = drag.getDataAsString("text/x-dde-dock-dnd-winid") launcherDndDesktopId = desktopId - if (launcherDndDragSource !== "taskbar" && taskmanager.Applet.requestDockByDesktopId(desktopId) === false) { + if (launcherDndDragSource === "overflow-popup") { + pendingOverflowDrop = null + drag.acceptProposedAction() + return + } + let targetIndex = targetIndexAt(drag.x, drag.y) + if (!isOverflowButtonIndex(targetIndex) && !requestDockIfNeeded()) { drag.accepted = false - resetDndState() + return + } + if (!isOverflowButtonIndex(targetIndex)) { + drag.acceptProposedAction() } } onPositionChanged: function(drag) { if (launcherDndDesktopId === "") return - let targetIndex = appContainer.indexAt(drag.x, drag.y) + if (launcherDndDragSource === "overflow-popup") return + let targetIndex = targetIndexAt(drag.x, drag.y) + if (isOverflowButtonIndex(targetIndex) || !requestDockIfNeeded()) return + drag.acceptProposedAction() let appId = taskmanager.Applet.desktopIdToAppId(launcherDndDesktopId) let currentIndex = taskmanager.Applet.windowSplit ? taskmanager.findAppIndexByWindow(appId, launcherDndWinId) : taskmanager.findAppIndex(appId) if (currentIndex !== -1 && targetIndex !== -1 && currentIndex !== targetIndex) { @@ -298,14 +460,30 @@ ContainmentItem { taskmanager.Applet.moveItem(currentIndex, targetIndex) } else { visualModel.items.move(currentIndex, targetIndex) + taskOverflow.scheduleSync() } } } onDropped: function(drop) { - Panel.contextDragging = false if (launcherDndDesktopId === "") return - let targetIndex = appContainer.indexAt(drop.x, drop.y) + if (launcherDndDragSource === "overflow-popup") { + pendingOverflowDrop = { + appId: taskmanager.Applet.desktopIdToAppId(launcherDndDesktopId), + winId: launcherDndWinId, + targetIndex: overflowTargetIndexAt(drop.x, drop.y) + } + drop.accept(Qt.MoveAction) + resetDndState() + return + } + Panel.contextDragging = false + let targetIndex = targetIndexAt(drop.x, drop.y) + if (isOverflowButtonIndex(targetIndex)) { + resetDndState() + return + } + if (!requestDockIfNeeded()) return let appId = taskmanager.Applet.desktopIdToAppId(launcherDndDesktopId) let currentIndex = taskmanager.Applet.windowSplit ? taskmanager.findAppIndexByWindow(appId, launcherDndWinId) : taskmanager.findAppIndex(appId) if (currentIndex !== -1 && targetIndex !== -1 && currentIndex !== targetIndex) { @@ -313,22 +491,22 @@ ContainmentItem { taskmanager.Applet.moveItem(currentIndex, targetIndex) } else { visualModel.items.move(currentIndex, targetIndex) + taskOverflow.scheduleSync() } } - let appIds = [] - for (let i = 0; i < visualModel.items.count; i++) { - appIds.push(visualModel.items.get(i).model.itemId) - } - taskmanager.Applet.saveDockElementsOrder(appIds) - if (drop.formats.indexOf("text/x-dde-dock-dnd-folder") !== -1) + saveOrder() + if (drop.formats.indexOf("text/x-dde-dock-dnd-folder") !== -1) { drop.accept(Qt.CopyAction) - else + } else if (isInternalDrag()) { + drop.accept(Qt.MoveAction) + } else { drop.acceptProposedAction() + } resetDndState() } onExited: function() { - if (launcherDndDesktopId !== "" && launcherDndDragSource !== "taskbar") { + if (launcherDndDesktopId !== "" && !isInternalDrag() && launcherDndDocked) { taskmanager.Applet.requestUndockByDesktopId(launcherDndDesktopId) } resetDndState() @@ -382,8 +560,10 @@ ContainmentItem { return dockSize; const otherItems = Math.max(0, Panel.rootObject.dockCenterPartCount - 1); - const optimal = solveMaxSizeFull(Panel.rootObject.dockRawCenterSpace, appCount, otherItems); - return optimal >= dockSize ? dockSize : optimal; + const optimal = solveMaxSizeFull(Panel.rootObject.dockEffectiveCenterSpace, appCount, otherItems); + const fitted = optimal >= dockSize ? dockSize : optimal; + return Math.max(taskmanager.minimumDockItemSize, fitted); }) + taskOverflow.scheduleSync() } } diff --git a/panels/dock/taskmanager/package/TaskOverflowButton.qml b/panels/dock/taskmanager/package/TaskOverflowButton.qml new file mode 100644 index 000000000..b0f2e79a1 --- /dev/null +++ b/panels/dock/taskmanager/package/TaskOverflowButton.qml @@ -0,0 +1,334 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Window 2.15 + +import org.deepin.ds 1.0 +import org.deepin.ds.dock 1.0 +import org.deepin.dtk 1.0 as D + +Item { + id: root + + required property var taskManagerItem + required property var overflow + + signal dragFinished(int dropAction, string itemId, bool canUndock) + + readonly property int overflowCount: overflow.popupCount + readonly property var primaryOverflowItem: overflow.popupCount > 0 ? overflow.primaryItem : null + readonly property string overflowIconName: primaryOverflowItem && primaryOverflowItem.iconName ? primaryOverflowItem.iconName : "" + readonly property real popupItemWidth: overflow.fallbackItemExtent + readonly property real popupItemHeight: overflow.fallbackItemExtent + readonly property real buttonOffset: overflow.buttonOffset + readonly property real overflowIconSize: Math.min(overflow.buttonExtent, Math.max(12, Math.round(Panel.rootObject.dockItemIconSize))) + readonly property int hoverVerticalSpacing: Math.round(overflowIconSize / 8) + 1 + readonly property int hoverHorizontalSpacing: Math.round(overflowIconSize / 8) + readonly property int hoverBackgroundWidth: Math.round(overflowIconSize + hoverHorizontalSpacing * 2) + readonly property int hoverBackgroundHeight: Math.round(overflowIconSize + hoverVerticalSpacing * 2) + readonly property real dotSize: Math.max(2, Math.round(overflowIconSize * 0.11)) + readonly property real dotSpacing: Math.max(1, Math.round(dotSize * 0.75)) + readonly property color dotColor: D.DTK.themeType === D.ApplicationHelper.DarkType ? "#FFFFFF" : "#000000" + readonly property color separatorColor: D.DTK.themeType === D.ApplicationHelper.DarkType + ? Qt.rgba(1, 1, 1, 0.1) : Qt.rgba(0, 0, 0, 0.1) + readonly property real gridSpacing: overflow.spacing + readonly property int popupCellWidth: Math.ceil(popupItemWidth + gridSpacing) + readonly property int popupCellHeight: Math.ceil(popupItemHeight + + Math.max(10, 2 * (Math.round(popupItemHeight / 8) + 2))) + readonly property real popupAvailableWidth: Math.max(popupCellWidth + 20, + Screen.width - 40 - (root.taskManagerItem.useColumnLayout ? Panel.rootObject.dockSize : 0)) + readonly property real popupAvailableHeight: Math.max(popupCellHeight + 20, + Screen.height - 40 - (root.taskManagerItem.useColumnLayout ? 0 : Panel.rootObject.dockSize)) + readonly property int popupColumns: Math.min(6, Math.max(1, overflowCount), + Math.max(1, Math.floor((popupAvailableWidth - 20) / popupCellWidth))) + property point popupAnchorPoint: Qt.point(0, 0) + + visible: overflowCount > 0 && overflow.buttonExtent > 0 + x: root.taskManagerItem.useColumnLayout ? 0 : root.buttonOffset + y: root.taskManagerItem.useColumnLayout ? root.buttonOffset : 0 + implicitWidth: root.taskManagerItem.useColumnLayout ? root.taskManagerItem.implicitWidth : overflow.buttonExtent + implicitHeight: root.taskManagerItem.useColumnLayout ? overflow.buttonExtent : root.taskManagerItem.implicitHeight + width: implicitWidth + height: implicitHeight + + Accessible.role: Accessible.Button + Accessible.name: qsTr("Overflow area") + + // Leave a full application gap between each outer line and its shortcut. + // A vertical dock uses horizontal lines before and after the group. + Repeater { + model: 2 + + Rectangle { + required property int index + visible: overflow.separatorPadding >= 1 + readonly property real axisPosition: index === 0 + ? -(overflow.showRecent ? overflow.fallbackItemExtent + overflow.spacing : 0) - overflow.separatorPadding + : overflow.buttonExtent + overflow.separatorPadding - 1 + + width: root.taskManagerItem.useColumnLayout ? Panel.rootObject.dockItemIconSize : 1 + height: root.taskManagerItem.useColumnLayout ? 1 : Panel.rootObject.dockItemIconSize + x: root.taskManagerItem.useColumnLayout ? (root.width - width) / 2 : Math.round(axisPosition) + y: root.taskManagerItem.useColumnLayout ? Math.round(axisPosition) : (root.height - height) / 2 + color: root.separatorColor + Accessible.ignored: true + } + } + + // The recent shortcut precedes the overflow entry in both dock orientations. + Loader { + active: root.visible && root.overflow.showRecent && !!root.overflow.recentItem.itemId + x: root.taskManagerItem.useColumnLayout ? 0 : -(overflow.fallbackItemExtent + overflow.spacing) + y: root.taskManagerItem.useColumnLayout ? -(overflow.fallbackItemExtent + overflow.spacing) : 0 + width: root.width + height: root.height + sourceComponent: AppItem { + readonly property var entry: root.overflow.recentItem + displayMode: Panel.indicatorStyle + colorTheme: Panel.colorTheme + active: entry.active + attention: entry.attention + itemId: entry.itemId + name: entry.name + iconName: entry.iconName + folderType: entry.folderType + applicationFolderContents: entry.applicationFolderContents + folderPresentation: entry.folderPresentation + menus: entry.menus + windows: entry.windows + visualIndex: entry.visualIndex + modelIndex: entry.modelIndex + title: entry.title + blendOpacity: root.taskManagerItem.blendOpacity + dragEnabled: false + Component.onCompleted: dropFilesOnItem.connect(root.taskManagerItem.Applet.dropFilesOnItem) + } + } + + onOverflowCountChanged: { + if (overflowCount === 0) + overflowPopup.close() + } + + Connections { + target: Panel + function onPositionChanged() { overflowPopup.close() } + } + + function togglePopup() { + toolTipShowTimer.stop() + toolTip.close() + + if (root.overflowCount <= 0) { + return + } + + if (overflowPopup.popupVisible) { + overflowPopup.close() + return + } + + Panel.requestClosePopup() + popupAnchorPoint = root.mapToItem(null, root.width / 2, root.height / 2) + popupContentLoader.active = true + overflowPopup.open() + } + + AppItemBackground { + width: root.hoverBackgroundWidth + height: root.hoverBackgroundHeight + radius: height / 5 + anchors.centerIn: parent + enabled: false + displayMode: Panel.indicatorStyle + windowCount: 0 + isActive: false + D.ColorSelector.hovered: overflowHoverHandler.hovered || overflowPopup.popupVisible + D.ColorSelector.pressed: overflowMouseArea.pressed + } + + Item { + id: overflowIndicator + anchors.centerIn: parent + visible: root.primaryOverflowItem !== null + width: root.overflowIconSize + height: root.overflowIconSize + + D.DciIcon { + anchors.fill: parent + name: root.overflowIconName + sourceSize: Qt.size(width, height) + retainWhileLoading: true + smooth: false + opacity: 0.4 + } + Row { + spacing: root.dotSpacing + anchors.centerIn: parent + + Repeater { + model: 3 + + Rectangle { + width: root.dotSize + height: width + radius: width / 2 + color: root.dotColor + } + } + } + } + + MouseArea { + id: overflowMouseArea + anchors.fill: parent + // Consume right clicks without opening the popup or the dock's menu. + acceptedButtons: Qt.LeftButton | Qt.RightButton + hoverEnabled: false + onClicked: function(mouse) { + if (mouse.button === Qt.LeftButton) + root.togglePopup() + } + } + + HoverHandler { + id: overflowHoverHandler + onHoveredChanged: { + if (hovered && !overflowPopup.popupVisible) { + toolTipShowTimer.start() + } else { + toolTipShowTimer.stop() + toolTip.close() + } + } + } + + PanelToolTip { + id: toolTip + text: qsTr("Overflow area") + toolTipX: DockPanelPositioner.x + toolTipY: DockPanelPositioner.y + } + + Timer { + id: toolTipShowTimer + interval: 50 + onTriggered: { + var point = root.mapToItem(null, root.width / 2, root.height / 2) + toolTip.DockPanelPositioner.bounding = Qt.rect(point.x, point.y, toolTip.width, toolTip.height) + toolTip.open() + } + } + + PanelPopup { + id: overflowPopup + width: popupContentLoader.item ? popupContentLoader.item.implicitWidth : 0 + height: popupContentLoader.item ? popupContentLoader.item.implicitHeight : 0 + contentReady: popupContentLoader.status === Loader.Ready + popupX: DockPanelPositioner.x + popupY: DockPanelPositioner.y + closeOnInactive: MenuHelper.activeMenu === null && !Panel.contextDragging + + Component.onCompleted: { + DockPanelPositioner.bounding = Qt.binding(function () { + return Qt.rect(root.popupAnchorPoint.x, + root.popupAnchorPoint.y, + overflowPopup.width, + overflowPopup.height) + }) + } + + // Create the grid on first use and retain it across close/open and native + // drags. GridView only instantiates the visible rows (plus its cache). + Loader { + id: popupContentLoader + active: false + width: overflowPopup.width + height: overflowPopup.height + sourceComponent: Control { + id: overflowPopupContent + padding: 10 + implicitWidth: root.popupColumns * overflowGrid.cellWidth + leftPadding + rightPadding + implicitHeight: Math.min(root.popupAvailableHeight, + Math.ceil(root.overflowCount / root.popupColumns) * overflowGrid.cellHeight + + topPadding + bottomPadding) + background: Item {} + + DropArea { + anchors.fill: parent + keys: ["text/x-dde-dock-dnd-appid", "text/x-dde-dock-dnd-folder"] + onDropped: function(drop) { + if (drop.getDataAsString("text/x-dde-dock-dnd-source") === "overflow-popup") + drop.accept(Qt.MoveAction) + } + } + + contentItem: GridView { + id: overflowGrid + cellWidth: root.popupCellWidth + cellHeight: root.popupCellHeight + interactive: contentHeight > height && !Panel.contextDragging + clip: true + boundsBehavior: Flickable.StopAtBounds + flickableDirection: Flickable.VerticalFlick + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + model: root.overflow.popupModel + + delegate: Item { + id: popupDelegate + required property int index + required property var entry + width: overflowGrid.cellWidth + height: overflowGrid.cellHeight + GridView.delayRemove: popupApp.Drag.active + + AppItem { + id: popupApp + x: (popupDelegate.width - width) / 2 + y: (popupDelegate.height - height) / 2 + width: root.popupItemWidth + height: root.popupItemHeight + displayMode: Panel.indicatorStyle + colorTheme: Panel.colorTheme + active: popupDelegate.entry.active + attention: popupDelegate.entry.attention + itemId: popupDelegate.entry.itemId + name: popupDelegate.entry.name + iconName: popupDelegate.entry.iconName + folderType: popupDelegate.entry.folderType + applicationFolderContents: popupDelegate.entry.applicationFolderContents + folderPresentation: popupDelegate.entry.folderPresentation + menus: popupDelegate.entry.menus + windows: popupDelegate.entry.windows + visualIndex: popupDelegate.entry.visualIndex + modelIndex: popupDelegate.entry.modelIndex + blendOpacity: root.taskManagerItem.blendOpacity + title: popupDelegate.entry.title + enableTitle: false + preservePanelPopupOnContextMenu: true + previewAnchor: overflowPopupContent + updateIconGeometry: false + dragEnabled: popupDelegate.entry.docked && windows.length === 0 + dragSource: "overflow-popup" + restorePositionAfterDrag: true + Drag.onDragStarted: { + // Views retain their current item outside the viewport. + overflowGrid.currentIndex = popupDelegate.index + root.overflow.beginDrag() + } + onDropFilesOnItem: function(itemId, files) { + root.taskManagerItem.Applet.dropFilesOnItem(itemId, files) + } + onDragFinished: function(dropAction) { + root.dragFinished(dropAction, itemId, dragEnabled) + } + } + } + } + } + } + } +} diff --git a/panels/dock/taskmanager/taskmanager.cpp b/panels/dock/taskmanager/taskmanager.cpp index 1f2c0504d..3c2accdc7 100644 --- a/panels/dock/taskmanager/taskmanager.cpp +++ b/panels/dock/taskmanager/taskmanager.cpp @@ -19,6 +19,7 @@ #include "pluginfactory.h" #include "taskmanager.h" #include "taskmanageradaptor.h" +#include "taskoverflowcontroller.h" #include "taskmanagersettings.h" #include "textcalculator.h" #include "treelandwindowmonitor.h" @@ -150,6 +151,7 @@ TaskManager::TaskManager(QObject *parent) , m_folderMenuRegistry(this) , m_windowFullscreen(false) { + qmlRegisterType("org.deepin.ds.dock.taskmanager", 1, 0, "TaskOverflowController"); qmlRegisterType("org.deepin.ds.dock.taskmanager", 1, 0, "TextCalculator"); qmlRegisterUncreatableType("org.deepin.ds.dock.taskmanager", 1, 0, "TextCalculatorAttached", "TextCalculator Attached"); diff --git a/panels/dock/taskmanager/taskoverflowcontroller.cpp b/panels/dock/taskmanager/taskoverflowcontroller.cpp new file mode 100644 index 000000000..6bc982b95 --- /dev/null +++ b/panels/dock/taskmanager/taskoverflowcontroller.cpp @@ -0,0 +1,585 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "taskoverflowcontroller.h" + +#include "globals.h" + +#include +#include +#include +#include + +#include +#include +#include + +namespace dock { +namespace { +constexpr qreal OverflowReleaseHysteresis = 8; +} + +int TaskOverflowModel::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : m_entries.size(); +} + +QVariant TaskOverflowModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_entries.size() || role != Qt::UserRole) + return {}; + return m_entries.at(index.row()).values; +} + +QHash TaskOverflowModel::roleNames() const +{ + return {{Qt::UserRole, "entry"}}; +} + +void TaskOverflowModel::setItems(const QVariantList &items) +{ + for (int row = 0; row < items.size(); ++row) { + const auto values = items.at(row).toMap(); + const QPersistentModelIndex source = values.value(QStringLiteral("modelIndex")).value(); + int existing = row; + while (existing < m_entries.size() && m_entries.at(existing).source != source) + ++existing; + if (existing == m_entries.size()) { + beginInsertRows({}, row, row); + m_entries.insert(row, {source, values}); + endInsertRows(); + } else { + if (existing != row) { + beginMoveRows({}, existing, existing, {}, row); + m_entries.move(existing, row); + endMoveRows(); + } + if (m_entries.at(row).values != values) { + m_entries[row].values = values; + emit dataChanged(index(row), index(row), {Qt::UserRole}); + } + } + } + if (m_entries.size() > items.size()) { + beginRemoveRows({}, items.size(), m_entries.size() - 1); + m_entries.erase(m_entries.begin() + items.size(), m_entries.end()); + endRemoveRows(); + } +} + +TaskOverflowController::TaskOverflowController(QObject *parent) + : QObject(parent) +{ +} + +void TaskOverflowController::setEnabled(bool enabled) +{ + if (m_enabled == enabled) + return; + + m_enabled = enabled; + if (!m_enabled) + m_latched = false; + emit enabledChanged(); + scheduleSync(); +} + +void TaskOverflowController::setAvailableExtent(qreal availableExtent) +{ + availableExtent = std::isfinite(availableExtent) ? std::max(0, availableExtent) : 0; + if (!qFuzzyCompare(m_availableExtent + 1, availableExtent + 1)) { + m_availableExtent = availableExtent; + scheduleSync(); + } +} + +void TaskOverflowController::setMinimumReached(bool minimumReached) +{ + if (m_minimumReached != minimumReached) { + m_minimumReached = minimumReached; + scheduleSync(); + } +} + +void TaskOverflowController::setFallbackItemExtent(qreal fallbackItemExtent) +{ + fallbackItemExtent = std::isfinite(fallbackItemExtent) ? std::max(1, fallbackItemExtent) : 1; + if (!qFuzzyCompare(m_fallbackItemExtent + 1, fallbackItemExtent + 1)) { + m_fallbackItemExtent = fallbackItemExtent; + emit fallbackItemExtentChanged(); + scheduleSync(); + } +} + +void TaskOverflowController::setSpacing(qreal spacing) +{ + spacing = std::isfinite(spacing) ? std::max(0, spacing) : 0; + if (!qFuzzyCompare(m_spacing + 1, spacing + 1)) { + m_spacing = spacing; + emit spacingChanged(); + scheduleSync(); + } +} + +void TaskOverflowController::setOverflowSidePadding(qreal padding) +{ + padding = std::isfinite(padding) ? std::max(0, padding) : 0; + if (qFuzzyCompare(m_overflowSidePadding + 1, padding + 1)) + return; + + m_overflowSidePadding = padding; + emit overflowSidePaddingChanged(); + scheduleSync(); +} + +void TaskOverflowController::setVisualModel(QObject *visualModel) +{ + if (m_visualModel == visualModel) + return; + + if (m_visualModel) + disconnect(m_visualModel, nullptr, this, nullptr); + + m_visualModel = visualModel; + if (m_visualModel) + connect(m_visualModel, &QObject::destroyed, this, [this] { + m_visualModel = nullptr; + scheduleSync(); + }); + + scheduleSync(); +} + +void TaskOverflowController::setDataModel(QAbstractItemModel *dataModel) +{ + if (m_dataModel == dataModel) + return; + + disconnectDataModelSignals(); + m_dataModel = dataModel; + cacheRoles(); + m_entryCache.clear(); + m_recentHistory.clear(); + m_pendingActivity.clear(); + captureActivity(true); + connectDataModelSignals(); + scheduleSync(); +} + +void TaskOverflowController::scheduleSync() +{ + if (m_syncPending) + return; + + m_syncPending = true; + QTimer::singleShot(0, this, [this] { + m_syncPending = false; + sync(); + }); +} + +void TaskOverflowController::beginDrag() +{ + if (m_dragInProgress) + return; + m_dragInProgress = true; + emit dragInProgressChanged(); + m_dragCancelled = false; + // Install after QDrag starts its event loop so that Escape is observed + // before the native drag filter consumes it and clears the mouse state. + QTimer::singleShot(0, this, [this] { + if (m_dragInProgress) + QCoreApplication::instance()->installEventFilter(this); + }); +} + +bool TaskOverflowController::eventFilter(QObject *watched, QEvent *event) +{ + if (event->type() == QEvent::KeyPress && static_cast(event)->key() == Qt::Key_Escape) + m_dragCancelled = true; + return QObject::eventFilter(watched, event); +} + +bool TaskOverflowController::finishDrag(int dropAction) +{ + if (!m_dragInProgress) + return false; + m_dragInProgress = false; + QCoreApplication::instance()->removeEventFilter(this); + emit dragInProgressChanged(); + if (m_syncDeferred) { + m_syncDeferred = false; + scheduleSync(); + } + // IgnoreAction represents both dropping outside and cancelling with Escape. + return dropAction == Qt::IgnoreAction && !m_dragCancelled + && !(QGuiApplication::mouseButtons() & Qt::LeftButton); +} + +void TaskOverflowController::setLayout(bool showRecent, qreal trailingExtent, qreal buttonOffset, + qreal buttonExtent, qreal separatorPadding) +{ + if (m_showRecent == showRecent && m_trailingExtent == trailingExtent + && m_buttonOffset == buttonOffset && m_buttonExtent == buttonExtent + && m_separatorPadding == separatorPadding) + return; + m_showRecent = showRecent; + m_trailingExtent = trailingExtent; + m_buttonOffset = buttonOffset; + m_buttonExtent = buttonExtent; + m_separatorPadding = separatorPadding; + emit layoutChanged(); +} + +void TaskOverflowController::sync() +{ + // A native drag runs a nested event loop. Keep its source delegate alive + // until it returns, even if a window opens/closes or the dock is resized. + if (m_dragInProgress) { + m_syncDeferred = true; + return; + } + const int totalCount = modelCount(); + const qreal fullExtent = totalCount > 0 + ? totalCount * m_fallbackItemExtent + (totalCount - 1) * m_spacing : 0; + if (!syncEnabledState(totalCount, fullExtent)) { + setLayout(false, 0, 0, 0, 0); + setVisibleItemCount(totalCount); + setPopupItems({}); + return; + } + + const qreal available = std::max(0, m_availableExtent); + const qreal pairExtent = 2 * (m_fallbackItemExtent + m_overflowSidePadding) + m_spacing; + const bool showRecent = pairExtent <= available; + const qreal buttonExtent = std::min(m_fallbackItemExtent, available); + const qreal padding = std::min(m_overflowSidePadding, (available - buttonExtent) / 2); + const qreal groupExtent = showRecent ? pairExtent : buttonExtent + 2 * padding; + const qreal stride = m_fallbackItemExtent + m_spacing; + const int visibleCount = std::clamp(int(std::floor((available - groupExtent) / stride)), 0, totalCount - 1); + const qreal buttonOffset = visibleCount * stride + padding + (showRecent ? stride : 0); + + QVariantList overflowItems; + overflowItems.reserve(totalCount - visibleCount); + for (int index = visibleCount; index < totalCount; ++index) { + const auto itemData = entryAt(index); + if (!itemData.isEmpty()) + overflowItems.append(itemData); + } + setLayout(showRecent, groupExtent, buttonOffset, buttonExtent, padding); + setVisibleItemCount(visibleCount); + setPopupItems(overflowItems); +} + +void TaskOverflowController::onDataModelDestroyed() +{ + m_dataModel = nullptr; + m_entryCache.clear(); + m_activity.clear(); + m_activeItems.clear(); + m_runningItems.clear(); + scheduleSync(); +} + +int TaskOverflowController::modelCount() const +{ + if (m_visualModel) { + const auto countValue = m_visualModel->property("count"); + if (countValue.isValid()) + return countValue.toInt(); + } + + return m_dataModel ? m_dataModel->rowCount() : 0; +} + +QModelIndex TaskOverflowController::modelIndexAt(int visualIndex) const +{ + if (visualIndex < 0) + return {}; + + if (m_visualModel) { + QVariant modelIndexValue; + const bool ok = QMetaObject::invokeMethod(m_visualModel, + "sourceIndexAt", + Q_RETURN_ARG(QVariant, modelIndexValue), + Q_ARG(QVariant, visualIndex)); + if (ok) { + const auto index = modelIndexValue.value(); + if (index.isValid()) + return index; + } + } + + if (m_dataModel && visualIndex < m_dataModel->rowCount()) + return m_dataModel->index(visualIndex, 0); + + return {}; +} + +QVariant TaskOverflowController::dataForRole(const QModelIndex &index, const QByteArray &roleName) const +{ + if (!m_dataModel || !index.isValid()) + return {}; + + const auto role = m_roles.value(roleName, -1); + if (role < 0) + return {}; + + return m_dataModel->data(index, role); +} + +QVariantMap TaskOverflowController::entryAt(int visualIndex) +{ + const auto index = modelIndexAt(visualIndex); + if (!index.isValid()) + return {}; + + auto cached = m_entryCache.constFind(index); + if (cached == m_entryCache.cend()) { + auto iconName = dataForRole(index, MODEL_ICONNAME).toString(); + if (iconName.isEmpty()) + iconName = dataForRole(index, MODEL_WINICON).toString(); + if (iconName.isEmpty()) + iconName = DEFAULT_APP_ICONNAME; + + QVariantMap values { + { QStringLiteral("active"), dataForRole(index, MODEL_ACTIVE).toBool() }, + { QStringLiteral("attention"), dataForRole(index, MODEL_ATTENTION).toBool() }, + { QStringLiteral("docked"), dataForRole(index, MODEL_DOCKED).toBool() }, + { QStringLiteral("itemId"), dataForRole(index, MODEL_ITEMID).toString() }, + { QStringLiteral("name"), dataForRole(index, MODEL_NAME).toString() }, + { QStringLiteral("title"), dataForRole(index, MODEL_TITLE).toString() }, + { QStringLiteral("iconName"), iconName }, + { QStringLiteral("menus"), dataForRole(index, MODEL_MENUS).toString() }, + { QStringLiteral("windows"), dataForRole(index, MODEL_WINDOWS).toStringList() }, + { QStringLiteral("folderType"), dataForRole(index, MODEL_FOLDERTYPE).toString() }, + { QStringLiteral("applicationFolderContents"), dataForRole(index, MODEL_APPLICATIONFOLDERCONTENTS).toList() }, + { QStringLiteral("folderPresentation"), dataForRole(index, MODEL_FOLDERPRESENTATION) }, + }; + cached = m_entryCache.insert(index, values); + } + auto result = *cached; + result.insert(QStringLiteral("visualIndex"), visualIndex); + result.insert(QStringLiteral("modelIndex"), QVariant::fromValue(index)); + return result; +} + +bool TaskOverflowController::syncEnabledState(int totalCount, qreal fullExtent) +{ + if (!m_enabled || totalCount <= 0 || !std::isfinite(m_availableExtent)) { + m_latched = false; + return false; + } + + // Enter overflow as soon as the content exceeds the available boundary. + // Keep hysteresis only when leaving overflow to avoid rapid toggling near + // the threshold without allowing visible items to overlap first. + const qreal startThreshold = m_availableExtent; + const qreal stopThreshold = std::max(0, m_availableExtent - OverflowReleaseHysteresis); + const bool nextLatched = m_latched + ? fullExtent > stopThreshold + : (m_minimumReached && fullExtent > startThreshold); + m_latched = nextLatched; + return m_latched; +} + +void TaskOverflowController::connectDataModelSignals() +{ + if (!m_dataModel) + return; + + const auto structureChanged = [this] { + m_entryCache.clear(); + captureActivity(); + scheduleSync(); + }; + connect(m_dataModel, &QAbstractItemModel::dataChanged, this, + [this](const QModelIndex &first, const QModelIndex &last, const QList &roles) { + const auto changed = [this, &roles](const QByteArray &name) { + return roles.isEmpty() || roles.contains(m_roles.value(name, -1)); + }; + if (!roles.isEmpty() && std::none_of(roles.cbegin(), roles.cend(), [this](int role) { + return m_entryRoles.contains(role); + })) + return; + for (int row = first.row(); row <= last.row(); ++row) + m_entryCache.remove(m_dataModel->index(row, 0)); + if (changed(MODEL_ACTIVE) || changed(MODEL_WINDOWS) || changed(MODEL_ITEMID)) + captureActivity(false, first.row(), last.row()); + scheduleSync(); + }); + connect(m_dataModel, &QAbstractItemModel::rowsInserted, this, structureChanged); + connect(m_dataModel, &QAbstractItemModel::rowsMoved, this, &TaskOverflowController::scheduleSync); + connect(m_dataModel, &QAbstractItemModel::rowsRemoved, this, structureChanged); + connect(m_dataModel, &QAbstractItemModel::modelReset, this, [this, structureChanged] { + cacheRoles(); + structureChanged(); + }); + connect(m_dataModel, &QAbstractItemModel::layoutChanged, this, &TaskOverflowController::scheduleSync); + connect(m_dataModel, &QObject::destroyed, this, &TaskOverflowController::onDataModelDestroyed); +} + +void TaskOverflowController::disconnectDataModelSignals() +{ + if (m_dataModel) + disconnect(m_dataModel, nullptr, this, nullptr); +} + +void TaskOverflowController::setVisibleItemCount(int visibleItemCount) +{ + if (m_visibleItemCount == visibleItemCount) + return; + + m_visibleItemCount = visibleItemCount; + emit visibleItemCountChanged(); +} + +void TaskOverflowController::setPopupItems(const QVariantList &items) +{ + syncRecentItem(items); + if (m_popupItems == items) + return; + + m_popupItems = items; + m_popupModel.setItems(items); + emit popupItemsChanged(); +} + +void TaskOverflowController::cacheRoles() +{ + m_roles.clear(); + m_entryRoles.clear(); + if (!m_dataModel) + return; + const auto roles = m_dataModel->roleNames(); + for (const QByteArray name : {MODEL_ICONNAME, MODEL_WINICON, MODEL_ACTIVE, MODEL_ATTENTION, + MODEL_DOCKED, MODEL_ITEMID, MODEL_NAME, MODEL_TITLE, MODEL_MENUS, MODEL_WINDOWS, + MODEL_FOLDERTYPE, MODEL_APPLICATIONFOLDERCONTENTS, MODEL_FOLDERPRESENTATION}) { + const int role = roles.key(name, -1); + if (role >= 0) { + m_roles.insert(name, role); + m_entryRoles.insert(role); + } + } +} + +void TaskOverflowController::captureActivity(bool baseline, int first, int last) +{ + if (last < 0) { + m_activity.clear(); + last = m_dataModel ? m_dataModel->rowCount() - 1 : -1; + } + QStringList changedIds; + for (int row = first; m_dataModel && row <= last; ++row) { + const auto index = m_dataModel->index(row, 0); + Activity state {dataForRole(index, MODEL_ITEMID).toString(), + dataForRole(index, MODEL_ACTIVE).toBool(), + !dataForRole(index, MODEL_WINDOWS).toStringList().isEmpty()}; + m_activity.insert(index, state); + if (!state.id.isEmpty() && !changedIds.contains(state.id)) + changedIds.append(state.id); + } + // Aggregate cached rows because split windows can share an application ID. + QSet activeItems; + QSet runningItems; + for (const auto &state : std::as_const(m_activity)) { + if (state.active) + activeItems.insert(state.id); + if (state.running) + runningItems.insert(state.id); + } + if (!baseline) { + for (const auto &id : changedIds) { + if ((activeItems.contains(id) && !m_activeItems.contains(id)) + || (runningItems.contains(id) && !m_runningItems.contains(id))) { + m_pendingActivity.removeAll(id); + m_pendingActivity.append(id); + } + } + } + m_activeItems = activeItems; + m_runningItems = runningItems; +} + +void TaskOverflowController::syncRecentItem(const QVariantList &items) +{ + const bool recentClosed = !m_recentItem.value(QStringLiteral("windows")).toStringList().isEmpty() + && !m_runningItems.contains(m_recentItem.value(QStringLiteral("itemId")).toString()); + QHash applications; + for (const auto &item : items) { + const auto entry = item.toMap(); + const auto id = entry.value(QStringLiteral("itemId")).toString(); + if (id.isEmpty()) + continue; + // In window-split mode prefer the active, then a running window of an app. + const auto previous = applications.value(id); + if (previous.isEmpty() || entry.value(QStringLiteral("active")).toBool() + || (!previous.value(QStringLiteral("active")).toBool() + && previous.value(QStringLiteral("windows")).toStringList().isEmpty())) + applications.insert(id, entry); + } + const auto eligible = [&applications](const QString &id) { + const auto entry = applications.value(id); + return !entry.isEmpty() && (entry.value(QStringLiteral("docked")).toBool() + || !entry.value(QStringLiteral("windows")).toStringList().isEmpty()); + }; + for (auto it = m_recentHistory.begin(); it != m_recentHistory.end();) { + if (!eligible(*it)) + it = m_recentHistory.erase(it); + else + ++it; + } + // A background app can close while another overflow app remains active, + // without producing a new active-role transition for that fallback app. + if (recentClosed) { + for (const auto &item : items) { + const auto entry = item.toMap(); + if (entry.value(QStringLiteral("active")).toBool()) + m_pendingActivity.append(entry.value(QStringLiteral("itemId")).toString()); + } + } + for (const auto &id : std::as_const(m_pendingActivity)) { + if (!eligible(id)) + continue; + m_recentHistory.removeAll(id); + m_recentHistory.append(id); + if (m_recentHistory.size() > 30) + m_recentHistory.erase(m_recentHistory.begin(), m_recentHistory.begin() + 10); + } + m_pendingActivity.clear(); + + QVariantMap recent; + // Closing the current app falls back to the next running record, even if + // the closed app is pinned and therefore still present in the history. + for (auto it = m_recentHistory.crbegin(); it != m_recentHistory.crend(); ++it) { + const auto entry = applications.value(*it); + if (!entry.value(QStringLiteral("windows")).toStringList().isEmpty()) { + recent = entry; + break; + } + } + if (recent.isEmpty() && !m_recentHistory.isEmpty()) { + const auto previousId = m_recentItem.value(QStringLiteral("itemId")).toString(); + if (!recentClosed && m_recentHistory.contains(previousId)) { + recent = applications.value(previousId); + } else { + for (auto it = m_recentHistory.crbegin(); it != m_recentHistory.crend(); ++it) { + if (*it != previousId) { + recent = applications.value(*it); + break; + } + } + } + if (recent.isEmpty()) + recent = applications.value(m_recentHistory.last()); + } + if (recent.isEmpty() && !items.isEmpty()) + recent = items.last().toMap(); + if (m_recentItem != recent) { + m_recentItem = recent; + emit recentItemChanged(); + } +} + +} diff --git a/panels/dock/taskmanager/taskoverflowcontroller.h b/panels/dock/taskmanager/taskoverflowcontroller.h new file mode 100644 index 000000000..53b28d564 --- /dev/null +++ b/panels/dock/taskmanager/taskoverflowcontroller.h @@ -0,0 +1,200 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace dock { + +class TaskOverflowModel : public QAbstractListModel +{ + Q_OBJECT +public: + using QAbstractListModel::QAbstractListModel; + int rowCount(const QModelIndex &parent = {}) const override; + QVariant data(const QModelIndex &index, int role) const override; + QHash roleNames() const override; + void setItems(const QVariantList &items); + +private: + struct Entry { + QPersistentModelIndex source; + QVariantMap values; + }; + QList m_entries; +}; + +class TaskOverflowController : public QObject +{ + Q_OBJECT + Q_PROPERTY(bool enabled READ isEnabled WRITE setEnabled NOTIFY enabledChanged) + Q_PROPERTY(qreal availableExtent READ availableExtent WRITE setAvailableExtent) + Q_PROPERTY(bool minimumReached READ minimumReached WRITE setMinimumReached) + Q_PROPERTY(qreal fallbackItemExtent READ fallbackItemExtent WRITE setFallbackItemExtent NOTIFY fallbackItemExtentChanged) + Q_PROPERTY(qreal spacing READ spacing WRITE setSpacing NOTIFY spacingChanged) + Q_PROPERTY(qreal overflowSidePadding READ overflowSidePadding WRITE setOverflowSidePadding NOTIFY overflowSidePaddingChanged) + Q_PROPERTY(QObject *visualModel READ visualModel WRITE setVisualModel) + Q_PROPERTY(QAbstractItemModel *dataModel READ dataModel WRITE setDataModel) + Q_PROPERTY(int visibleItemCount READ visibleItemCount NOTIFY visibleItemCountChanged) + Q_PROPERTY(QAbstractItemModel *popupModel READ popupModel CONSTANT) + Q_PROPERTY(int popupCount READ popupCount NOTIFY popupItemsChanged) + Q_PROPERTY(QVariantMap primaryItem READ primaryItem NOTIFY popupItemsChanged) + Q_PROPERTY(QVariantMap recentItem READ recentItem NOTIFY recentItemChanged) + Q_PROPERTY(bool showRecent READ showRecent NOTIFY layoutChanged) + Q_PROPERTY(qreal trailingExtent READ trailingExtent NOTIFY layoutChanged) + Q_PROPERTY(qreal buttonOffset READ buttonOffset NOTIFY layoutChanged) + Q_PROPERTY(qreal buttonExtent READ buttonExtent NOTIFY layoutChanged) + Q_PROPERTY(qreal separatorPadding READ separatorPadding NOTIFY layoutChanged) + Q_PROPERTY(bool dragInProgress READ dragInProgress NOTIFY dragInProgressChanged) + +public: + explicit TaskOverflowController(QObject *parent = nullptr); + + bool isEnabled() const + { + return m_enabled; + } + void setEnabled(bool enabled); + + qreal availableExtent() const + { + return m_availableExtent; + } + void setAvailableExtent(qreal availableExtent); + + bool minimumReached() const + { + return m_minimumReached; + } + void setMinimumReached(bool minimumReached); + + qreal fallbackItemExtent() const + { + return m_fallbackItemExtent; + } + void setFallbackItemExtent(qreal fallbackItemExtent); + + qreal spacing() const + { + return m_spacing; + } + void setSpacing(qreal spacing); + + qreal overflowSidePadding() const { return m_overflowSidePadding; } + void setOverflowSidePadding(qreal padding); + + QObject *visualModel() const + { + return m_visualModel; + } + void setVisualModel(QObject *visualModel); + + QAbstractItemModel *dataModel() const + { + return m_dataModel; + } + void setDataModel(QAbstractItemModel *dataModel); + + int visibleItemCount() const + { + return m_visibleItemCount; + } + QVariantList popupItems() const + { + return m_popupItems; + } + QVariantMap recentItem() const { return m_recentItem; } + QAbstractItemModel *popupModel() { return &m_popupModel; } + int popupCount() const { return m_popupItems.size(); } + QVariantMap primaryItem() const { return m_popupItems.value(0).toMap(); } + bool showRecent() const { return m_showRecent; } + qreal trailingExtent() const { return m_trailingExtent; } + qreal buttonOffset() const { return m_buttonOffset; } + qreal buttonExtent() const { return m_buttonExtent; } + qreal separatorPadding() const { return m_separatorPadding; } + bool dragInProgress() const { return m_dragInProgress; } + + Q_INVOKABLE void scheduleSync(); + Q_INVOKABLE void beginDrag(); + Q_INVOKABLE bool finishDrag(int dropAction); + +Q_SIGNALS: + void enabledChanged(); + void fallbackItemExtentChanged(); + void spacingChanged(); + void overflowSidePaddingChanged(); + void visibleItemCountChanged(); + void popupItemsChanged(); + void recentItemChanged(); + void layoutChanged(); + void dragInProgressChanged(); + +private Q_SLOTS: + void onDataModelDestroyed(); + +private: + bool eventFilter(QObject *watched, QEvent *event) override; + void sync(); + int modelCount() const; + QModelIndex modelIndexAt(int visualIndex) const; + QVariant dataForRole(const QModelIndex &index, const QByteArray &roleName) const; + QVariantMap entryAt(int visualIndex); + bool syncEnabledState(int totalCount, qreal fullExtent); + + void connectDataModelSignals(); + void disconnectDataModelSignals(); + + void setVisibleItemCount(int visibleItemCount); + void setPopupItems(const QVariantList &items); + void cacheRoles(); + void captureActivity(bool baseline = false, int first = 0, int last = -1); + void syncRecentItem(const QVariantList &items); + void setLayout(bool showRecent, qreal trailingExtent, qreal buttonOffset, + qreal buttonExtent, qreal separatorPadding); + + bool m_enabled = true; + qreal m_availableExtent = 0; + bool m_minimumReached = false; + qreal m_fallbackItemExtent = 1; + qreal m_spacing = 0; + qreal m_overflowSidePadding = 0; + QPointer m_visualModel; + QPointer m_dataModel; + bool m_latched = false; + int m_visibleItemCount = 0; + QVariantList m_popupItems; + TaskOverflowModel m_popupModel; + QHash m_roles; + QSet m_entryRoles; + QHash m_entryCache; + struct Activity { + QString id; + bool active; + bool running; + }; + QHash m_activity; + QVariantMap m_recentItem; + QStringList m_recentHistory; // Oldest first, one record per application. + QStringList m_pendingActivity; + QSet m_activeItems; + QSet m_runningItems; + bool m_syncPending = false; + bool m_dragInProgress = false; + bool m_dragCancelled = false; + bool m_syncDeferred = false; + bool m_showRecent = false; + qreal m_trailingExtent = 0; + qreal m_buttonOffset = 0; + qreal m_buttonExtent = 0; + qreal m_separatorPadding = 0; +}; + +} diff --git a/panels/dock/taskmanager/textcalculator.cpp b/panels/dock/taskmanager/textcalculator.cpp index b3f5c6e4a..0516141b9 100644 --- a/panels/dock/taskmanager/textcalculator.cpp +++ b/panels/dock/taskmanager/textcalculator.cpp @@ -8,6 +8,7 @@ #include #include #include +#include namespace dock { @@ -169,7 +170,19 @@ void TextCalculator::scheduleCalculation() resetCalculatedWidths(); return; } - calculateOptimalTextWidth(); + + if (m_calculationPending) + return; + + m_calculationPending = true; + QTimer::singleShot(0, this, [this] { + m_calculationPending = false; + if (!m_enabled || !m_dataModel) { + resetCalculatedWidths(); + return; + } + calculateOptimalTextWidth(); + }); } void TextCalculator::resetCalculatedWidths() @@ -267,12 +280,9 @@ void TextCalculator::calculateOptimalTextWidth() const int appCount = titles.size(); if (appCount <= 0 || m_remainingSpace <= 0) { - if (m_optimalSingleTextWidth != 0.0) { + if (!qFuzzyIsNull(m_optimalSingleTextWidth) || !qFuzzyIsNull(m_totalWidth)) { qCDebug(textCalculatorLog) << "Setting optimal width to 0 (no apps or no space)"; - m_optimalSingleTextWidth = 0.0; - m_totalWidth = 0; - emit optimalSingleTextWidthChanged(); - emit totalWidthChanged(); + resetCalculatedWidths(); } return; } @@ -317,15 +327,20 @@ void TextCalculator::calculateOptimalTextWidth() } } - // Update results - if (!qFuzzyCompare(m_optimalSingleTextWidth, newOptimalWidth)) { + // Publish a single, internally consistent layout result. totalWidth can change + // even when the selected character count (and therefore optimal width) does not. + const bool optimalWidthChanged = !qFuzzyCompare(m_optimalSingleTextWidth, newOptimalWidth); + const bool totalWidthDidChange = !qFuzzyCompare(m_totalWidth, newTotalWidth); + if (optimalWidthChanged || totalWidthDidChange) { qCDebug(textCalculatorLog) << "Optimal text width changed from" << m_optimalSingleTextWidth << "to" << newOptimalWidth << "App count:" << appCount << "Remaining space:" << m_remainingSpace << "Total required:" << newTotalWidth << "Char count:" << charCount << "spacing:" << m_spacing; m_optimalSingleTextWidth = newOptimalWidth; - emit optimalSingleTextWidthChanged(); m_totalWidth = newTotalWidth; - emit totalWidthChanged(); + if (optimalWidthChanged) + emit optimalSingleTextWidthChanged(); + if (totalWidthDidChange) + emit totalWidthChanged(); } } diff --git a/panels/dock/taskmanager/textcalculator.h b/panels/dock/taskmanager/textcalculator.h index 6152b3b1d..665e44941 100644 --- a/panels/dock/taskmanager/textcalculator.h +++ b/panels/dock/taskmanager/textcalculator.h @@ -184,6 +184,7 @@ private slots: QPointer m_dataModel; qreal m_remainingSpace; bool m_enabled; + bool m_calculationPending = false; QHash m_baselineWidthCache; // Cache for baseline widths of different character counts }; diff --git a/panels/dock/taskmanager/translations/org.deepin.ds.dock.taskmanager.ts b/panels/dock/taskmanager/translations/org.deepin.ds.dock.taskmanager.ts index e089821e3..8a88e4694 100644 --- a/panels/dock/taskmanager/translations/org.deepin.ds.dock.taskmanager.ts +++ b/panels/dock/taskmanager/translations/org.deepin.ds.dock.taskmanager.ts @@ -111,4 +111,11 @@ + + TaskOverflowButton + + Overflow area + + + diff --git a/panels/dock/taskmanager/translations/org.deepin.ds.dock.taskmanager_zh_CN.ts b/panels/dock/taskmanager/translations/org.deepin.ds.dock.taskmanager_zh_CN.ts index ba2603284..7c6fec9ce 100644 --- a/panels/dock/taskmanager/translations/org.deepin.ds.dock.taskmanager_zh_CN.ts +++ b/panels/dock/taskmanager/translations/org.deepin.ds.dock.taskmanager_zh_CN.ts @@ -109,4 +109,11 @@ 移除驻留 + + TaskOverflowButton + + Overflow area + 溢出区域 + + \ No newline at end of file diff --git a/panels/dock/taskmanager/translations/org.deepin.ds.dock.taskmanager_zh_HK.ts b/panels/dock/taskmanager/translations/org.deepin.ds.dock.taskmanager_zh_HK.ts index c6225d7df..935696265 100644 --- a/panels/dock/taskmanager/translations/org.deepin.ds.dock.taskmanager_zh_HK.ts +++ b/panels/dock/taskmanager/translations/org.deepin.ds.dock.taskmanager_zh_HK.ts @@ -109,4 +109,11 @@ 移除駐留 + + TaskOverflowButton + + Overflow area + 溢出區域 + + diff --git a/panels/dock/taskmanager/translations/org.deepin.ds.dock.taskmanager_zh_TW.ts b/panels/dock/taskmanager/translations/org.deepin.ds.dock.taskmanager_zh_TW.ts index 7f02bfa8d..a02126878 100644 --- a/panels/dock/taskmanager/translations/org.deepin.ds.dock.taskmanager_zh_TW.ts +++ b/panels/dock/taskmanager/translations/org.deepin.ds.dock.taskmanager_zh_TW.ts @@ -109,4 +109,11 @@ 移除駐留 + + TaskOverflowButton + + Overflow area + 溢出區域 + +