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) + } +}