From 91315e1a9ce71d00f01111d29df2ffaa222cf548 Mon Sep 17 00:00:00 2001 From: Wu JiangYu Date: Tue, 27 Oct 2026 13:18:36 +0800 Subject: [PATCH] feat: add popup close policy and dock screen tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Add closeOnInactive property to PanelPopup to allow popups to stay open when the window becomes inactive 2. Guard all internal auto-close paths (focus loss, grab timeout, ungrab, interaction end) behind closeOnInactive 3. Track the panel's QWindow screen in DockPositioner and expose availableGeometry with change notifications 4. Reconnect screen signals on screenChanged to follow panels moved across screens 5. Fix popup parent fallback from undefined to null for cleaner QML binding semantics 6. Add QML lifecycle smoke tests for PanelPopup covering open/close, shared window switching, and focus loss scenarios 7. Wire qmltestrunner into CTest with offscreen platform for headless popup policy testing Log: Added closeOnInactive option for panel popups and availableGeometry tracking in DockPositioner Influence: 1. Test popup open/close toggle behavior with repeated clicks 2. Verify popups remain open when closeOnInactive is false and focus is lost 3. Verify popups close automatically when closeOnInactive is true and focus is lost via activeChanged, grab timeout, or ungrab 4. Test switching popups sharing the same popup window keeps the new owner open 5. Verify stale close calls from previous popup owners do not close the new owner 6. Test DockPositioner availableGeometry updates when moving panel across screens 7. Verify availableGeometryChanged signal fires on screen change and available geometry change 8. Run QML tests via ctest to validate popup lifecycle policies feat: 添加弹窗关闭策略和任务栏屏幕跟踪 1. 为 PanelPopup 添加 closeOnInactive 属性,允许弹窗在窗口失活时保持打开 2. 将内部所有自动关闭路径(焦点丢失、抓取超时、取消抓取、交互结束)置于 closeOnInactive 控制之下 3. 在 DockPositioner 中跟踪面板 QWindow 所在屏幕,并暴露带变更通知 的 availableGeometry 4. 在 screenChanged 时重新连接屏幕信号,以跟随跨屏移动的面板 5. 修复弹窗 parent 回退值从 undefined 改为 null,使 QML 绑定语义更清晰 6. 新增 PanelPopup 的 QML 生命周期冒烟测试,覆盖打开关闭、共享窗口切换和 焦点丢失场景 7. 将 qmltestrunner 接入 CTest,使用离屏平台进行无头弹窗策略测试 Log: 新增面板弹窗 closeOnInactive 选项和 DockPositioner 的 availableGeometry 跟踪 Influence: 1. 测试重复点击时的弹窗打开关闭切换行为 2. 验证 closeOnInactive 为 false 时焦点丢失后弹窗保持打开 3. 验证 closeOnInactive 为 true 时通过 activeChanged、抓取超时或取消抓取 导致焦点丢失后弹窗自动关闭 4. 测试共享同一弹窗窗口的弹窗切换时新所有者保持打开 5. 验证旧弹窗所有者的过期关闭调用不会关闭新所有者 6. 测试面板跨屏移动时 DockPositioner 的 availableGeometry 更新 7. 验证屏幕切换和可用几何变化时触发 availableGeometryChanged 信号 8. 通过 ctest 运行 QML 测试以验证弹窗生命周期策略 PMS: TASK-397247 --- frame/qml/PanelPopup.qml | 18 +++- panels/dock/dockpositioner.cpp | 22 +++++ panels/dock/dockpositioner.h | 7 +- tests/CMakeLists.txt | 16 ++++ tests/frame/qml/tst_panelpopup.qml | 135 +++++++++++++++++++++++++++++ 5 files changed, 193 insertions(+), 5 deletions(-) create mode 100644 tests/frame/qml/tst_panelpopup.qml diff --git a/frame/qml/PanelPopup.qml b/frame/qml/PanelPopup.qml index 33b132533..aed4e93da 100644 --- a/frame/qml/PanelPopup.qml +++ b/frame/qml/PanelPopup.qml @@ -14,6 +14,7 @@ Item { property var popupWindow: Panel.popupWindow property int popupX: 0 property int popupY: 0 + property bool closeOnInactive: true property bool readyBinding: false property bool openPending: false property bool contentReady: true @@ -112,6 +113,15 @@ Item { popupWindow.currentItem = null } + onCloseOnInactiveChanged: { + if (!closeOnInactive) return + Qt.callLater(function() { + if (control.closeOnInactive && popupWindow && readyBinding + && popupWindow.currentItem === control && !popupWindow.active) + control.close() + }) + } + onContentReadyChanged: { if (!contentReady || !contentOpenPending) return @@ -150,7 +160,7 @@ Item { if (!popupWindow || !readyBinding || popupWindow.currentItem !== control || !popup.visible) { return } - if (!popupWindow.active) { + if (control.closeOnInactive && !popupWindow.active) { control.close() } } @@ -176,7 +186,7 @@ Item { return } Qt.callLater(function() { - if (!popupWindow + if (!control.closeOnInactive || !popupWindow || popupWindow.active) { return } @@ -224,7 +234,7 @@ Item { || control.grabInactivePending) { return } - if (!popupWindow.active) { + if (control.closeOnInactive && !popupWindow.active) { control.close() } }) @@ -236,7 +246,7 @@ Item { visible: readyBinding width: control.width height: control.height - parent: popupWindow ? popupWindow.contentItem : undefined + parent: popupWindow ? popupWindow.contentItem : null } Component.onDestruction: { if (popup.visible) diff --git a/panels/dock/dockpositioner.cpp b/panels/dock/dockpositioner.cpp index e1b8bc5d4..fccc5a02e 100644 --- a/panels/dock/dockpositioner.cpp +++ b/panels/dock/dockpositioner.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include DS_USE_NAMESPACE @@ -34,6 +35,10 @@ DockPositioner::DockPositioner(DPanel *panel, QObject *parent) connect(m_positionTimer, &QTimer::timeout, this, &DockPositioner::updatePosition); Q_ASSERT(m_panel); + if (auto window = m_panel->window()) { + connect(window, &QWindow::screenChanged, this, &DockPositioner::setScreen); + setScreen(window->screen()); + } connect(m_panel, SIGNAL(positionChanged(Position)), this, SLOT(update())); connect(m_panel, SIGNAL(geometryChanged(QRect)), this, SLOT(update())); // The popup may be opened in the same event-loop turn as its anchor is @@ -78,6 +83,23 @@ int DockPositioner::y() const return m_y; } +void DockPositioner::setScreen(QScreen *screen) +{ + if (m_screen == screen) return; + if (m_screen) disconnect(m_screen, nullptr, this, nullptr); + m_screen = screen; + if (m_screen) { + connect(m_screen, &QScreen::availableGeometryChanged, this, &DockPositioner::availableGeometryChanged); + connect(m_screen, &QObject::destroyed, this, &DockPositioner::availableGeometryChanged); + } + emit availableGeometryChanged(); +} + +QRect DockPositioner::availableGeometry() const +{ + return m_screen ? m_screen->availableGeometry() : QRect(); +} + QRect DockPositioner::dockGeometry() const { return m_panel ? m_panel->property("geometry").toRect() : QRect(); diff --git a/panels/dock/dockpositioner.h b/panels/dock/dockpositioner.h index b60ed1521..07b0693b8 100644 --- a/panels/dock/dockpositioner.h +++ b/panels/dock/dockpositioner.h @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2024 UnionTech Software Technology Co., Ltd. +// SPDX-FileCopyrightText: 2024-2026 UnionTech Software Technology Co., Ltd. // // SPDX-License-Identifier: GPL-3.0-or-later @@ -23,6 +23,7 @@ class DockPositioner : public QObject Q_PROPERTY(QRect bounding READ bounding WRITE setBounding NOTIFY boundingChanged FINAL) Q_PROPERTY(int x READ x NOTIFY xChanged FINAL) Q_PROPERTY(int y READ y NOTIFY yChanged FINAL) + Q_PROPERTY(QRect availableGeometry READ availableGeometry NOTIFY availableGeometryChanged FINAL) QML_NAMED_ELEMENT(DockPositioner) QML_UNCREATABLE("DockPositioner is only available via attached properties.") QML_ATTACHED(DockPositioner) @@ -37,6 +38,7 @@ class DockPositioner : public QObject int x() const; int y() const; QRect dockGeometry() const; + QRect availableGeometry() const; Position dockPosition() const; void setX(int x); @@ -49,8 +51,11 @@ public slots: void boundingChanged(); void xChanged(); void yChanged(); + void availableGeometryChanged(); protected: + void setScreen(QScreen *screen); + QPointer m_screen; QPointer m_panel; QRect m_bounding {}; int m_x = 0; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b9093e579..76f015896 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -14,3 +14,19 @@ add_custom_target(coverage DEPENDS frame_coverage applets_coverage dock_coverage COMMENT "Run all dde-shell module tests and generate coverage reports" ) + +# QML lifecycle and folder component smoke tests. No desktop session or app +# manager is started; native window interactions are covered by manual testing. +find_program(QML_TEST_RUNNER NAMES qmltestrunner6 qmltestrunner + HINTS /usr/lib/qt${QT_VERSION_MAJOR}/bin) +if(QML_TEST_RUNNER) + foreach(_suite frame/qml) + string(REPLACE "/" "_" _name "${_suite}") + add_test(NAME ${_name} + COMMAND ${CMAKE_COMMAND} -E env + "QT_QPA_PLATFORM=offscreen" "QT_QUICK_BACKEND=software" + "LD_PRELOAD=$:$" + ${QML_TEST_RUNNER} -input "${CMAKE_CURRENT_SOURCE_DIR}/${_suite}" + -import "${CMAKE_BINARY_DIR}/plugins") + endforeach() +endif() diff --git a/tests/frame/qml/tst_panelpopup.qml b/tests/frame/qml/tst_panelpopup.qml new file mode 100644 index 000000000..65498eca3 --- /dev/null +++ b/tests/frame/qml/tst_panelpopup.qml @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: LGPL-3.0-or-later +import QtQuick +import QtTest +import "../../../frame/qml" as Shell + +TestCase { + name: "PanelPopupLifecycle" + when: windowShown + // Control focus explicitly: these tests exercise popup policy, not the WM. + QtObject { + id: sharedWindow + property var currentItem: null + property Item contentItem: Item {} + property real requestedWidth: 120 + property real requestedHeight: 80 + property real xOffset: 0 + property real yOffset: 0 + property bool visible: false + property bool active: false + property real opacity: 1 + property string title: "" + property bool x11GrabFocusTransition: false + signal updateGeometryFinished() + signal frameSwapped() + signal x11FocusOutByGrab() + signal x11FocusInByUngrab() + function requestUpdateGeometry() { updateGeometryFinished() } + function show() { visible = true } + function close() { visible = false; active = false } + function requestActivate() { active = true } + function update() { Qt.callLater(function() { sharedWindow.frameSwapped() }) } + } + Shell.PanelPopup { + id: first + popupWindow: sharedWindow + width: 120; height: 80 + grabInactiveTimeout: 20 + Rectangle { anchors.fill: parent; color: "red" } + } + Shell.PanelPopup { + id: second + popupWindow: sharedWindow + width: 140; height: 90 + } + function cleanup() { + first.close() + second.close() + first.closeOnInactive = true + second.closeOnInactive = true + first.revealAfterRender = false + wait(0) + } + function test_openCloseAndToggle() { + first.open() + tryCompare(sharedWindow, "visible", true) + compare(sharedWindow.opacity, 1) + compare(sharedWindow.currentItem, first) + first.open() // A second click closes immediately, without an animation. + compare(sharedWindow.currentItem, null) + compare(sharedWindow.visible, false) + compare(sharedWindow.opacity, 1) + } + function test_switchSharedWindow() { + first.open() + tryCompare(sharedWindow, "visible", true) + second.open() + tryCompare(sharedWindow, "currentItem", second) + tryCompare(sharedWindow, "visible", true) + compare(sharedWindow.opacity, 1) + // A stale close from the previous folder must not close the new owner. + first.close() + compare(sharedWindow.currentItem, second) + compare(sharedWindow.visible, true) + } + function test_revealAfterRender() { + first.revealAfterRender = true + first.open() + tryCompare(sharedWindow, "visible", true) + tryCompare(first, "revealPending", false) + compare(sharedWindow.opacity, 1) + compare(sharedWindow.active, true) + first.close() + compare(sharedWindow.visible, false) + compare(sharedWindow.currentItem, null) + } + function test_focusLoss_data() { + let cases = [] + for (const source of ["activeChanged", "grabTimeout", "ungrab"]) { + for (const close of [true, false]) + cases.push({tag: source + "-" + close, source: source, close: close}) + } + return cases + } + function test_focusLoss(data) { + first.closeOnInactive = data.close + first.open() + tryCompare(sharedWindow, "visible", true) + compare(sharedWindow.active, true) + if (data.source !== "activeChanged") sharedWindow.x11FocusOutByGrab() + sharedWindow.active = false + if (data.source === "ungrab") sharedWindow.x11FocusInByUngrab() + if (data.close) { + tryCompare(sharedWindow, "currentItem", null) + compare(sharedWindow.visible, false) + } else { + if (data.source === "grabTimeout") tryCompare(first, "grabInactivePending", false) + wait(30) // Process deferred focus callbacks before checking visibility. + compare(sharedWindow.currentItem, first) + compare(sharedWindow.visible, true) + } + } + function test_interactionEndsWhileInactive() { + first.closeOnInactive = false + first.open() + tryCompare(sharedWindow, "visible", true) + sharedWindow.active = false + wait(30) + compare(sharedWindow.visible, true) + first.closeOnInactive = true + tryCompare(sharedWindow, "currentItem", null) + compare(sharedWindow.visible, false) + } + function test_interactionEndsAfterFocusReturns() { + first.closeOnInactive = false + first.open() + tryCompare(sharedWindow, "visible", true) + sharedWindow.active = false + first.closeOnInactive = true + sharedWindow.active = true + wait(30) + compare(sharedWindow.currentItem, first) + compare(sharedWindow.visible, true) + } +}