From 34dcb2a661197a9a88089ef6d857b5e7026f6b1b Mon Sep 17 00:00:00 2001 From: guolin Date: Sun, 20 Sep 2026 16:34:01 +0800 Subject: [PATCH 1/3] feat(window-transition): add window transition protocol v1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the treeland-window-transition-unstable-v1 server protocol and its manager, associate client rects with xdg-activation tokens, and drive surface open/close transitions via WindowTransition.qml. Support activation requests arriving before the target surface is mapped. 新增 treeland-window-transition-unstable-v1 服务端协议及管理器, 将客户端矩形与 xdg-activation 令牌关联,通过 WindowTransition.qml 驱动窗口开关过渡动画,并支持目标表面映射前到达的激活请求。 Log: 添加窗口过渡协议v1及窗口开关动画支持 PMS: TASK-395857 Influence: 新增窗口过渡协议全局对象;SurfaceWrapper 增加过渡与待激活状态,激活与窗口开关动画行为有变化。 --- src/CMakeLists.txt | 4 + src/common/treelandlogging.cpp | 3 + src/common/treelandlogging.h | 3 + src/core/focuscontroller.cpp | 53 +- src/core/focuscontroller.h | 8 +- src/core/qml/Animations/WindowTransition.qml | 136 ++++++ src/core/qmlengine.cpp | 19 + src/core/qmlengine.h | 8 + src/interfaces/windowtransition.h | 56 +++ src/modules/CMakeLists.txt | 1 + .../activationmanagerinterfacev1.cpp | 70 +-- .../activation/activationmanagerinterfacev1.h | 11 +- src/modules/window-transition/CMakeLists.txt | 18 + .../windowtransitionmanagerinterfacev1.cpp | 458 ++++++++++++++++++ .../windowtransitionmanagerinterfacev1.h | 48 ++ src/surface/surfacewindowtransition.cpp | 178 +++++++ src/surface/surfacewindowtransition.h | 63 +++ src/surface/surfacewrapper.cpp | 213 +++++++- src/surface/surfacewrapper.h | 25 +- .../wayland-xdg-activation-v1/setup.cpp | 16 +- 20 files changed, 1346 insertions(+), 45 deletions(-) create mode 100644 src/core/qml/Animations/WindowTransition.qml create mode 100644 src/interfaces/windowtransition.h create mode 100644 src/modules/window-transition/CMakeLists.txt create mode 100644 src/modules/window-transition/windowtransitionmanagerinterfacev1.cpp create mode 100644 src/modules/window-transition/windowtransitionmanagerinterfacev1.h create mode 100644 src/surface/surfacewindowtransition.cpp create mode 100644 src/surface/surfacewindowtransition.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index c745f899b0..f91b3b56e8 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -187,6 +187,7 @@ qt_add_qml_module(libtreeland interfaces/multitaskviewinterface.h interfaces/plugininterface.h interfaces/proxyinterface.h + interfaces/windowtransition.h modules/resource/treelandremotesource.cpp modules/resource/treelandremotesource.h modules/resource/treelanddebugsocket.cpp @@ -217,6 +218,8 @@ qt_add_qml_module(libtreeland surface/surfaceproxy.h surface/surfacewrapper.cpp surface/surfacewrapper.h + surface/surfacewindowtransition.cpp + surface/surfacewindowtransition.h utils/cmdline.cpp utils/cmdline.h utils/propertymonitor.cpp @@ -269,6 +272,7 @@ qt_add_qml_module(libtreeland core/qml/WorkspaceProxy.qml core/qml/FpsDisplay.qml core/qml/Animations/GeometryAnimation.qml + core/qml/Animations/WindowTransition.qml core/qml/Animations/NewAnimation.qml core/qml/Animations/MinimizeAnimation.qml core/qml/Animations/ShowDesktopAnimation.qml diff --git a/src/common/treelandlogging.cpp b/src/common/treelandlogging.cpp index e60133ac2b..608e4c5566 100644 --- a/src/common/treelandlogging.cpp +++ b/src/common/treelandlogging.cpp @@ -74,6 +74,9 @@ Q_LOGGING_CATEGORY(lcTlXsettings, "treeland.xsettings") // Activation module Q_LOGGING_CATEGORY(lcTlActivation, "treeland.activation") +// Window transition module +Q_LOGGING_CATEGORY(lcTlWindowTransition, "treeland.window.transition") + // App ID resolver Q_LOGGING_CATEGORY(lcTlAppIdResolver, "treeland.appid.resolver", QtInfoMsg) diff --git a/src/common/treelandlogging.h b/src/common/treelandlogging.h index 54458f0270..ae5b2e8362 100644 --- a/src/common/treelandlogging.h +++ b/src/common/treelandlogging.h @@ -77,6 +77,9 @@ Q_DECLARE_LOGGING_CATEGORY(lcTlXsettings) // Activation module Q_DECLARE_LOGGING_CATEGORY(lcTlActivation) +// Window transition module +Q_DECLARE_LOGGING_CATEGORY(lcTlWindowTransition) + // App ID resolver Q_DECLARE_LOGGING_CATEGORY(lcTlAppIdResolver) diff --git a/src/core/focuscontroller.cpp b/src/core/focuscontroller.cpp index 73b95f0620..b82e7a4af9 100644 --- a/src/core/focuscontroller.cpp +++ b/src/core/focuscontroller.cpp @@ -291,6 +291,10 @@ void FocusController::updateSurfaceSeatInteraction(SurfaceWrapper *surface, WSea void FocusController::installActivationProtocol(WServer *server) { + // The window transition manager is part of the activation path: an + // activation token may carry a pending transition rect that has to be + // associated with the target surface before it maps. + m_windowTransitionManagerV1 = server->attach(); auto *activation = server->attach( [this](WSurface *surface, WSeat *seat) { return canTransferActivation(surface, seat); }); connect(activation, &ActivationManagerInterfaceV1::activateRequested, @@ -323,8 +327,10 @@ bool FocusController::canTransferActivation(WSurface *surface, WSeat *seat) cons return false; } -void FocusController::onActivationRequested(ActivationManagerInterfaceV1::TokenDisposition disposition, - WSurface *wsurface, WSeat *seat) +void FocusController::onActivationRequested(const QString &token, + ActivationManagerInterfaceV1::TokenDisposition disposition, + WSurface *wsurface, WSeat *seat, + WSurface *originatingSurface) { auto wrapper = m_server->rootContainer()->getSurface(wsurface); if (!wrapper) { @@ -332,10 +338,51 @@ void FocusController::onActivationRequested(ActivationManagerInterfaceV1::TokenD return; } + if (m_windowTransitionManagerV1 + && m_windowTransitionManagerV1->hasPendingWindowTransitionRect(token)) { + if (!originatingSurface) { + // Without an originating surface the rect cannot be mapped to + // global coordinates, so it will never be used. Close it now + // instead of waiting for the pending sweep. + qCDebug(lcTlWindowTransition) << "Window transition rect discarded: token has no" + << "originating surface" + << "appId:" << wrapper->appId(); + m_windowTransitionManagerV1->discardPendingRect(token); + } else if (wsurface->mapped()) { + qCDebug(lcTlWindowTransition) << "Window transition rect discarded: target surface" + << "is already mapped (client activation arrived too late)" + << "appId:" << wrapper->appId(); + // Discard the pending rect object if any. + m_windowTransitionManagerV1->discardPendingRect(token); + } else { + auto *originWrapper = m_server->rootContainer()->getSurface(originatingSurface); + if (originWrapper) { + bool associated = m_windowTransitionManagerV1->associatePendingRect( + token, wrapper->windowTransition(), originWrapper->windowTransition()); + if (!associated) { + qCDebug(lcTlWindowTransition) + << "Window transition rect was destroyed" + << "before activation, using default animation" + << "appId:" << wrapper->appId(); + } + } else { + qCDebug(lcTlWindowTransition) + << "Window transition: originating surface wrapper" + << "not found, skipping" + << "appId:" << wrapper->appId(); + // Discard the pending rect object. + m_windowTransitionManagerV1->discardPendingRect(token); + } + } + } + // Don't use hasActiveCapability() here — it also checks UnMinimized, // but minimized windows should be allowed to activate (which unminimizes them). if (!wsurface->mapped()) { - qCWarning(lcTlCore) << "Activation request for unmapped surface!"; + if (disposition == ActivationManagerInterfaceV1::TokenDisposition::Active) + wrapper->setPendingActivation(seat); + else + qCWarning(lcTlCore) << "Activation request for unmapped surface!"; return; } diff --git a/src/core/focuscontroller.h b/src/core/focuscontroller.h index cca0fa7464..1a576390d4 100644 --- a/src/core/focuscontroller.h +++ b/src/core/focuscontroller.h @@ -6,6 +6,7 @@ #include #include "modules/activation/activationmanagerinterfacev1.h" +#include "modules/window-transition/windowtransitionmanagerinterfacev1.h" #include @@ -41,8 +42,11 @@ class FocusController : public QObject private: bool canTransferActivation(WSurface *surface, WSeat *seat) const; - void onActivationRequested(ActivationManagerInterfaceV1::TokenDisposition disposition, - WSurface *surface, WSeat *seat); + void onActivationRequested(const QString &token, + ActivationManagerInterfaceV1::TokenDisposition disposition, + WSurface *surface, WSeat *seat, + WSurface *originatingSurface); private: WaylandServer *m_server; + WindowTransitionManagerInterfaceV1 *m_windowTransitionManagerV1 = nullptr; }; diff --git a/src/core/qml/Animations/WindowTransition.qml b/src/core/qml/Animations/WindowTransition.qml new file mode 100644 index 0000000000..7262c58d37 --- /dev/null +++ b/src/core/qml/Animations/WindowTransition.qml @@ -0,0 +1,136 @@ +// Copyright (C) 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: Apache-2.0 OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +import QtQuick +import Treeland +import Waylib.Server +import QtQuick.Effects + +Item { + id: root + + required property SurfaceWrapper surface + required property rect fromGeometry + required property rect toGeometry + property int duration: 400 * Helper.animationSpeed + property real fadeDurationRatio: 0.5 + property var enableBlur: false + property var sourceSurface: null + property int direction: 0 + + signal finished + + x: fromGeometry.x + y: fromGeometry.y + width: fromGeometry.width + height: fromGeometry.height + + function start() { + animation.start(); + } + + XdgShadow { + anchors.fill: parent + visible: surface.visibleDecoration && !surface.noDecoration + cornerRadius: surface.radius + shadowBlur: surface.shadowBlurRadius + shadowOffsetX: surface.shadowOffsetX + shadowOffsetY: surface.shadowOffsetY + shadowColor: surface.shadowColor + } + + Loader { + active: root.enableBlur + anchors.fill: parent + sourceComponent: Blur { + anchors.fill: parent + radius: surface.radius + } + } + + ShaderEffectSource { + id: backgroundEffect + + readonly property real xScale: root.width / surface.width + readonly property real yScale: root.height / surface.height + + live: true + sourceItem: surface + hideSource: true + sourceRect: surface.boundingRect + width: sourceRect.width * xScale + height: sourceRect.height * yScale + x: sourceRect.x * xScale + y: sourceRect.y * yScale + } + + SurfaceItemContent { + id: sourceImage + + anchors.fill: parent + visible: !!root.sourceSurface + surface: root.sourceSurface + smooth: true + live: true + opacity: root.direction === 1 ? 1 : 0 + } + + ParallelAnimation { + id: animation + + XAnimator { + target: root + duration: root.duration + easing.type: Easing.OutExpo + from: root.fromGeometry.x + to: root.toGeometry.x + } + + YAnimator { + target: root + duration: root.duration + easing.type: Easing.OutExpo + from: root.fromGeometry.y + to: root.toGeometry.y + } + + PropertyAnimation { + target: root + property: "width" + duration: root.duration + easing.type: Easing.OutExpo + from: root.fromGeometry.width + to: root.toGeometry.width + } + + PropertyAnimation { + target: root + property: "height" + duration: root.duration + easing.type: Easing.OutExpo + from: root.fromGeometry.height + to: root.toGeometry.height + } + + SequentialAnimation { + PauseAnimation { + duration: root.direction === 1 ? 0 : root.duration * (1 - root.fadeDurationRatio) + } + PropertyAnimation { + target: sourceImage + property: "opacity" + duration: root.duration * root.fadeDurationRatio + from: root.direction === 1 ? 1 : 0 + to: root.direction === 1 ? 0 : 1 + easing.type: Easing.Linear + } + } + + onFinished: { + // Defer the signal emission to avoid deleting animation objects in the same callback stack. + Qt.callLater(function() { + root.finished(); + }) + } + } +} diff --git a/src/core/qmlengine.cpp b/src/core/qmlengine.cpp index 0a5d001481..5da6f9d02d 100644 --- a/src/core/qmlengine.cpp +++ b/src/core/qmlengine.cpp @@ -43,6 +43,7 @@ QmlEngine::QmlEngine(QObject *parent) , xdgShadowComponent(this, "Treeland", "XdgShadow") , taskSwitchComponent(this, "Treeland", "TaskSwitcher") , geometryAnimationComponent(this, "Treeland", "GeometryAnimation") + , windowTransitionComponent(this, "Treeland", "WindowTransition") , menuBarComponent(this, "Treeland", "OutputMenuBar") , workspaceSwitcher(this, "Treeland", "WorkspaceSwitcher") , newAnimationComponent(this, "Treeland", "NewAnimation") @@ -149,6 +150,24 @@ QQuickItem *QmlEngine::createGeometryAnimation(SurfaceWrapper *surface, }); } +QQuickItem *QmlEngine::createWindowTransition(SurfaceWrapper *surface, + const QRectF &startGeo, + const QRectF &endGeo, + QQuickItem *parent, + WSurface *sourceSurface, + int direction) +{ + return createComponent(windowTransitionComponent, + parent, + { + { "surface", QVariant::fromValue(surface) }, + { "fromGeometry", QVariant::fromValue(startGeo) }, + { "toGeometry", QVariant::fromValue(endGeo) }, + { "sourceSurface", QVariant::fromValue(sourceSurface) }, + { "direction", QVariant::fromValue(direction) }, + }); +} + QQuickItem *QmlEngine::createMenuBar(WOutputItem *output, QQuickItem *parent) { return createComponent(menuBarComponent, parent, { { "output", QVariant::fromValue(output) } }); diff --git a/src/core/qmlengine.h b/src/core/qmlengine.h index 3c031eaf38..1c406bda4f 100644 --- a/src/core/qmlengine.h +++ b/src/core/qmlengine.h @@ -16,6 +16,7 @@ QT_END_NAMESPACE WAYLIB_SERVER_BEGIN_NAMESPACE class WOutputItem; +class WSurface; WAYLIB_SERVER_END_NAMESPACE WAYLIB_SERVER_USE_NAMESPACE @@ -45,6 +46,12 @@ class QmlEngine : public QQmlApplicationEngine const QRectF &startGeo, const QRectF &endGeo, QQuickItem *parent); + QQuickItem *createWindowTransition(SurfaceWrapper *surface, + const QRectF &startGeo, + const QRectF &endGeo, + QQuickItem *parent, + WSurface *sourceSurface = nullptr, + int direction = 0); QQuickItem *createMenuBar(WOutputItem *output, QQuickItem *parent); QQuickItem *createWorkspaceSwitcher(Workspace *parent); QQuickItem *createNewAnimation(SurfaceWrapper *surface, QQuickItem *parent, uint direction); @@ -88,6 +95,7 @@ class QmlEngine : public QQmlApplicationEngine QQmlComponent xdgShadowComponent; QQmlComponent taskSwitchComponent; QQmlComponent geometryAnimationComponent; + QQmlComponent windowTransitionComponent; QQmlComponent menuBarComponent; QQmlComponent workspaceSwitcher; QQmlComponent newAnimationComponent; diff --git a/src/interfaces/windowtransition.h b/src/interfaces/windowtransition.h new file mode 100644 index 0000000000..d945d9cc04 --- /dev/null +++ b/src/interfaces/windowtransition.h @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: Apache-2.0 OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +#pragma once + +#include + +#include +#include +#include +#include + +#include + +WAYLIB_SERVER_BEGIN_NAMESPACE +class WOutput; +class WSurface; +WAYLIB_SERVER_END_NAMESPACE + +WAYLIB_SERVER_USE_NAMESPACE + +// Refcounted handle to a client-provided window-transition source surface. +class WindowTransitionSource : public QObject +{ + Q_OBJECT +public: + using QObject::QObject; + + virtual WSurface *surface() const = 0; + + virtual void retain(const QList &outputs) = 0; + virtual void release(const QList &outputs) = 0; +}; + +class WindowTransitionTarget : public QObject +{ + Q_OBJECT +public: + using QObject::QObject; + + virtual void setWindowTransitionRect(const QRectF &localRect, + WindowTransitionTarget *origin) = 0; + virtual void updateWindowTransitionRect(const QRectF &localRect) = 0; + + virtual void setWindowTransitionSource(WindowTransitionSource *source) = 0; + // Drop the live source only + virtual void clearWindowTransitionSource() = 0; + // Drop the whole transition and notify the client that the rect is done. + virtual void resetWindowTransition() = 0; + + virtual std::optional mapTransitionPointToGlobal( + const QPointF &surfacePoint) const = 0; + +Q_SIGNALS: + void windowTransitionFinished(); +}; diff --git a/src/modules/CMakeLists.txt b/src/modules/CMakeLists.txt index 329b3b4e81..3be317a402 100644 --- a/src/modules/CMakeLists.txt +++ b/src/modules/CMakeLists.txt @@ -49,6 +49,7 @@ add_subdirectory(wine-window-state) add_subdirectory(wine-window-management) add_subdirectory(wallpaper) add_subdirectory(activation) +add_subdirectory(window-transition) add_subdirectory(input-manager) add_subdirectory(keyboard-state-notify) add_subdirectory(keyboard-shortcuts-inhibit) diff --git a/src/modules/activation/activationmanagerinterfacev1.cpp b/src/modules/activation/activationmanagerinterfacev1.cpp index fa7a335ac0..63a3e9fdc2 100644 --- a/src/modules/activation/activationmanagerinterfacev1.cpp +++ b/src/modules/activation/activationmanagerinterfacev1.cpp @@ -118,6 +118,7 @@ struct TokenInfo bool fromTrustedSurface = false; // set_surface called and surface was active at commit time QDeadlineTimer expiry; // invalidated 60 s after registration QPointer seat; // seat associated with the token via set_serial + QPointer originatingSurface; // surface set via set_surface }; class ActivationManagerInterfaceV1Private @@ -145,11 +146,18 @@ class ActivationManagerInterfaceV1Private wl_client *client, std::optional serial, bool fromTrustedSurface, - WSeat *seat) + WSeat *seat, + WSurface *originatingSurface) { const QString token = QUuid::createUuid().toString(QUuid::WithoutBraces); - m_tokens.append(TokenInfo{ token, appId, client, serial, fromTrustedSurface, - QDeadlineTimer(TokenLifetimeMs), seat }); + m_tokens.append(TokenInfo{ token, + appId, + client, + serial, + fromTrustedSurface, + QDeadlineTimer(TokenLifetimeMs), + seat, + originatingSurface }); qCDebug(lcTlActivation) << "Registered activation token" << token.left(8) + u"..."_s << "for app" << appId << (fromTrustedSurface ? "" : "(inactive-surface-token-request)"); @@ -177,9 +185,9 @@ class ActivationManagerInterfaceV1Private void get_activation_token(Resource *resource, uint32_t id) override { auto *tokenResource = wl_resource_create(resource->client(), - &xdg_activation_token_v1_interface, - wl_resource_get_version(resource->handle), - id); + &xdg_activation_token_v1_interface, + resource->version(), + id); if (!tokenResource) { wl_resource_post_no_memory(resource->handle); return; @@ -206,41 +214,34 @@ class ActivationManagerInterfaceV1Private return; } - auto disposition = dispositionForToken(token); - // Retrieve the seat associated with the token before consuming it. + auto disposition = ActivationManagerInterfaceV1::TokenDisposition::Invalid; WSeat *tokenSeat = nullptr; + WSurface *originatingSurface = nullptr; + auto it = std::find_if(m_tokens.begin(), m_tokens.end(), [&token](const TokenInfo &t) { return t.token == token; }); if (it != m_tokens.end()) { tokenSeat = it->seat.data(); - m_tokens.erase(it); + originatingSurface = it->originatingSurface; + disposition = it->fromTrustedSurface + ? (it->serial.has_value() + ? ActivationManagerInterfaceV1::TokenDisposition::Active + : ActivationManagerInterfaceV1::TokenDisposition::Attention) + : ActivationManagerInterfaceV1::TokenDisposition::Attention; } - qCInfo(lcTlActivation) << "activate: emitting activateRequested for token" << token.left(8) + u"..."_s - << "with disposition" << disposition; - Q_EMIT q->activateRequested(disposition, wsurface, tokenSeat); - } + qCInfo(lcTlActivation) << "activate: emitting activateRequested for token" + << token.left(8) + u"..."_s << "with disposition" << disposition; -private: - ActivationManagerInterfaceV1::TokenDisposition dispositionForToken(const QString &token) const - { - auto it = std::find_if(m_tokens.cbegin(), m_tokens.cend(), - [&token](const TokenInfo &t) { return t.token == token; }); - if (it == m_tokens.cend()) { - return ActivationManagerInterfaceV1::TokenDisposition::Invalid; - } - if (it->expiry.hasExpired()) { - return ActivationManagerInterfaceV1::TokenDisposition::Invalid; - } - // inactive-surface-token-request: set_surface not called or surface was not active - // → treat as Attention - if (!it->fromTrustedSurface) { - return ActivationManagerInterfaceV1::TokenDisposition::Attention; + // Keep token one-shot semantics + if (it != m_tokens.end()) { + m_tokens.erase(it); } - return it->serial.has_value() ? ActivationManagerInterfaceV1::TokenDisposition::Active - : ActivationManagerInterfaceV1::TokenDisposition::Attention; + + Q_EMIT q->activateRequested(token, disposition, wsurface, tokenSeat, originatingSurface); } +private: void sweepExpiredTokens() { auto it = m_tokens.begin(); @@ -285,7 +286,14 @@ void TokenContext::commit(Resource *resource) // fromTrustedSurface: set_surface was called, surface still alive, and currently active const bool fromTrustedSurface = m_surface && m_manager->isTrustedSurface(m_surface, m_seat); - const QString token = m_manager->registerToken(m_appId, resource->client(), m_serial, fromTrustedSurface, m_seat); + + const QString token = m_manager->registerToken(m_appId, + resource->client(), + m_serial, + fromTrustedSurface, + m_seat, + m_surface); + send_done(token); } diff --git a/src/modules/activation/activationmanagerinterfacev1.h b/src/modules/activation/activationmanagerinterfacev1.h index d37519bdf2..e7acbca375 100644 --- a/src/modules/activation/activationmanagerinterfacev1.h +++ b/src/modules/activation/activationmanagerinterfacev1.h @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -40,9 +41,15 @@ class ActivationManagerInterfaceV1 : public QObject, public WAYLIB_SERVER_NAMESP Q_SIGNALS: // The server emits one signal with precomputed disposition for policy handling. - void activateRequested(TokenDisposition disposition, + // token is the committed activation token string. + // seat is the WSeat associated with the token via set_serial, or null. + // originatingSurface is the WSurface that was set via set_surface on the + // token (the surface that initiated the launch), or null. + void activateRequested(const QString &token, + TokenDisposition disposition, WAYLIB_SERVER_NAMESPACE::WSurface *surface, - WAYLIB_SERVER_NAMESPACE::WSeat *seat); + WAYLIB_SERVER_NAMESPACE::WSeat *seat, + WAYLIB_SERVER_NAMESPACE::WSurface *originatingSurface); protected: void create(WAYLIB_SERVER_NAMESPACE::WServer *server) override; diff --git a/src/modules/window-transition/CMakeLists.txt b/src/modules/window-transition/CMakeLists.txt new file mode 100644 index 0000000000..65ae1c1676 --- /dev/null +++ b/src/modules/window-transition/CMakeLists.txt @@ -0,0 +1,18 @@ +find_package(TreelandProtocols 0.7.0 REQUIRED) + +waylib_generate_qtwayland_server_protocol(libtreeland + PROTOCOL ${TREELAND_PROTOCOLS_DATA_DIR}/treeland-window-transition-unstable-v1.xml + BASENAME treeland-window-transition-unstable-v1 +) + +impl_treeland( + NAME + module_window_transition + SOURCE + ${CMAKE_SOURCE_DIR}/src/modules/window-transition/windowtransitionmanagerinterfacev1.h + ${CMAKE_SOURCE_DIR}/src/modules/window-transition/windowtransitionmanagerinterfacev1.cpp + INCLUDE + $ + LINK + Qt6::Core +) diff --git a/src/modules/window-transition/windowtransitionmanagerinterfacev1.cpp b/src/modules/window-transition/windowtransitionmanagerinterfacev1.cpp new file mode 100644 index 0000000000..e944f1ad8a --- /dev/null +++ b/src/modules/window-transition/windowtransitionmanagerinterfacev1.cpp @@ -0,0 +1,458 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: Apache-2.0 OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +#include "windowtransitionmanagerinterfacev1.h" + +#include "common/treelandlogging.h" +#include "interfaces/windowtransition.h" +#include "qwayland-server-treeland-window-transition-unstable-v1.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include + +WAYLIB_SERVER_USE_NAMESPACE +using namespace Qt::StringLiterals; + +namespace { +constexpr int kPendingRectLifetimeMs = 60'000; +} // namespace + +class WindowTransitionManagerInterfaceV1Private; + +static const wlr_surface_role transitionSourceRole = { + .name = "treeland_window_transition_source_v1", + .no_object = true, + .client_commit = nullptr, + .commit = nullptr, + .map = nullptr, + .unmap = nullptr, + .destroy = nullptr, +}; + +class WindowTransitionSourceImpl : public WindowTransitionSource +{ +public: + explicit WindowTransitionSourceImpl(wlr_surface *handle) + : WindowTransitionSource() + , m_wrapper(new WSurface(handle)) + { + } + + ~WindowTransitionSourceImpl() override + { + const auto outputs = m_refs.keys(); + for (auto *output : outputs) + m_wrapper->leaveOutput(output); + delete m_wrapper; + } + + WSurface *surface() const override + { + return m_wrapper; + } + + void retain(const QList &outputs) override + { + for (auto *output : outputs) { + if (++m_refs[output] == 1) + m_wrapper->enterOutput(output); + } + m_wrapper->notifyFrameDone(); + } + + void release(const QList &outputs) override + { + for (auto *output : outputs) { + auto it = m_refs.find(output); + if (it == m_refs.end()) + continue; + if (--it.value() <= 0) { + m_refs.erase(it); + m_wrapper->leaveOutput(output); + } + } + } + +private: + WSurface *m_wrapper; + QHash m_refs; +}; + +struct SourceSurfaceEntry +{ + wlr_surface *handle = nullptr; + WindowTransitionSourceImpl *source = nullptr; + // Owns the native destroy listener registered on the source surface + // wrapper; destroying the entry detaches it automatically. + std::unique_ptr listenerOwner; +}; + +class WindowTransitionRectV1 : public QtWaylandServer::treeland_window_transition_rect_v1 +{ + friend class WindowTransitionManagerInterfaceV1; + +public: + WindowTransitionRectV1(WindowTransitionManagerInterfaceV1Private *manager, + const QString &token, + wl_resource *resource) + : QtWaylandServer::treeland_window_transition_rect_v1(resource) + , m_manager(manager) + , m_token(token) + , m_expiry(QDeadlineTimer(kPendingRectLifetimeMs)) + { + } + + ~WindowTransitionRectV1(); + + void notifyDiscarded() + { + if (m_closed) + return; + m_closed = true; + send_closed(); + } + + void detachManager() + { + m_manager = nullptr; + } + + bool isExpired() const + { + return m_expiry.hasExpired(); + } + + void associate(WindowTransitionTarget *target, WindowTransitionTarget *origin) + { + m_target = target; + if (m_target) { + m_target->setWindowTransitionRect(m_rect, origin); + m_target->setWindowTransitionSource(m_source.data()); + m_targetTransitionFinishedConnection = + QObject::connect(m_target.data(), + &WindowTransitionTarget::windowTransitionFinished, + [this] { + notifyDiscarded(); + }); + } + } + +protected: + void destroy(Resource *resource) override + { + wl_resource_destroy(resource->handle); + } + + void destroy_resource(Resource *) override + { + delete this; + } + + void set_geometry(Resource * /*resource*/, + int32_t x, + int32_t y, + int32_t width, + int32_t height) override; + void set_source_surface(Resource * /*resource*/, struct ::wl_resource *surface) override; + +private: + void clearSourceSurface(); + void releaseSourceSurface(); + + WindowTransitionManagerInterfaceV1Private *m_manager; + QString m_token; + QDeadlineTimer m_expiry; + QRectF m_rect; + QMetaObject::Connection m_targetTransitionFinishedConnection; + QPointer m_target; + bool m_closed = false; + + // Owned by the manager, not by this rect; only a weak reference. + QPointer m_source; +}; + +class WindowTransitionManagerInterfaceV1Private + : public QtWaylandServer::treeland_window_transition_manager_v1 +{ +public: + explicit WindowTransitionManagerInterfaceV1Private(WindowTransitionManagerInterfaceV1 *q) + : QtWaylandServer::treeland_window_transition_manager_v1() + , q(q) + { + } + + ~WindowTransitionManagerInterfaceV1Private(); + + wl_global *globalHandle() const + { + return m_global; + } + + // Returns the shared handle for a transition source surface, creating and + // taking ownership of it on first use. + WindowTransitionSource *acquireSourceSurface(wlr_surface *surface); + + WindowTransitionManagerInterfaceV1 *q; + QHash m_pendingRects; + QHash m_sourceSurfaces; + +protected: + void destroy_global() override + { + qCDebug(lcTlWindowTransition) << "treeland_window_transition_manager_v1 global destroyed"; + } + + void destroy(Resource *resource) override + { + wl_resource_destroy(resource->handle); + } + + void get_window_transition_rect(Resource *resource, + uint32_t rect_id, + const QString &token) override + { + auto *rectResource = wl_resource_create(resource->client(), + &treeland_window_transition_rect_v1_interface, + resource->version(), + rect_id); + if (!rectResource) { + wl_resource_post_no_memory(resource->handle); + return; + } + auto *rect = new WindowTransitionRectV1(this, token, rectResource); + // One rectangle per token: close an earlier one so the client can + // release it. + if (auto *previous = m_pendingRects.take(token)) + previous->notifyDiscarded(); + m_pendingRects.insert(token, rect); + q->ensurePendingSweepRunning(); + } +}; + +WindowTransitionManagerInterfaceV1Private::~WindowTransitionManagerInterfaceV1Private() +{ + for (auto *rect : std::as_const(m_pendingRects)) + rect->detachManager(); + m_pendingRects.clear(); + + // Deleting an entry destroys its listenerOwner, which detaches the + // surface destroy listener registered on the wrapper. + const auto entries = m_sourceSurfaces; + m_sourceSurfaces.clear(); + for (auto *entry : entries) { + delete entry->source; + delete entry; + } +} + +WindowTransitionSource *WindowTransitionManagerInterfaceV1Private::acquireSourceSurface( + wlr_surface *surface) +{ + if (auto *entry = m_sourceSurfaces.value(surface)) + return entry->source; + + auto *entry = new SourceSurfaceEntry; + entry->handle = surface; + entry->source = new WindowTransitionSourceImpl(surface); + entry->listenerOwner = std::make_unique(); + + // Track the native surface's destruction through the wrapper's listener + // graph instead of a hand-rolled wl_listener. Registering on the wrapper + // with the entry's owner token means destroying either side detaches the + // listener; the callback itself is safe to delete the entry from. + entry->source->surface() + ->listeners(entry->listenerOwner.get()) + ->add(&surface->events.destroy, this, [this, entry] { + m_sourceSurfaces.remove(entry->handle); + delete entry->source; + delete entry; + }); + + m_sourceSurfaces.insert(surface, entry); + return entry->source; +} + +WindowTransitionRectV1::~WindowTransitionRectV1() +{ + if (m_targetTransitionFinishedConnection) + QObject::disconnect(m_targetTransitionFinishedConnection); + + if (m_target) { + m_target->resetWindowTransition(); + } + + releaseSourceSurface(); + + if (m_manager && m_manager->m_pendingRects.value(m_token) == this) + m_manager->m_pendingRects.remove(m_token); +} + +void WindowTransitionRectV1::set_geometry(Resource *resource, + int32_t x, + int32_t y, + int32_t width, + int32_t height) +{ + if (width < 0 || height < 0) { + wl_resource_post_error(resource->handle, + error_invalid_geometry, + "set_geometry with negative size %dx%d", + width, + height); + return; + } + m_rect = QRectF(x, y, width, height); + + if (m_target) + m_target->updateWindowTransitionRect(m_rect); + + if (m_source) + m_source->surface()->notifyFrameDone(); +} + +void WindowTransitionRectV1::set_source_surface(Resource *resource, + struct ::wl_resource *surfaceResource) +{ + if (!surfaceResource) { + clearSourceSurface(); + return; + } + + wlr_surface *surface = wlr_surface_from_resource(surfaceResource); + if (!surface + || wl_resource_get_client(surfaceResource) != wl_resource_get_client(resource->handle)) { + wl_resource_post_error(resource->handle, + error_invalid_surface, + "set_source_surface: surface is not a wl_surface of this client"); + return; + } + + if (m_source && m_source->surface()->handle() == surface) + return; + + // The surface must not already carry a role. + if (!wlr_surface_set_role(surface, + &transitionSourceRole, + resource->handle, + error_invalid_surface)) { + return; + } + + releaseSourceSurface(); + + auto *source = m_manager->acquireSourceSurface(surface); + m_source = source; + + if (m_target) + m_target->setWindowTransitionSource(source); + + source->surface()->notifyFrameDone(); +} + +void WindowTransitionRectV1::clearSourceSurface() +{ + releaseSourceSurface(); + if (m_target) + m_target->clearWindowTransitionSource(); +} + +void WindowTransitionRectV1::releaseSourceSurface() +{ + // The handle is owned by the manager; only drop our weak reference. + m_source = nullptr; +} + +WindowTransitionManagerInterfaceV1::WindowTransitionManagerInterfaceV1(QObject *parent) + : QObject(parent) + , WServerInterface() + , d(new WindowTransitionManagerInterfaceV1Private(this)) + , m_pendingSweepTimer(this) +{ + m_pendingSweepTimer.setInterval(kPendingRectLifetimeMs / 2); + connect(&m_pendingSweepTimer, + &QTimer::timeout, + this, + &WindowTransitionManagerInterfaceV1::sweepPendingRects); +} + +WindowTransitionManagerInterfaceV1::~WindowTransitionManagerInterfaceV1() = default; + +QByteArrayView WindowTransitionManagerInterfaceV1::interfaceName() const +{ + return d->interfaceName(); +} + +void WindowTransitionManagerInterfaceV1::ensurePendingSweepRunning() +{ + if (!d->m_pendingRects.isEmpty() && !m_pendingSweepTimer.isActive()) + m_pendingSweepTimer.start(); +} + +void WindowTransitionManagerInterfaceV1::sweepPendingRects() +{ + for (auto it = d->m_pendingRects.begin(); it != d->m_pendingRects.end();) { + if (it.value()->isExpired()) { + it.value()->notifyDiscarded(); + it = d->m_pendingRects.erase(it); + } else { + ++it; + } + } + if (d->m_pendingRects.isEmpty()) + m_pendingSweepTimer.stop(); +} + +bool WindowTransitionManagerInterfaceV1::hasPendingWindowTransitionRect(const QString &token) const +{ + return d->m_pendingRects.contains(token); +} + +bool WindowTransitionManagerInterfaceV1::associatePendingRect(const QString &token, + WindowTransitionTarget *target, + WindowTransitionTarget *origin) +{ + auto it = d->m_pendingRects.find(token); + if (it == d->m_pendingRects.end()) + return false; + auto *rectObj = it.value(); + d->m_pendingRects.erase(it); + rectObj->associate(target, origin); + return true; +} + +void WindowTransitionManagerInterfaceV1::discardPendingRect(const QString &token) +{ + auto it = d->m_pendingRects.find(token); + if (it == d->m_pendingRects.end()) + return; + auto *rectObj = it.value(); + d->m_pendingRects.erase(it); + rectObj->notifyDiscarded(); +} + +void WindowTransitionManagerInterfaceV1::create(WServer *server) +{ + d->init(server->handle(), InterfaceVersion); +} + +void WindowTransitionManagerInterfaceV1::destroy([[maybe_unused]] WServer *server) +{ + d->globalRemove(); +} + +wl_global *WindowTransitionManagerInterfaceV1::global() const +{ + return d->globalHandle(); +} diff --git a/src/modules/window-transition/windowtransitionmanagerinterfacev1.h b/src/modules/window-transition/windowtransitionmanagerinterfacev1.h new file mode 100644 index 0000000000..a13133f026 --- /dev/null +++ b/src/modules/window-transition/windowtransitionmanagerinterfacev1.h @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: Apache-2.0 OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +#pragma once + +#include + +#include +#include +#include + +#include + +class WindowTransitionTarget; +class WindowTransitionManagerInterfaceV1Private; + +class WindowTransitionManagerInterfaceV1 + : public QObject + , public WAYLIB_SERVER_NAMESPACE::WServerInterface +{ + Q_OBJECT +public: + explicit WindowTransitionManagerInterfaceV1(QObject *parent = nullptr); + ~WindowTransitionManagerInterfaceV1() override; + + QByteArrayView interfaceName() const override; + + static constexpr int InterfaceVersion = 1; + + void discardPendingRect(const QString &token); + bool hasPendingWindowTransitionRect(const QString &token) const; + bool associatePendingRect(const QString &token, + WindowTransitionTarget *target, + WindowTransitionTarget *origin); + +protected: + void create(WAYLIB_SERVER_NAMESPACE::WServer *server) override; + void destroy(WAYLIB_SERVER_NAMESPACE::WServer *server) override; + wl_global *global() const override; + +private: + friend class WindowTransitionRectV1; + friend class WindowTransitionManagerInterfaceV1Private; + std::unique_ptr d; + QTimer m_pendingSweepTimer; + void ensurePendingSweepRunning(); + void sweepPendingRects(); +}; diff --git a/src/surface/surfacewindowtransition.cpp b/src/surface/surfacewindowtransition.cpp new file mode 100644 index 0000000000..4529d243b9 --- /dev/null +++ b/src/surface/surfacewindowtransition.cpp @@ -0,0 +1,178 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: Apache-2.0 OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +#include "surfacewindowtransition.h" + +#include "surface/surfacewrapper.h" + +#include +#include + +#include + +SurfaceWindowTransition::SurfaceWindowTransition(SurfaceWrapper *wrapper) + : WindowTransitionTarget() + , m_wrapper(wrapper) +{ +} + +bool SurfaceWindowTransition::active() const +{ + return m_hasRect && !m_localRect.isEmpty(); +} + +WSurface *SurfaceWindowTransition::sourceSurface() const +{ + auto *source = m_source.data(); + return source ? source->surface() : nullptr; +} + +std::optional SurfaceWindowTransition::globalRect() const +{ + if (!m_hasRect || !m_origin) + return std::nullopt; + + const auto topLeft = m_origin->mapTransitionPointToGlobal(m_localRect.topLeft()); + if (!topLeft) + return std::nullopt; + + return QRectF(*topLeft, m_localRect.size()); +} + +void SurfaceWindowTransition::begin() +{ + if (m_phase == Phase::Playing) + return; + + m_phase = Phase::Playing; + m_retainedOutputs = m_wrapper->outputs(); + if (m_source) + m_source->retain(m_retainedOutputs); +} + +void SurfaceWindowTransition::endAnimation(bool wrapperAboutToRemove) +{ + releaseSourceOutputs(); + + if (wrapperAboutToRemove) { + if (m_sourceDestroyConnection) + QObject::disconnect(m_sourceDestroyConnection); + m_sourceDestroyConnection = { }; + m_phase = Phase::Idle; + m_hasRect = false; + m_localRect = { }; + m_origin = nullptr; + m_source = nullptr; + Q_EMIT windowTransitionFinished(); + } else { + m_phase = m_hasRect ? Phase::Armed : Phase::Idle; + } +} + +void SurfaceWindowTransition::resetWindowTransition() +{ + const bool wasArmed = m_hasRect; + + releaseSourceOutputs(); + if (m_sourceDestroyConnection) + QObject::disconnect(m_sourceDestroyConnection); + m_sourceDestroyConnection = { }; + m_source = nullptr; + m_phase = Phase::Idle; + m_hasRect = false; + m_localRect = { }; + m_origin = nullptr; + + m_wrapper->updateWindowTransitionAnimationSource(); + + if (wasArmed) + Q_EMIT windowTransitionFinished(); +} + +void SurfaceWindowTransition::abort() +{ + releaseSourceOutputs(); + m_wrapper->updateWindowTransitionAnimationSource(); +} + +void SurfaceWindowTransition::setWindowTransitionRect(const QRectF &localRect, + WindowTransitionTarget *origin) +{ + // The rect is honored only when the surface is not yet mapped. + if (m_wrapper->surface() && m_wrapper->surface()->mapped()) + return; + + m_localRect = localRect; + m_origin = origin; + m_hasRect = true; + m_phase = Phase::Armed; +} + +void SurfaceWindowTransition::updateWindowTransitionRect(const QRectF &localRect) +{ + if (!m_hasRect) + return; + + m_localRect = localRect; +} + +void SurfaceWindowTransition::setWindowTransitionSource(WindowTransitionSource *source) +{ + if (m_source.data() == source) + return; + + releaseSourceOutputs(); + + if (m_sourceDestroyConnection) + QObject::disconnect(m_sourceDestroyConnection); + m_sourceDestroyConnection = { }; + m_source = source; + if (source) { + // The handle is owned by the manager and may outlive the animation; + // track its surface so the QML item never keeps a dangling pointer. + m_sourceDestroyConnection = + QObject::connect(source->surface(), + &WSurface::beforeDestroy, + this, + &SurfaceWindowTransition::onSourceSurfaceDestroyed); + } + + m_wrapper->updateWindowTransitionAnimationSource(); +} + +void SurfaceWindowTransition::clearWindowTransitionSource() +{ + releaseSourceOutputs(); + if (m_sourceDestroyConnection) + QObject::disconnect(m_sourceDestroyConnection); + m_sourceDestroyConnection = { }; + m_source = nullptr; + + m_wrapper->updateWindowTransitionAnimationSource(); +} + +std::optional SurfaceWindowTransition::mapTransitionPointToGlobal( + const QPointF &surfacePoint) const +{ + auto *item = m_wrapper->surfaceItem(); + if (!item || !item->surface() || !item->shellSurface()) + return std::nullopt; + + return m_wrapper->position() + item->mapFromSurface(surfacePoint); +} + +void SurfaceWindowTransition::releaseSourceOutputs() +{ + if (m_source && !m_retainedOutputs.isEmpty()) + m_source->release(m_retainedOutputs); + m_retainedOutputs.clear(); +} + +void SurfaceWindowTransition::onSourceSurfaceDestroyed() +{ + m_sourceDestroyConnection = { }; + m_source = nullptr; + m_retainedOutputs.clear(); + + m_wrapper->updateWindowTransitionAnimationSource(); +} diff --git a/src/surface/surfacewindowtransition.h b/src/surface/surfacewindowtransition.h new file mode 100644 index 0000000000..7cf74c78e6 --- /dev/null +++ b/src/surface/surfacewindowtransition.h @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: Apache-2.0 OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +#pragma once + +#include "interfaces/windowtransition.h" + +#include +#include +#include +#include + +#include + +class SurfaceWrapper; + +// Idle --setWindowTransitionRect--> Armed --begin--> Playing +// Playing --endAnimation(false)--> Armed --begin--> ... +// Playing --endAnimation(true)--> Idle (source left, rect finished) +// Armed/Playing --resetWindowTransition--> Idle (source left, rect finished) +class SurfaceWindowTransition : public WindowTransitionTarget +{ + Q_OBJECT +public: + enum class Phase + { + Idle, + Armed, + Playing, + }; + + explicit SurfaceWindowTransition(SurfaceWrapper *wrapper); + + // True when a non-empty transition rectangle is configured. + bool active() const; + WSurface *sourceSurface() const; + std::optional globalRect() const; + + // Lifecycle driven by SurfaceWrapper. + void begin(); + void endAnimation(bool wrapperAboutToRemove); + void abort(); + + void setWindowTransitionRect(const QRectF &localRect, WindowTransitionTarget *origin) override; + void updateWindowTransitionRect(const QRectF &localRect) override; + void setWindowTransitionSource(WindowTransitionSource *source) override; + void clearWindowTransitionSource() override; + void resetWindowTransition() override; + std::optional mapTransitionPointToGlobal(const QPointF &surfacePoint) const override; + +private: + void releaseSourceOutputs(); + void onSourceSurfaceDestroyed(); + + SurfaceWrapper *m_wrapper; + Phase m_phase = Phase::Idle; + bool m_hasRect = false; + QRectF m_localRect; + QPointer m_origin; + QPointer m_source; + QList m_retainedOutputs; + QMetaObject::Connection m_sourceDestroyConnection; +}; diff --git a/src/surface/surfacewrapper.cpp b/src/surface/surfacewrapper.cpp index 1931e9c7cd..c922eb78f7 100644 --- a/src/surface/surfacewrapper.cpp +++ b/src/surface/surfacewrapper.cpp @@ -7,6 +7,7 @@ #include "core/qmlengine.h" #include "output/output.h" #include "core/waylandserver.h" +#include "surface/surfacewindowtransition.h" #include "treelanduserconfig.hpp" #include "workspace/workspace.h" #include "wtoplevelsurface.h" @@ -70,6 +71,7 @@ SurfaceWrapper::SurfaceWrapper(QmlEngine *qmlEngine, , m_modal(false) , m_appId(appId) { + m_windowTransition = std::make_unique(this); QQmlEngine::setContextForObject(this, qmlEngine->rootContext()); setup(); @@ -109,6 +111,7 @@ SurfaceWrapper::SurfaceWrapper(SurfaceWrapper *original, QQuickItem *parent) , m_modal(false) , m_appId(original->m_appId) { + m_windowTransition = std::make_unique(this); QQmlEngine::setContextForObject(this, m_engine->rootContext()); if (original->m_shellSurface) { @@ -181,6 +184,7 @@ SurfaceWrapper::SurfaceWrapper(QmlEngine *qmlEngine, , m_modal(false) , m_appId(appId) { + m_windowTransition = std::make_unique(this); QQmlEngine::setContextForObject(this, qmlEngine->rootContext()); if (initialSize.isValid() && initialSize.width() > 0 && initialSize.height() > 0) { // Also set implicit size to keep QML layout consistent @@ -203,6 +207,19 @@ void SurfaceWrapper::invalidate() m_wrapperAboutToRemove = true; Q_EMIT aboutToBeInvalidated(); + // Drop any pending window transition rect so it cannot be consumed later by + // a stale mapping path on this wrapper. + if (m_windowTransitionPending && m_windowAnimation) { + m_windowAnimation->disconnect(this); + m_windowAnimation->deleteLater(); + m_windowAnimation = nullptr; + } + m_windowTransitionPending = false; + if (!isWindowAnimationRunning()) + m_windowTransition->resetWindowTransition(); + m_pendingActivation = false; + m_pendingActivationSeat.clear(); + if (!m_skipDockPreView) setSkipDockPreView(true); @@ -241,6 +258,10 @@ SurfaceWrapper::~SurfaceWrapper() } invalidate(); } + // Safety net for the path above: if a window animation was still running we + // are destroyed without onWindowAnimationFinished(), so make sure the + // transition source is not left entered on any output. + m_windowTransition->abort(); if (m_titleBar) { delete m_titleBar; m_titleBar = nullptr; @@ -1333,6 +1354,22 @@ bool SurfaceWrapper::setAttention(bool attention) return true; } +void SurfaceWrapper::setPendingActivation(WSeat *seat) +{ + m_pendingActivation = true; + m_pendingActivationSeat = seat; +} + +bool SurfaceWrapper::takePendingActivation(WSeat *&seat) +{ + if (!m_pendingActivation) + return false; + m_pendingActivation = false; + seat = m_pendingActivationSeat.data(); + m_pendingActivationSeat.clear(); + return true; +} + bool SurfaceWrapper::isInputPopupLike() const { return m_type == Type::InputPopup || m_isIMCandidatePanel; @@ -1555,17 +1592,171 @@ void SurfaceWrapper::geometryChange(const QRectF &newGeo, const QRectF &oldGeome updateClipRect(); } +void SurfaceWrapper::tryFlushPendingActivation() +{ + if (!m_pendingActivation) + return; + + if (!hasInitializeContainer()) { + connect(this, + &SurfaceWrapper::hasInitializeContainerChanged, + this, + &SurfaceWrapper::tryFlushPendingActivation, + Qt::SingleShotConnection); + return; + } + + WSeat *seat = nullptr; + if (takePendingActivation(seat)) + WaylandServer::instance()->forceActivateSurface(this, Qt::OtherFocusReason, seat); +} + +void SurfaceWrapper::startWindowTransition() +{ + if (!m_windowTransition->active()) { + m_windowTransitionPending = false; + if (m_windowAnimation) { + m_windowAnimation->disconnect(this); + m_windowAnimation->deleteLater(); + m_windowAnimation = nullptr; + Q_EMIT windowAnimationRunningChanged(); + } + return; + } + m_windowTransitionPending = true; + + if (!m_windowAnimation) { + auto globalRectOpt = m_windowTransition->globalRect(); + if (!globalRectOpt) { + // Origin wrapper or its surface item is gone — fall back to default. + m_windowTransitionPending = false; + m_windowTransition->resetWindowTransition(); + createNewOrClose(OPEN_ANIMATION); + return; + } + + beginWindowTransition(OPEN_ANIMATION, *globalRectOpt, + QRectF()); // will update later + } + + restackWindowAnimationAbove(); + + // Wait until the surface item reports ready before computing the target + // geometry. + if (!m_surfaceItem->isReady()) { + connect(m_surfaceItem, + &WSurfaceItem::readyChanged, + this, + &SurfaceWrapper::startWindowTransition, + Qt::SingleShotConnection); + return; + } + + // Defer to the next event-loop iteration so that the window's final + // position and size has set before we sample them. + QMetaObject::invokeMethod(this, + &SurfaceWrapper::finishWindowTransitionOpen, + Qt::QueuedConnection); +} + +void SurfaceWrapper::finishWindowTransitionOpen() +{ + m_windowTransitionPending = false; + // The surface may have unmapped while we were waiting. Drop the + // component we created above so it does not block a later close path. + if (m_wrapperAboutToRemove || !surface() || !surface()->mapped()) { + if (m_windowAnimation) { + m_windowAnimation->deleteLater(); + m_windowAnimation = nullptr; + } + m_windowTransition->endAnimation(false); + return; + } + + // For maximized/fullscreen the target is the state geometry. + const QRectF stateGeometry = targetGeometryForState(m_surfaceState); + const bool useStateGeometry = + (m_surfaceState == State::Maximized || m_surfaceState == State::Fullscreen) + && stateGeometry.isValid(); + const QRectF toGeometry = useStateGeometry ? stateGeometry : QRectF(position(), size()); + + m_windowAnimation->setProperty("toGeometry", toGeometry); + + if (WaylandServer::instance()->noAnimation()) { + onShowAnimationFinished(); + return; + } + bool ok = connect(m_windowAnimation, SIGNAL(finished()), this, SLOT(onShowAnimationFinished())); + Q_ASSERT(ok); + ok = QMetaObject::invokeMethod(m_windowAnimation, "start"); + Q_ASSERT(ok); + Q_EMIT windowAnimationRunningChanged(); +} + +void SurfaceWrapper::startWindowCloseTransition() +{ + Q_ASSERT(m_windowTransition->active()); + + auto globalRectOpt = m_windowTransition->globalRect(); + if (!globalRectOpt) { + // Origin wrapper or its surface item is gone — fall back to default. + m_windowTransition->resetWindowTransition(); + createNewOrClose(CLOSE_ANIMATION); + return; + } + + beginWindowTransition(CLOSE_ANIMATION, QRectF(position(), size()), *globalRectOpt); + + restackWindowAnimationAbove(); + + if (WaylandServer::instance()->noAnimation()) { + onHideAnimationFinished(); + return; + } + + bool ok = connect(m_windowAnimation, SIGNAL(finished()), this, SLOT(onHideAnimationFinished())); + Q_ASSERT(ok); + ok = QMetaObject::invokeMethod(m_windowAnimation, "start"); + Q_ASSERT(ok); + Q_EMIT windowAnimationRunningChanged(); +} + +void SurfaceWrapper::beginWindowTransition(int direction, + const QRectF &fromGeometry, + const QRectF &toGeometry) +{ + m_windowTransition->begin(); + + m_windowAnimation = m_engine->createWindowTransition(this, + fromGeometry, + toGeometry, + container(), + m_windowTransition->sourceSurface(), + direction); + m_windowAnimation->setProperty("enableBlur", m_blur); +} + void SurfaceWrapper::createNewOrClose(uint direction) { if (!m_windowAnimationEnabled) return; - if (m_windowAnimation) + if (m_windowAnimation || m_windowTransitionPending) return; if (m_container.isNull()) return; + if (direction == OPEN_ANIMATION && m_windowTransition->active()) { + startWindowTransition(); + return; + } + + if (direction == CLOSE_ANIMATION && m_windowTransition->active()) { + startWindowCloseTransition(); + return; + } + switch (m_type) { case Type::SplashScreen: [[fallthrough]]; @@ -1751,6 +1942,10 @@ void SurfaceWrapper::onWindowAnimationFinished() m_windowAnimation->deleteLater(); m_windowAnimation = nullptr; + // Stop pacing the transition source now that the animation is over. The + // source itself stays referenced until the rect is done with it. + m_windowTransition->endAnimation(m_wrapperAboutToRemove); + Q_EMIT windowAnimationRunningChanged(); if (m_wrapperAboutToRemove) { @@ -1794,6 +1989,7 @@ void SurfaceWrapper::onMappedChanged() if (!m_prelaunchSplash) { createNewOrClose(OPEN_ANIMATION); } else { + m_windowTransition->resetWindowTransition(); syncPrelaunchMappedState(); startPrelaunchSplashHideSequence(); } @@ -1805,6 +2001,9 @@ void SurfaceWrapper::onMappedChanged() } } + if (mapped) + tryFlushPendingActivation(); + if (m_coverContent) { m_coverContent->setProperty("mapped", mapped); } @@ -2710,6 +2909,18 @@ void SurfaceWrapper::disableWindowAnimation(bool disable) m_windowAnimationEnabled = !disable; } +WindowTransitionTarget *SurfaceWrapper::windowTransition() const +{ + return m_windowTransition.get(); +} + +void SurfaceWrapper::updateWindowTransitionAnimationSource() +{ + if (m_windowAnimation) + m_windowAnimation->setProperty("sourceSurface", + QVariant::fromValue(m_windowTransition->sourceSurface())); +} + void SurfaceWrapper::updateStackingLayer() { int newExplicitAlwaysOnTop = m_alwaysOnTop; diff --git a/src/surface/surfacewrapper.h b/src/surface/surfacewrapper.h index 17c141e4b8..4de735402c 100644 --- a/src/surface/surfacewrapper.h +++ b/src/surface/surfacewrapper.h @@ -12,6 +12,7 @@ #include #include +#include #include Q_MOC_INCLUDE() @@ -19,10 +20,15 @@ Q_MOC_INCLUDE() WAYLIB_SERVER_USE_NAMESPACE +WAYLIB_SERVER_BEGIN_NAMESPACE +class WSeat; +WAYLIB_SERVER_END_NAMESPACE + class QmlEngine; class Output; class SurfaceContainer; - +class SurfaceWindowTransition; +class WindowTransitionTarget; class SurfaceWrapper : public QQuickItem { friend class WaylandServer; @@ -33,6 +39,7 @@ class SurfaceWrapper : public QQuickItem friend class SurfaceProxy; friend class ShellHandler; friend class LockScreen; + friend class SurfaceWindowTransition; Q_OBJECT QML_ELEMENT QML_UNCREATABLE("SurfaceWrapper objects are created by c++") @@ -336,6 +343,7 @@ class SurfaceWrapper : public QQuickItem bool hasInitializeContainer() const; void setHasInitializeContainer(bool value); void disableWindowAnimation(bool disable = true); + WindowTransitionTarget *windowTransition() const; void setHideByShowDesk(bool show); void setHideByLockScreen(bool hide); @@ -357,6 +365,9 @@ class SurfaceWrapper : public QQuickItem bool attention() const; bool setAttention(bool attention); + void setPendingActivation(WSeat *seat); + bool takePendingActivation(WSeat *&seat); + public Q_SLOTS: void minimize(bool onAnimation = true); void restoreFromMinimized(bool onAnimation = true); @@ -459,6 +470,12 @@ public Q_SLOTS: void updateClipRect(); void geometryChange(const QRectF &newGeo, const QRectF &oldGeometry) override; void createNewOrClose(uint direction); + void beginWindowTransition(int direction, const QRectF &fromGeometry, const QRectF &toGeometry); + void updateWindowTransitionAnimationSource(); + void startWindowTransition(); + void finishWindowTransitionOpen(); + void startWindowCloseTransition(); + void tryFlushPendingActivation(); void itemChange(ItemChange change, const ItemChangeData &data) override; QRectF targetGeometryForState(State state) const; @@ -592,12 +609,18 @@ public Q_SLOTS: uint m_resizable : 1; uint m_maximizable : 1; uint m_modal : 1; + uint m_pendingActivation : 1 = 0; // activation requested before the surface mapped + uint m_windowTransitionPending : 1 = 0; + SurfaceRole m_surfaceRole = SurfaceRole::Normal; std::optional m_autoPlaceCursorOffset; std::optional m_clientRequstPos; bool m_socketEnabled{ false }; bool m_windowAnimationEnabled{ true }; + + QPointer m_pendingActivationSeat; + std::unique_ptr m_windowTransition; const QString m_appId; }; diff --git a/tests/protocols/wayland-xdg-activation-v1/setup.cpp b/tests/protocols/wayland-xdg-activation-v1/setup.cpp index 13b5b2d4e8..038ccf0c6b 100644 --- a/tests/protocols/wayland-xdg-activation-v1/setup.cpp +++ b/tests/protocols/wayland-xdg-activation-v1/setup.cpp @@ -23,11 +23,17 @@ void protocol_test_setup(WaylandServer *server) g_mgr = find_server_interface(server); if (g_mgr) { - QObject::connect(g_mgr, &ActivationManagerInterfaceV1::activateRequested, - server, [](ActivationManagerInterfaceV1::TokenDisposition disposition, - WAYLIB_SERVER_NAMESPACE::WSurface *surface, - WAYLIB_SERVER_NAMESPACE::WSeat *seat) { - (void)seat; + QObject::connect(g_mgr, + &ActivationManagerInterfaceV1::activateRequested, + server, + [](const QString &token, + ActivationManagerInterfaceV1::TokenDisposition disposition, + WAYLIB_SERVER_NAMESPACE::WSurface *surface, + WAYLIB_SERVER_NAMESPACE::WSeat *seat, + WAYLIB_SERVER_NAMESPACE::WSurface *originatingSurface) { + Q_UNUSED(token); + Q_UNUSED(seat); + Q_UNUSED(originatingSurface); g_surface_ptr = surface; g_activate_requested = true; g_captured_disposition = static_cast(disposition); From 81ec94c18680f0f74413e2f9eb8cda4bc44d9629 Mon Sep 17 00:00:00 2001 From: guolin Date: Sun, 20 Sep 2026 16:34:18 +0800 Subject: [PATCH 2/3] feat(examples): add window transition test client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add test-window-transition example using xdg-activation-v1 and the treeland-window-transition-unstable-v1 client protocol to verify open and close transition animations. 新增 test-window-transition 示例,使用 xdg-activation-v1 与 treeland-window-transition-unstable-v1 客户端协议验证窗口开关过渡动画。 Log: 添加窗口过渡测试示例 PMS: TASK-395857 Influence: 仅新增示例程序,不影响合成器行为。 --- examples/CMakeLists.txt | 1 + .../test-window-transition/CMakeLists.txt | 30 + examples/test-window-transition/main.cpp | 676 ++++++++++++++++++ 3 files changed, 707 insertions(+) create mode 100644 examples/test-window-transition/CMakeLists.txt create mode 100644 examples/test-window-transition/main.cpp diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index a134fe72f3..efa6a9fcf0 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -29,3 +29,4 @@ add_subdirectory(test_im_candidate_panel_xprop) add_subdirectory(test_input_manager) add_subdirectory(test_keyboard_state_notify) add_subdirectory(test_glass) +add_subdirectory(test-window-transition) diff --git a/examples/test-window-transition/CMakeLists.txt b/examples/test-window-transition/CMakeLists.txt new file mode 100644 index 0000000000..39b51fbbbd --- /dev/null +++ b/examples/test-window-transition/CMakeLists.txt @@ -0,0 +1,30 @@ +find_package(Qt6 REQUIRED COMPONENTS Gui WaylandClient Widgets) +if(Qt6_VERSION VERSION_GREATER_EQUAL 6.10) + find_package(Qt6 REQUIRED COMPONENTS GuiPrivate WaylandClientPrivate) +endif() +find_package(PkgConfig REQUIRED) +pkg_get_variable(WAYLAND_PROTOCOLS_DATADIR wayland-protocols pkgdatadir) +find_package(TreelandProtocols REQUIRED) + +set(BIN_NAME test-window-transition) + +qt_add_executable(${BIN_NAME} + main.cpp +) + +qt_generate_wayland_protocol_client_sources(${BIN_NAME} + FILES + ${WAYLAND_PROTOCOLS_DATADIR}/staging/xdg-activation/xdg-activation-v1.xml + ${TREELAND_PROTOCOLS_DATA_DIR}/treeland-window-transition-unstable-v1.xml +) + +target_link_libraries(${BIN_NAME} + PRIVATE + Qt6::Gui + Qt6::Widgets + Qt6::WaylandClient + Qt6::GuiPrivate + Qt6::WaylandClientPrivate +) + +install(TARGETS ${BIN_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}") diff --git a/examples/test-window-transition/main.cpp b/examples/test-window-transition/main.cpp new file mode 100644 index 0000000000..51da4cc9fe --- /dev/null +++ b/examples/test-window-transition/main.cpp @@ -0,0 +1,676 @@ +// Copyright (C) 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: Apache-2.0 OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +#include "qwayland-treeland-window-transition-unstable-v1.h" +#include "qwayland-xdg-activation-v1.h" + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// Protocol client wrappers — managers (globals) +// --------------------------------------------------------------------------- + +class XdgActivationManager + : public QWaylandClientExtensionTemplate + , public QtWayland::xdg_activation_v1 +{ + Q_OBJECT +public: + explicit XdgActivationManager() + : QWaylandClientExtensionTemplate(1) + { + } + + ~XdgActivationManager() override + { + if (isInitialized()) + destroy(); + } +}; + +class WindowTransitionManager + : public QWaylandClientExtensionTemplate + , public QtWayland::treeland_window_transition_manager_v1 +{ + Q_OBJECT +public: + explicit WindowTransitionManager() + : QWaylandClientExtensionTemplate(1) + { + } + + ~WindowTransitionManager() override + { + if (isInitialized()) + destroy(); + } +}; + +// --------------------------------------------------------------------------- +// Per-object wrappers +// --------------------------------------------------------------------------- + +class ActivationToken + : public QObject + , public QtWayland::xdg_activation_token_v1 +{ + Q_OBJECT +public: + explicit ActivationToken(::xdg_activation_token_v1 *obj, QObject *parent = nullptr) + : QObject(parent) + , QtWayland::xdg_activation_token_v1(obj) + { + } + + QString tokenString() const + { + return m_token; + } + +protected: + void xdg_activation_token_v1_done(const QString &token) override + { + m_token = token; + Q_EMIT done(token); + } + +Q_SIGNALS: + void done(const QString &token); + +private: + QString m_token; +}; + +class WindowTransitionRect + : public QObject + , public QtWayland::treeland_window_transition_rect_v1 +{ + Q_OBJECT +public: + explicit WindowTransitionRect(::treeland_window_transition_rect_v1 *obj, + QObject *parent = nullptr) + : QObject(parent) + , QtWayland::treeland_window_transition_rect_v1(obj) + { + } + + void setSourceImage(QtWaylandClient::QWaylandDisplay *display, const QPixmap &pixmap) + { + if (!display || !display->compositor()) + return; + + if (!m_sourceSurface) + m_sourceSurface = display->compositor()->create_surface(); + + delete m_shmBuffer; + m_shmBuffer = new QtWaylandClient::QWaylandShmBuffer(display, + pixmap.size(), + QImage::Format_ARGB32_Premultiplied); + QImage *image = m_shmBuffer->image(); + image->fill(Qt::transparent); + QPainter p(image); + p.drawPixmap(0, 0, pixmap); + p.end(); + + // The compositor renders this surface live during the transition, so + // it must be kept alive with valid content until closed. + wl_surface_attach(m_sourceSurface, m_shmBuffer->buffer(), 0, 0); + wl_surface_damage(m_sourceSurface, 0, 0, pixmap.width(), pixmap.height()); + wl_surface_commit(m_sourceSurface); + + set_source_surface(m_sourceSurface); + } + + void clearSourceImage() + { + set_source_surface(nullptr); + } + + ~WindowTransitionRect() override + { + if (m_sourceSurface) + wl_surface_destroy(m_sourceSurface); + delete m_shmBuffer; + } + +protected: + void treeland_window_transition_rect_v1_closed() override + { + Q_EMIT closed(); + } + +Q_SIGNALS: + void closed(); + +private: + ::wl_surface *m_sourceSurface = nullptr; + QtWaylandClient::QWaylandShmBuffer *m_shmBuffer = nullptr; +}; + +// --------------------------------------------------------------------------- +// Test image / icon palette +// --------------------------------------------------------------------------- + +struct IconPalette +{ + QColor from; + QColor to; +}; + +static constexpr int kPaletteCount = 2; +static const IconPalette kIconPalettes[kPaletteCount] = { + { QColor(63, 81, 181), QColor(33, 150, 243) }, // indigo -> blue + { QColor(76, 175, 80), QColor(0, 150, 136) }, // green -> teal +}; + +static QPixmap +createTestImage(const QString &text, const QColor &from, const QColor &to, int w, int h) +{ + QPixmap pm(w, h); + QPainter p(&pm); + QLinearGradient grad(0, 0, w, h); + grad.setColorAt(0, from); + grad.setColorAt(1, to); + p.fillRect(0, 0, w, h, grad); + p.setPen(QPen(QColor(255, 255, 255, 200), 4)); + p.drawRoundedRect(10, 10, w - 20, h - 20, 20, 20); + p.setPen(Qt::white); + QFont f = p.font(); + f.setPointSize(28); + f.setBold(true); + p.setFont(f); + p.drawText(QRect(0, 0, w, h), Qt::AlignCenter, text); + return pm; +} + +// --------------------------------------------------------------------------- +// Image / icon widget +// --------------------------------------------------------------------------- + +class IconWidget : public QWidget +{ +public: + explicit IconWidget(const QPixmap &pixmap, QWidget *parent = nullptr) + : QWidget(parent) + , m_pixmap(pixmap) + { + } + + void setPixmap(const QPixmap &pixmap) + { + m_pixmap = pixmap; + update(); + } + + void paintEvent(QPaintEvent *) override + { + QPainter p(this); + p.drawPixmap( + 0, + 0, + m_pixmap.scaled(size(), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation)); + } + +private: + QPixmap m_pixmap; +}; + +// --------------------------------------------------------------------------- +// Helper: get wl_surface from a widget +// --------------------------------------------------------------------------- + +static ::wl_surface *getWlSurface(QWidget *widget) +{ + QWindow *handle = widget->windowHandle(); + if (!handle || !handle->handle()) + return nullptr; + auto *waylandWindow = static_cast(handle->handle()); + return waylandWindow ? waylandWindow->surface() : nullptr; +} + +// --------------------------------------------------------------------------- +// Helper: get the wayland display behind a widget +// --------------------------------------------------------------------------- + +static QtWaylandClient::QWaylandDisplay *getWlDisplay(QWidget *widget) +{ + QWindow *handle = widget->windowHandle(); + if (!handle || !handle->handle()) + return nullptr; + auto *waylandWindow = static_cast(handle->handle()); + return waylandWindow ? waylandWindow->display() : nullptr; +} + +// --------------------------------------------------------------------------- +// Sender (Application A) +// --------------------------------------------------------------------------- + +class SenderWindow : public QMainWindow +{ + Q_OBJECT +public: + SenderWindow(XdgActivationManager *actMgr, WindowTransitionManager *transitionMgr) + : m_actMgr(actMgr) + , m_transitionMgr(transitionMgr) + { + setWindowTitle("Window Transition - Sender (press Space / .)"); + + auto *central = new QWidget(this); + auto *layout = new QVBoxLayout(central); + + auto *iconsLayout = new QHBoxLayout; + iconsLayout->setSpacing(24); + for (int i = 0; i < kIconCount; ++i) { + auto *column = new QVBoxLayout; + auto *icon = new IconWidget(iconPixmap(i), central); + icon->setFixedSize(200, 200); + column->addWidget(icon, 0, Qt::AlignCenter); + + auto *caption = new QLabel(m_iconCaptions.at(i), central); + caption->setAlignment(Qt::AlignCenter); + column->addWidget(caption); + + iconsLayout->addLayout(column); + m_iconWidgets.append(icon); + } + layout->addLayout(iconsLayout); + + auto *label = new QLabel("SPACE: launch receiver from icon 1 (new process)\n" + ".: launch receiver from icon 2 (new process)\n" + "ENTER: launch modal from icon 1 (same process)\n" + "0: toggle disable animation (hide icons)\n" + "B: toggle commit source surface\n" + "P: toggle icon color", + central); + label->setAlignment(Qt::AlignCenter); + layout->addWidget(label); + + m_sourceBufferIndicator = new QLabel("Commit source surface: ON", central); + m_sourceBufferIndicator->setAlignment(Qt::AlignCenter); + layout->addWidget(m_sourceBufferIndicator); + + setCentralWidget(central); + resize(560, 400); + } + + ~SenderWindow() override + { + // Destroy all rect objects so the compositor falls back + // to the default close animation once sender is gone. + for (auto &live : std::as_const(m_rects)) { + live.rect->destroy(); + } + m_rects.clear(); + } + +protected: + void keyPressEvent(QKeyEvent *event) override + { + if (event->key() == Qt::Key_Space) + launchReceiver(0); + else if (event->key() == Qt::Key_Period) + launchReceiver(1); + else if (event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) + launchModal(0); + else if (event->key() == Qt::Key_0) + toggleAnimation(); + else if (event->key() == Qt::Key_B) + toggleSourceBuffer(); + else if (event->key() == Qt::Key_P) + toggleIconPalette(); + QMainWindow::keyPressEvent(event); + } + + void resizeEvent(QResizeEvent *event) override + { + // The icon widgets are centered in the layout, so resizing the main + // window moves them. Re-set the persistent rect(s) with the updated + // geometry so the open/close animation follows the new position. + updateRectGeometries(); + QMainWindow::resizeEvent(event); + } + +private: + static constexpr int kIconCount = 2; + + QPixmap iconPixmap(int iconIndex) const + { + const auto &palette = kIconPalettes[m_paletteIndex]; + return createTestImage(m_iconCaptions.at(iconIndex), palette.from, palette.to, 200, 200); + } + + // Geometry (relative to this sender surface) of the icon widget that is + // the source of the window transition. + QRect animationRect(int iconIndex) const + { + auto *icon = m_iconWidgets.at(iconIndex); + const QPoint iconPos = icon->mapTo(this, QPoint(0, 0)); + return QRect(iconPos, icon->size()); + } + + void toggleAnimation() + { + m_animationDisabled = !m_animationDisabled; + for (auto *icon : std::as_const(m_iconWidgets)) + icon->setVisible(!m_animationDisabled); + // Hiding/showing the icons reflows the layout; force it before reading + // back the icon geometry. + centralWidget()->layout()->activate(); + updateRectGeometries(); + } + + void toggleSourceBuffer() + { + m_commitSourceBuffer = !m_commitSourceBuffer; + if (m_sourceBufferIndicator) + m_sourceBufferIndicator->setText(m_commitSourceBuffer ? "Commit source surface: ON" + : "Commit source surface: OFF"); + + auto *display = getWlDisplay(this); + for (auto &live : std::as_const(m_rects)) { + if (m_commitSourceBuffer && display) + live.rect->setSourceImage(display, iconPixmap(live.iconIndex)); + else + live.rect->clearSourceImage(); + } + } + + void toggleIconPalette() + { + m_paletteIndex = (m_paletteIndex + 1) % kPaletteCount; + + for (int i = 0; i < kIconCount; ++i) + m_iconWidgets.at(i)->setPixmap(iconPixmap(i)); + + // Re-commit the live source surfaces so a running transition shows the + // new color immediately, exercising the live-source path. + if (!m_commitSourceBuffer) + return; + auto *display = getWlDisplay(this); + if (!display) + return; + for (auto &live : std::as_const(m_rects)) + live.rect->setSourceImage(display, iconPixmap(live.iconIndex)); + } + + void updateRectGeometries() + { + if (m_rects.isEmpty()) + return; + // When disabled, keep setting a 0x0 rect so the compositor stays in + // the disabled state; a resize must not re-enable it. + for (auto &live : std::as_const(m_rects)) { + const QRect rect = m_animationDisabled ? QRect() : animationRect(live.iconIndex); + live.rect->set_geometry(rect.x(), rect.y(), rect.width(), rect.height()); + } + } + + ActivationToken *createLaunchToken() + { + if (!m_actMgr || !m_actMgr->isInitialized()) { + qWarning() << "xdg_activation_v1 not available"; + return nullptr; + } + + ::wl_surface *surface = getWlSurface(this); + if (!surface) { + qWarning() << "Cannot get wl_surface"; + return nullptr; + } + + // Create the activation token, identify the originating surface and + // commit it. The token string is delivered by the done event, after + // which the window transition rect can be created for it. + auto *rawToken = m_actMgr->get_activation_token(); + if (!rawToken) { + qWarning() << "Failed to create activation token"; + return nullptr; + } + auto *token = new ActivationToken(rawToken, this); + token->set_surface(surface); + token->commit(); + return token; + } + + void createTransitionRect(const QString &tokenStr, int iconIndex) + { + if (!m_transitionMgr || !m_transitionMgr->isInitialized()) + return; + + const QRect rectGeo = m_animationDisabled ? QRect() : animationRect(iconIndex); + auto *rawRect = m_transitionMgr->get_window_transition_rect(tokenStr); + if (!rawRect) + return; + + // Each launched window gets its own persistent rect. The rect + // stays alive (associated with that window) so it can drive + // that window's close animation. We must NOT destroy a + // previous rect here: doing so clears the close animation of + // a still-open earlier window. + auto *rect = new WindowTransitionRect(rawRect, this); + // When the compositor reports that the rect's target window + // has closed, the rect is useless: destroy it and drop it + // from our list. + connect(rect, &WindowTransitionRect::closed, this, [this, rect] { + for (int i = 0; i < m_rects.size(); ++i) { + if (m_rects.at(i).rect == rect) { + m_rects.removeAt(i); + break; + } + } + rect->destroy(); + }); + rect->set_geometry(rectGeo.x(), rectGeo.y(), rectGeo.width(), rectGeo.height()); + // Attach the icon image as a live source surface. The + // compositor renders it as the starting visual of the open + // animation (and the ending visual of the close animation). + auto *display = getWlDisplay(this); + if (display) { + if (m_commitSourceBuffer) + rect->setSourceImage(display, iconPixmap(iconIndex)); + else + rect->clearSourceImage(); + } + m_rects.append(LiveRect{ rect, iconIndex }); + + // Make sure the rect and its geometry reach the compositor before the + // token is handed to the target window, which may activate as soon as + // it starts. + if (display) + display->flushRequests(); + } + + void launchReceiver(int iconIndex) + { + auto *token = createLaunchToken(); + if (!token) + return; + connect(token, &ActivationToken::done, this, [this, iconIndex](const QString &tokenStr) { + createTransitionRect(tokenStr, iconIndex); + onReceiverToken(tokenStr); + }); + } + + void launchModal(int iconIndex) + { + auto *token = createLaunchToken(); + if (!token) + return; + connect(token, &ActivationToken::done, this, [this, iconIndex](const QString &tokenStr) { + createTransitionRect(tokenStr, iconIndex); + onModalToken(tokenStr); + }); + } + + void onReceiverToken(const QString &tokenStr) + { + qInfo() << "Got activation token, launching receiver..."; + + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert("XDG_ACTIVATION_TOKEN", tokenStr); + + // Do NOT parent the QProcess to this window: QProcess kills its + // child on destruction, so closing the sender would tear down the + // launched receiver along with it. Keep it parentless and let it + // self-clean once the receiver exits. + auto *proc = new QProcess; + proc->setProcessEnvironment(env); + connect(proc, &QProcess::finished, proc, &QProcess::deleteLater); + proc->start(QCoreApplication::applicationFilePath(), { "--receiver" }); + } + + void onModalToken(const QString &tokenStr) + { + qInfo() << "Got activation token, showing modal..."; + + if (!m_actMgr || !m_actMgr->isInitialized()) { + qWarning() << "xdg_activation_v1 not available"; + return; + } + + // A modal QDialog maps to an xdg_toplevel with set_parent + xdg_dialog + // set_modal. WA_ShowWithoutActivating makes Qt skip its own + // requestActivateOnShow, so we drive activation (and thus the window + // transition) with the token we just created, mirroring the receiver. + auto *dlg = new QDialog(this); + dlg->setWindowTitle("Modal (Enter)"); + dlg->setModal(true); + dlg->setAttribute(Qt::WA_ShowWithoutActivating, true); + dlg->setAttribute(Qt::WA_DeleteOnClose, true); + + auto *layout = new QVBoxLayout(dlg); + auto *label = new QLabel("Modal launched via xdg_activation + window transition rect", dlg); + label->setAlignment(Qt::AlignCenter); + layout->addWidget(label); + dlg->resize(400, 300); + + dlg->show(); + + ::wl_surface *surface = getWlSurface(dlg); + if (!surface) { + qWarning() << "Cannot get wl_surface for modal"; + return; + } + m_actMgr->activate(tokenStr, surface); + } + + XdgActivationManager *m_actMgr; + WindowTransitionManager *m_transitionMgr; + + struct LiveRect + { + WindowTransitionRect *rect; + int iconIndex; + }; + + QList m_rects; + QList m_iconWidgets; + QList m_iconCaptions{ QStringLiteral("SPACE"), QStringLiteral(".") }; + int m_paletteIndex = 0; + QLabel *m_sourceBufferIndicator = nullptr; + bool m_commitSourceBuffer = true; + bool m_animationDisabled = false; +}; + +// --------------------------------------------------------------------------- +// Receiver (Application B) +// --------------------------------------------------------------------------- + +class ReceiverWindow : public QMainWindow +{ + Q_OBJECT +public: + explicit ReceiverWindow(XdgActivationManager *actMgr) + : m_actMgr(actMgr) + , m_image(createTestImage("Window\nTransition", + QColor(63, 81, 181), + QColor(33, 150, 243), + 800, + 600)) + { + setWindowTitle("Window Transition - Receiver"); + resize(800, 600); + + auto *central = new IconWidget(m_image, this); + setCentralWidget(central); + } + + void activateWithToken() + { + QByteArray tokenEnv = qgetenv("XDG_ACTIVATION_TOKEN"); + if (tokenEnv.isEmpty()) { + qWarning() << "No XDG_ACTIVATION_TOKEN set"; + return; + } + qputenv("XDG_ACTIVATION_TOKEN", ""); + + if (!m_actMgr || !m_actMgr->isInitialized()) { + qWarning() << "xdg_activation_v1 not available"; + return; + } + + ::wl_surface *surface = getWlSurface(this); + if (!surface) { + qWarning() << "Cannot get wl_surface for receiver"; + return; + } + + qInfo() << "Activating with token..."; + m_actMgr->activate(QString::fromUtf8(tokenEnv), surface); + } + +private: + XdgActivationManager *m_actMgr; + QPixmap m_image; +}; + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +int main(int argc, char **argv) +{ + QApplication app(argc, argv); + + bool receiverMode = false; + for (int i = 1; i < argc; ++i) { + if (qstrcmp(argv[i], "--receiver") == 0) + receiverMode = true; + } + + XdgActivationManager actMgr; + WindowTransitionManager transitionMgr; + + if (receiverMode) { + ReceiverWindow window(&actMgr); + window.show(); + window.activateWithToken(); + return app.exec(); + } else { + SenderWindow window(&actMgr, &transitionMgr); + window.show(); + return app.exec(); + } +} + +#include "main.moc" From 06483f2fc55649a43dfc9e2a7ca284c103c3acfa Mon Sep 17 00:00:00 2001 From: guolin Date: Wed, 14 Oct 2026 16:14:28 +0800 Subject: [PATCH 3/3] test(protocols): add window transition protocol test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a protocol test client for treeland-window-transition-unstable-v1. It reads the production pending rect set and the armed transition back on the compositor thread, so association, token supersede, geometry and error paths are asserted on compositor state, not on dispatched calls. 新增 treeland-window-transition-unstable-v1 协议测试客户端。断言在 compositor 线程回读生产 pending 矩形集合与被武装的 transition,覆盖 激活关联、同 token 覆盖、几何更新与协议错误路径。 Log: 添加窗口过渡协议测试 PMS: TASK-395857 Influence: 仅新增协议测试与测试文档,不影响合成器行为。 --- tests/protocols/CMakeLists.txt | 1 + tests/protocols/INDEX.md | 14 +- .../CMakeLists.txt | 8 + .../README.md | 69 ++ .../setup.cpp | 84 ++ .../treeland-window-transition-unstable-v1.c | 878 ++++++++++++++++++ .../treeland-window-transition-unstable-v1.h | 44 + 7 files changed, 1092 insertions(+), 6 deletions(-) create mode 100644 tests/protocols/treeland-window-transition-unstable-v1/CMakeLists.txt create mode 100644 tests/protocols/treeland-window-transition-unstable-v1/README.md create mode 100644 tests/protocols/treeland-window-transition-unstable-v1/setup.cpp create mode 100644 tests/protocols/treeland-window-transition-unstable-v1/treeland-window-transition-unstable-v1.c create mode 100644 tests/protocols/treeland-window-transition-unstable-v1/treeland-window-transition-unstable-v1.h diff --git a/tests/protocols/CMakeLists.txt b/tests/protocols/CMakeLists.txt index 6693143c3e..b313acd35d 100644 --- a/tests/protocols/CMakeLists.txt +++ b/tests/protocols/CMakeLists.txt @@ -67,6 +67,7 @@ add_subdirectory(treeland-show-desktop-v1) add_subdirectory(treeland-xwindow-control-unstable-v1) add_subdirectory(treeland-region-watch-unstable-v1) add_subdirectory(treeland-show-desktop-desktop-v1) +add_subdirectory(treeland-window-transition-unstable-v1) add_subdirectory(treeland-wine-window-state-unstable-v1) add_subdirectory(treeland-wine-window-management-unstable-v1) diff --git a/tests/protocols/INDEX.md b/tests/protocols/INDEX.md index 3d672d88a9..517692540a 100644 --- a/tests/protocols/INDEX.md +++ b/tests/protocols/INDEX.md @@ -52,6 +52,7 @@ Wayland 线上请求与事件;本文档规定发出请求后,测试必须观 | [treeland-xwindow-control-unstable-v1](treeland-xwindow-control-unstable-v1/README.md) | P | `set_xwindow_position_relative` 失败回调;成功路径需 Xwayland,当前环境未覆盖 | | [wine-window-management-unstable-v1](treeland-wine-window-management-unstable-v1/README.md) | P / E | 真实 wrapper 的位置与置顶层同步 | | [wine-window-state-unstable-v1](treeland-wine-window-state-unstable-v1/README.md) | P / E | 真实 wrapper 的最小化、attention 与可见性同步 | +| [window-transition-unstable-v1](treeland-window-transition-unstable-v1/README.md) | P / I / E | transition rect 进入生产 pending 集合、同 token 覆盖发一次 `closed`、旧 rect 析构不误删新 rect、销毁回收;map 前激活把**未 map** 的目标 arm 成 40×40 带 source surface(同 token 消费、他 token 不受影响),并跟随几何更新、0×0 禁用/重启用、source 清空/重设;激活迟到与无 originating surface 各发一次 `closed`;`invalid_geometry` / `invalid_surface` 协议错误 | | [drm (`wl_drm`)](drm/README.md) | P / V(GPU 条件) | wlroots native global、GBM DMA-BUF 与真实纹理 readback | | [input-method-unstable-v2](input-method-unstable-v2/README.md) | P / E | focused text-input 与 input method 的生产状态和编辑回传 | | [virtual-keyboard-unstable-v1](virtual-keyboard-unstable-v1/README.md) | P / E | 有效 keymap 的真实 seat 与 shortcut 链路 | @@ -104,13 +105,13 @@ xdg-shell 由共享 xdg-toplevel 夹具覆盖(多个 toplevel 测试复用) request stub 算作 request 覆盖;生成的 client-protocol 文件本身不计入。`destroy` 也单列, 因为它证明资源生命周期,却通常不承载主要业务语义。 -- **已注册并有测试目录的 24 个当前协议**:XML 共 221 条 request,其中测试客户端直接 - 调用了 **167 条(75.6%)**。 -- 去掉 59 条 `destroy` 生命周期 request 后,剩余 162 条工厂、配置和业务 request 中有 - **112 条(69.1%)** 被直接调用。 -- 24 个协议中 **19 个(79.2%)** 至少有一条 E 级生产业务链路;仅 DDM、output-manager +- **已注册并有测试目录的 25 个当前协议**:XML 共 226 条 request,其中测试客户端直接 + 调用了 **172 条(76.1%)**。 +- 去掉 61 条 `destroy` 生命周期 request 后,剩余 165 条工厂、配置和业务 request 中有 + **115 条(69.7%)** 被直接调用。 +- 25 个协议中 **20 个(80.0%)** 至少有一条 E 级生产业务链路;仅 DDM、output-manager color-control、wallpaper-color 仍停留在 I/P 层。 -- 当前 XML 共有 114 条 server event。这里**不发布“事件百分比”**:listener 中出现一个 +- 当前 XML 共有 115 条 server event。这里**不发布“事件百分比”**:listener 中出现一个 callback、或 fixture 手工发出一次 event,都不能证明事件负载或其业务来源被断言。下表只 列出已实际断言的关键 event,并明确列出尚未逐项验证的 event。 @@ -141,6 +142,7 @@ request stub 算作 request 覆盖;生成的 client-protocol 文件本身不 | layer-shell-extension-unstable-v1 | 4 / 4 | `begin_resize` 的 `resize_rejected(bad_serial)` | 成功 resize、clamp、`resizing` 生命周期 | | wine-window-management-unstable-v1 | 5 / 5 | `window_id/configure_position/configure_stacking`;真实 QQuickItem 位置/Z 值 | bottom/insert-after、多窗口 sibling、无效 sibling、重复 bind、越界坐标 | | wine-window-state-unstable-v1 | 6 / 7 | `state_changed`;真实最小化、attention 与可见性 | `activate/activate_denied`、重复 bind、toplevel 销毁后的 inert 状态 | +| window-transition-unstable-v1 | 5 / 5 | `get_window_transition_rect` 进入生产 pending 集合;同 token 覆盖发一次 `closed`,旧 rect 析构不误删当前 rect;销毁后集合移除;map 前激活把未 map 的目标 arm 成 40×40 带 source surface(同 token 消费、他 token 不受影响),并跟随几何更新、0×0 禁用/重启用、source 清空/重设;激活迟到与无 originating surface 各发一次 `closed`;`invalid_geometry` / `invalid_surface` 协议错误 | 目标销毁触发的 `closed`、60 s sweep、跨 client 的 `invalid_surface`、rect 全局位置、source surface 身份 | \* XML 共有 37 条 request;其中 manager `destroy` 的 `since=2` 高于生产 global 的 v1。 测试保留该调用用于兼容性/错误边界,正常可发布的 v1 request 集为其余 36 条。 diff --git a/tests/protocols/treeland-window-transition-unstable-v1/CMakeLists.txt b/tests/protocols/treeland-window-transition-unstable-v1/CMakeLists.txt new file mode 100644 index 0000000000..3324c22d6a --- /dev/null +++ b/tests/protocols/treeland-window-transition-unstable-v1/CMakeLists.txt @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: Apache-2.0 OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only +treeland_add_protocol_test( + NAME treeland_window_transition_unstable_v1 + XML "${TREELAND_PROTOCOLS_DATA_DIR}/treeland-window-transition-unstable-v1.xml" + SETUP "${CMAKE_CURRENT_SOURCE_DIR}/setup.cpp" + CLIENT "${CMAKE_CURRENT_SOURCE_DIR}/treeland-window-transition-unstable-v1.c" + EXTRA_XMLS "${WAYLAND_PROTOCOLS_DATADIR}/staging/xdg-activation/xdg-activation-v1.xml" +) diff --git a/tests/protocols/treeland-window-transition-unstable-v1/README.md b/tests/protocols/treeland-window-transition-unstable-v1/README.md new file mode 100644 index 0000000000..c2d8716164 --- /dev/null +++ b/tests/protocols/treeland-window-transition-unstable-v1/README.md @@ -0,0 +1,69 @@ +# `treeland-window-transition-unstable-v1` 测试规范 + +## 范围 + +- XML / interface:`treeland_window_transition_manager_v1` / `treeland_window_transition_rect_v1` +- 测试源码:`tests/protocols/treeland-window-transition-unstable-v1/` +- Fixture:desktop integration fixture(`protocol_test_setup` 取得生产 + `WindowTransitionManagerInterfaceV1`、创建 headless output,并记录 + `ShellHandler::surfaceWrapperAdded` 的 wrapper 以便回读真实 transition 状态) +- 覆盖等级:**P / I / E** + +`treeland_window_transition_manager_v1.get_window_transition_rect` 为激活 token 创建 +transition rect。测试通过 server bridge 在 compositor 线程回读生产 +`WindowTransitionManagerInterfaceV1::hasPendingWindowTransitionRect()`,证明 rect 真的进入 +`m_pendingRects`,而不只是请求可被派发。同一 token 再次创建会让前一个 rect 收到一次 +`closed`;客户端销毁 rect 后 pending 集合移除,而销毁被覆盖的旧 rect 不会误删当前 rect。 + +E 级场景走真实 `xdg-activation-v1` 链路:源 toplevel 用激活 token 字符串创建 rect +(geometry + source surface),目标 toplevel 在 map 前 `activate` 该 token,生产侧 +`associatePendingRect()` 消费 pending rect 并武装目标 `SurfaceWindowTransition`;回读 +`globalRect()` 的尺寸、`active()` 与 `sourceSurface()`,以及被武装 wrapper 的 mapped 状态, +因此断言落在"被武装的是那个尚未 map 的目标窗口"上,而不是"某个窗口被 arm 了"。 + +两条协议错误路径各自使用独立连接(`wl_resource_post_error` 会终止连接),并断言 +`wl_display_get_protocol_error()` 的 interface 与 code。 + +## 必须观察到的结果 + +| 场景 | 客户端动作 | 必须观察到的结果 | 证据层级 | +| --- | --- | --- | --- | +| pending 存储 | `get_window_transition_rect(token)` | 生产 `hasPendingWindowTransitionRect(token)` == 1,且没有任何 transition 被武装 | I | +| 过期前不关闭 | 已存储的 pending rect | 未收到 `closed` | P | +| 同 token 覆盖 | 对同一 token 再 `get_window_transition_rect` | 前一个 rect 收到恰好 1 次 `closed`,新的不关闭;再 roundtrip 不重复 | P | +| 旧 rect 析构 | 销毁被覆盖的旧 rect | 该 token 仍在 pending 集合(新 rect 未被误删) | I | +| 客户端销毁 | `treeland_window_transition_rect_v1.destroy` | pending 集合移除该 token | I | +| 激活关联 | 目标 map 前 `xdg_activation_v1.activate(token, target)` | 恰好 1 个 transition 被武装,且属于**未 map** 的窗口,带 40×40 的 rect 与 source surface;该 token 的 pending 被消费,另一个 token 的 rect 不被消费 | E | +| 几何更新 | 关联后 `set_geometry(3, 4, 20, 30)` | 被武装的 rect 变为 20×30,仍 armed | E | +| 0×0 禁用 | `set_geometry(0, 0, 0, 0)` | 不再 `active()`(armed_count == 0),但关联仍在(rect_present == 1,尺寸 0×0) | E | +| 重新启用 | `set_geometry(0, 0, 40, 40)` | 重新 `active()`,rect 40×40 | E | +| source 清空 / 重设 | `set_source_surface(NULL)`,随后连续两次设同一个 surface | 清空后无 source surface 且仍 armed;重设后 source surface 回来且不报错 | E | +| 激活迟到 | 目标已 map 后 `xdg_activation_v1.activate(token, target)` | rect 收到恰好 1 次 `closed`,pending 清空,且没有任何 transition 被武装 | E | +| 无 originating surface | token 未 `set_surface`,目标未 map 时 `activate` | rect 收到恰好 1 次 `closed`,pending 清空,且没有任何 transition 被武装 | E | +| 负几何 | `set_geometry(0, 0, -1, 4)` | `invalid_geometry` 协议错误 | P | +| 角色冲突 | 对已带 role 的 mapped toplevel surface `set_source_surface` | `invalid_surface` 协议错误 | P | + +## 生产结果 + +- `WindowTransitionManagerInterfaceV1::m_pendingRects` 的真实内容,经 + `hasPendingWindowTransitionRect()` 回读;`consume` 与 `discard` 都以 token 为键,因此 + 另一个 token 的 rect 会留在集合里。 +- `closed` 事件来自 `WindowTransitionRectV1::notifyDiscarded()`(同 token 覆盖、激活迟到、 + 无 originating surface 三条路径),断言按次数而非布尔,覆盖协议"至多一次"的约定。 +- 被武装目标的 `SurfaceWindowTransition` 状态经 `globalRect()`(尺寸)、`active()`、 + `sourceSurface()` 与所属 wrapper 的 `mapped()` 回读,对应 `Helper` 的 + `associatePendingRect()`;几何更新、0×0 禁用与 source 清空分别对应 + `updateWindowTransitionRect()` / `setWindowTransitionSource()` / + `clearWindowTransitionSource()`。 + +## 已知边界 / 下一项结果 + +- 未覆盖目标销毁触发的 rect `closed`:依赖关闭动画时序,客户端侧没有 roundtrip 之外 + 的确定性同步点。 +- 未覆盖 `set_geometry` 的 60 s sweep 过期:需要真实等待或给常量加测试注入点。 +- 未覆盖跨 client 的 `invalid_surface` 分支(生产里的 `wl_resource_get_client` 不匹配): + libwayland-server 对不属于本连接的对象 id 解析为 NULL,生产代码会走"清空 source" + 而不是报错,该分支是否可达需实测确认。 +- 只断言 rect 的尺寸,未断言其全局位置(位置依赖 output 布局与源窗口位置)。 +- 只断言 source surface 存在,未断言它就是客户端设置的那个 surface(wrapper 未暴露 + surface 标识;加强这一断言需要生产侧新增访问器)。 diff --git a/tests/protocols/treeland-window-transition-unstable-v1/setup.cpp b/tests/protocols/treeland-window-transition-unstable-v1/setup.cpp new file mode 100644 index 0000000000..a468ec107b --- /dev/null +++ b/tests/protocols/treeland-window-transition-unstable-v1/setup.cpp @@ -0,0 +1,84 @@ +// Copyright (C) 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: Apache-2.0 OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only +#include "server-bridge.h" +#include "treeland-window-transition-unstable-v1.h" + +#include "core/shellhandler.h" +#include "core/waylandserver.h" +#include "modules/window-transition/windowtransitionmanagerinterfacev1.h" +#include "surface/surfacewindowtransition.h" +#include "surface/surfacewrapper.h" + +#include +#include +#include +#include +#include + +#include + +namespace { +WindowTransitionManagerInterfaceV1 *g_manager = nullptr; +QList> g_wrappers; +} + +void protocol_test_setup(WaylandServer *server) +{ + g_manager = find_server_interface(server); + Q_ASSERT(g_manager); + add_headless_output(server->backend(), false); + QObject::connect(server->shellHandler(), + &ShellHandler::surfaceWrapperAdded, + server, + [](SurfaceWrapper *wrapper) { + g_wrappers.append(QPointer(wrapper)); + }); +} + +extern "C" void window_transition_read_server_state(void *data) +{ + auto *state = static_cast(data); + state->manager_present = g_manager ? 1 : 0; + state->pending = 0; + state->armed_count = 0; + state->rect_present = 0; + state->rect_mapped = 0; + state->rect_w = 0; + state->rect_h = 0; + state->source_set = 0; + if (g_manager && state->token[0] != '\0') { + state->pending = g_manager->hasPendingWindowTransitionRect( + QString::fromUtf8(state->token)) + ? 1 + : 0; + } + + // Report the rectangle the production manager associated with a real + // SurfaceWindowTransition. globalRect() resolves only for a transition that + // holds the manager's rect, so its value names the armed window instead of + // trusting the first wrapper that happens to be active. + for (const auto &wrapper : std::as_const(g_wrappers)) { + if (!wrapper) + continue; + auto *transition = + qobject_cast(wrapper->windowTransition()); + if (!transition) + continue; + if (transition->active()) + ++state->armed_count; + + // The rect survives a 0x0 geometry (that only disables it), so presence + // and size are reported separately from active(). + if (state->rect_present) + continue; + const auto rect = transition->globalRect(); + if (!rect) + continue; + state->rect_present = 1; + state->rect_mapped = + (wrapper->surface() && wrapper->surface()->mapped()) ? 1 : 0; + state->rect_w = qRound(rect->width()); + state->rect_h = qRound(rect->height()); + state->source_set = transition->sourceSurface() ? 1 : 0; + } +} diff --git a/tests/protocols/treeland-window-transition-unstable-v1/treeland-window-transition-unstable-v1.c b/tests/protocols/treeland-window-transition-unstable-v1/treeland-window-transition-unstable-v1.c new file mode 100644 index 0000000000..8a16d668c5 --- /dev/null +++ b/tests/protocols/treeland-window-transition-unstable-v1/treeland-window-transition-unstable-v1.c @@ -0,0 +1,878 @@ +// Copyright (C) 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: Apache-2.0 OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only +/* + * Coverage level P / I / E. + * + * The client drives treeland_window_transition_manager_v1 and reads back the + * production WindowTransitionManagerInterfaceV1 pending set and the armed + * SurfaceWindowTransition through the server bridge, so stored/associated + * rectangles are proven rather than a dispatchable request. + * + * The activation scenarios need a real xdg-activation-v1 token: an originating + * mapped toplevel attaches the rectangle to the token, then a target toplevel + * activates the token before it maps (association, which also proves the armed + * window is the unmapped target) or after it maps (the "activation arrived too + * late" fallback that closes the rectangle). + * + * The protocol-error paths each use their own connection because + * wl_resource_post_error tears the connection down. + */ + +#include "client-connection.h" +#include "server-bridge-api.h" +#include "treeland-window-transition-unstable-v1-client-protocol.h" +#include "treeland-window-transition-unstable-v1.h" +#include "xdg-activation-v1-client-protocol.h" +#include "xdg-toplevel-client.h" + +#include +#include + +#define WINDOW_TRANSITION_MANAGER "treeland_window_transition_manager_v1" + +struct rect_events { + int closed_count; +}; + +static void rect_handle_closed(void *data, struct treeland_window_transition_rect_v1 *rect) +{ + (void)rect; + ((struct rect_events *)data)->closed_count++; +} + +static const struct treeland_window_transition_rect_v1_listener rect_listener = { + .closed = rect_handle_closed, +}; + +static struct treeland_window_transition_rect_v1 *create_rect( + struct treeland_window_transition_manager_v1 *manager, + const char *token, + struct rect_events *events) +{ + struct treeland_window_transition_rect_v1 *rect = + treeland_window_transition_manager_v1_get_window_transition_rect(manager, token); + if (rect) + treeland_window_transition_rect_v1_add_listener(rect, &rect_listener, events); + return rect; +} + +static int read_state(const char *token, struct window_transition_server_state *state) +{ + memset(state, 0, sizeof(*state)); + snprintf(state->token, sizeof(state->token), "%s", token); + if (!invoke_on_server_thread(window_transition_read_server_state, state)) + return 0; + return state->manager_present; +} + +/* 1 when the token is in the production pending set, 0 when it is not, -1 when + * the production state could not be read at all. */ +static int read_pending(const char *token) +{ + struct window_transition_server_state state; + if (!read_state(token, &state)) + return -1; + return state.pending; +} + +/* + * The associated rectangle is described by the production wrapper the manager + * armed, so these assertions are about the real window transition rather than + * about the request being dispatched. + */ +static int expect_association(const struct window_transition_server_state *state, + int expected_mapped) +{ + if (state->armed_count != 1) { + TEST_ERROR("window-transition: expected exactly one armed transition, got %d\n", + state->armed_count); + return 0; + } + if (!state->rect_present) { + TEST_ERROR("window-transition: armed transition exposes no rectangle\n"); + return 0; + } + if (state->rect_mapped != expected_mapped) { + TEST_ERROR("window-transition: armed transition mapped=%d, expected %d\n", + state->rect_mapped, expected_mapped); + return 0; + } + if (!state->source_set) { + TEST_ERROR("window-transition: armed transition has no source surface\n"); + return 0; + } + return 1; +} + +static int expect_rect_size(const struct window_transition_server_state *state, + int width, + int height) +{ + if (!state->rect_present) { + TEST_ERROR("window-transition: no associated rectangle to size\n"); + return 0; + } + if (state->rect_w != width || state->rect_h != height) { + TEST_ERROR("window-transition: associated rect is %dx%d, expected %dx%d\n", + state->rect_w, state->rect_h, width, height); + return 0; + } + return 1; +} + +struct activation_token_state { + int done; + char token[128]; +}; + +static void token_handle_done(void *data, + struct xdg_activation_token_v1 *token, + const char *token_string) +{ + (void)token; + struct activation_token_state *state = data; + state->done = 1; + if (token_string) + snprintf(state->token, sizeof(state->token), "%s", token_string); +} + +static const struct xdg_activation_token_v1_listener token_listener = { + .done = token_handle_done, +}; + +/* + * Obtains a committed activation token string. `originating` may be NULL, which + * leaves the token without the set_surface hint: the compositor then has no + * surface to map the rectangle to and must discard the pending rect. + */ +static int obtain_token(struct client_connection *connection, + struct xdg_activation_v1 *activation, + struct wl_surface *originating, + char *token, + size_t token_size) +{ + struct xdg_activation_token_v1 *request = xdg_activation_v1_get_activation_token(activation); + if (!request) + return 0; + + struct activation_token_state state = { 0 }; + if (originating) + xdg_activation_token_v1_set_surface(request, originating); + xdg_activation_token_v1_add_listener(request, &token_listener, &state); + xdg_activation_token_v1_commit(request); + + const int ok = wl_display_roundtrip(connection->display) >= 0 + && state.done && state.token[0] != '\0'; + if (ok) + snprintf(token, token_size, "%s", state.token); + xdg_activation_token_v1_destroy(request); + return ok; +} + +static int run_pending_and_events(const char *socket_name) +{ + struct client_connection connection = { 0 }; + if (!client_connect(&connection, socket_name)) + return 0; + + struct treeland_window_transition_manager_v1 *manager = + client_bind(&connection, + WINDOW_TRANSITION_MANAGER, + &treeland_window_transition_manager_v1_interface, + treeland_window_transition_manager_v1_interface.version); + if (!manager) { + TEST_ERROR("window-transition: manager missing\n"); + client_disconnect(&connection); + return 0; + } + + int ok = 1; + struct window_transition_server_state state; + + /* A rectangle created for a token is kept in the production pending set. */ + struct rect_events events_a = { 0 }; + struct treeland_window_transition_rect_v1 *rect_a = + create_rect(manager, "treeland-transition-token-a", &events_a); + if (!rect_a || wl_display_roundtrip(connection.display) < 0) { + TEST_ERROR("window-transition: failed to create the first rectangle\n"); + ok = 0; + } else if (!read_state("treeland-transition-token-a", &state)) { + TEST_ERROR("window-transition: production manager not reachable\n"); + ok = 0; + } else if (!state.pending) { + TEST_ERROR("window-transition: pending rect for token-a not stored\n"); + ok = 0; + } else if (state.armed_count != 0 || state.rect_present != 0) { + TEST_ERROR("window-transition: a stored rect armed a transition by itself\n"); + ok = 0; + } else if (events_a.closed_count) { + TEST_ERROR("window-transition: pending rect closed prematurely\n"); + ok = 0; + } + + /* Creating another rectangle for the same token closes the previous one. */ + struct rect_events events_first = { 0 }; + struct rect_events events_second = { 0 }; + struct treeland_window_transition_rect_v1 *rect_first = NULL; + struct treeland_window_transition_rect_v1 *rect_second = NULL; + if (ok) { + rect_first = create_rect(manager, "treeland-transition-token-dup", &events_first); + rect_second = create_rect(manager, "treeland-transition-token-dup", &events_second); + if (!rect_first || !rect_second || wl_display_roundtrip(connection.display) < 0) { + TEST_ERROR("window-transition: failed to create the superseded pair\n"); + ok = 0; + } else if (events_first.closed_count != 1) { + TEST_ERROR("window-transition: superseded rect got %d closed events, expected 1\n", + events_first.closed_count); + ok = 0; + } else if (events_second.closed_count) { + TEST_ERROR("window-transition: replacement rect closed unexpectedly\n"); + ok = 0; + } + } + + /* closed is a one-shot notification; the superseded rect must not repeat it. */ + if (ok) { + if (wl_display_roundtrip(connection.display) < 0) { + ok = 0; + } else if (events_first.closed_count != 1) { + TEST_ERROR("window-transition: superseded rect repeated closed\n"); + ok = 0; + } + } + + /* Destroying the superseded rect must not drop the replacement from the + * pending set: only the rect the manager currently holds for the token is + * removed. */ + if (ok) { + treeland_window_transition_rect_v1_destroy(rect_first); + rect_first = NULL; + if (wl_display_roundtrip(connection.display) < 0) { + ok = 0; + } else if (read_pending("treeland-transition-token-dup") != 1) { + TEST_ERROR("window-transition: destroying the superseded rect dropped the" + " replacement\n"); + ok = 0; + } + } + + /* Destroying the client object removes it from the pending set. */ + struct rect_events events_idle = { 0 }; + struct treeland_window_transition_rect_v1 *rect_idle = NULL; + if (ok) { + rect_idle = create_rect(manager, "treeland-transition-token-idle", &events_idle); + if (!rect_idle || wl_display_roundtrip(connection.display) < 0) { + TEST_ERROR("window-transition: failed to create the recyclable rect\n"); + ok = 0; + } else { + treeland_window_transition_rect_v1_destroy(rect_idle); + rect_idle = NULL; + if (wl_display_roundtrip(connection.display) < 0) { + ok = 0; + } else if (read_pending("treeland-transition-token-idle") != 0) { + TEST_ERROR("window-transition: destroyed rect still pending\n"); + ok = 0; + } + } + } + + if (rect_idle) + treeland_window_transition_rect_v1_destroy(rect_idle); + if (rect_second) + treeland_window_transition_rect_v1_destroy(rect_second); + if (rect_first) + treeland_window_transition_rect_v1_destroy(rect_first); + if (rect_a) + treeland_window_transition_rect_v1_destroy(rect_a); + treeland_window_transition_manager_v1_destroy(manager); + client_disconnect(&connection); + return ok; +} + +/* + * E: once the rectangle is associated the object stays live. The armed + * transition must follow later set_geometry calls, a 0x0 geometry must disable + * it without dropping the association, and the source surface may be cleared + * and attached again. + */ +struct associated_rect { + struct wl_display *display; + struct treeland_window_transition_rect_v1 *rect; + struct wl_surface *source; + struct rect_events *events; + const char *token; +}; + +static int run_associated_rect_updates(const struct associated_rect *associated) +{ + struct window_transition_server_state state; + int ok = 1; + + /* The client may update the geometry; the armed transition must follow the + * latest value. */ + treeland_window_transition_rect_v1_set_geometry(associated->rect, 3, 4, 20, 30); + if (wl_display_roundtrip(associated->display) < 0) { + ok = 0; + } else if (!read_state(associated->token, &state)) { + ok = 0; + } else if (!expect_association(&state, 0) || !expect_rect_size(&state, 20, 30)) { + ok = 0; + } + + /* A 0x0 geometry is the documented way to disable the rectangle: the + * association stays alive, only the animation source goes away. */ + if (ok) { + treeland_window_transition_rect_v1_set_geometry(associated->rect, 0, 0, 0, 0); + if (wl_display_roundtrip(associated->display) < 0) { + ok = 0; + } else if (!read_state(associated->token, &state)) { + ok = 0; + } else if (state.armed_count != 0) { + TEST_ERROR("window-transition: 0x0 geometry did not disable the rect\n"); + ok = 0; + } else if (!state.rect_present) { + TEST_ERROR("window-transition: 0x0 geometry dropped the association\n"); + ok = 0; + } else if (!expect_rect_size(&state, 0, 0)) { + ok = 0; + } + } + + /* ...and a positive size again re-enables it. */ + if (ok) { + treeland_window_transition_rect_v1_set_geometry(associated->rect, 0, 0, 40, 40); + if (wl_display_roundtrip(associated->display) < 0) { + ok = 0; + } else if (!read_state(associated->token, &state)) { + ok = 0; + } else if (!expect_association(&state, 0) || !expect_rect_size(&state, 40, 40)) { + ok = 0; + } + } + + /* Passing null clears the source surface without disarming the rectangle. */ + if (ok) { + treeland_window_transition_rect_v1_set_source_surface(associated->rect, NULL); + if (wl_display_roundtrip(associated->display) < 0) { + ok = 0; + } else if (!read_state(associated->token, &state)) { + ok = 0; + } else if (state.source_set) { + TEST_ERROR("window-transition: null source surface did not clear the source\n"); + ok = 0; + } else if (state.armed_count != 1 || !state.rect_present) { + TEST_ERROR("window-transition: clearing the source disarmed the rect\n"); + ok = 0; + } + } + + /* ...and the same surface may be attached again, repeatedly. */ + if (ok) { + treeland_window_transition_rect_v1_set_source_surface(associated->rect, associated->source); + treeland_window_transition_rect_v1_set_source_surface(associated->rect, associated->source); + if (wl_display_roundtrip(associated->display) < 0) { + ok = 0; + } else if (!read_state(associated->token, &state)) { + ok = 0; + } else if (!expect_association(&state, 0) || !expect_rect_size(&state, 40, 40)) { + ok = 0; + } else if (associated->events->closed_count) { + TEST_ERROR("window-transition: associated rect was closed during the updates\n"); + ok = 0; + } + } + + return ok; +} + +/* + * E: an originating toplevel attaches a rectangle (geometry + live source + * surface) to an activation token, and a target toplevel activates that token + * before it maps. The production manager must consume the pending rect and arm + * the still unmapped target with the rectangle and the source surface. + */ +static int run_associate_on_activation(const char *socket_name) +{ + struct client_connection connection = { 0 }; + struct xdg_toplevel_client origin = { 0 }; + struct xdg_toplevel_client target = { 0 }; + struct wl_surface *source = NULL; + struct treeland_window_transition_rect_v1 *rect = NULL; + struct treeland_window_transition_rect_v1 *decoy = NULL; + if (!client_connect(&connection, socket_name)) + return 0; + + struct treeland_window_transition_manager_v1 *manager = + client_bind(&connection, + WINDOW_TRANSITION_MANAGER, + &treeland_window_transition_manager_v1_interface, + treeland_window_transition_manager_v1_interface.version); + struct xdg_activation_v1 *activation = + client_bind(&connection, + xdg_activation_v1_interface.name, + &xdg_activation_v1_interface, + xdg_activation_v1_interface.version); + struct wl_compositor *compositor = + client_bind(&connection, "wl_compositor", &wl_compositor_interface, 1); + if (!manager || !activation || !compositor) { + TEST_ERROR("window-transition: missing manager, activation or compositor\n"); + client_disconnect(&connection); + return 0; + } + + int ok = 1; + char token[128]; + struct window_transition_server_state state; + + /* A: the mapped originating toplevel that owns the activation token. */ + if (!xdg_toplevel_client_create(&connection, &origin)) { + TEST_ERROR("window-transition: failed to create the originating toplevel\n"); + ok = 0; + } + + if (ok) { + source = wl_compositor_create_surface(compositor); + if (!source) { + TEST_ERROR("window-transition: failed to create the source surface\n"); + ok = 0; + } + } + + if (ok && !obtain_token(&connection, activation, origin.surface, token, sizeof(token))) { + TEST_ERROR("window-transition: failed to obtain activation token\n"); + ok = 0; + } + + struct rect_events events = { 0 }; + if (ok) { + rect = treeland_window_transition_manager_v1_get_window_transition_rect(manager, token); + if (!rect) { + TEST_ERROR("window-transition: failed to create the transition rect\n"); + ok = 0; + } else { + treeland_window_transition_rect_v1_add_listener(rect, &rect_listener, &events); + treeland_window_transition_rect_v1_set_geometry(rect, 0, 0, 40, 40); + treeland_window_transition_rect_v1_set_source_surface(rect, source); + if (wl_display_roundtrip(connection.display) < 0) { + TEST_ERROR("window-transition: rect configuration was rejected\n"); + ok = 0; + } + } + } + + /* A second rectangle under an unrelated token must survive the activation: + * the manager consumes the pending rect of this token only. */ + struct rect_events decoy_events = { 0 }; + if (ok) { + decoy = create_rect(manager, "treeland-transition-token-decoy", &decoy_events); + if (!decoy || wl_display_roundtrip(connection.display) < 0) { + TEST_ERROR("window-transition: failed to create the decoy rectangle\n"); + ok = 0; + } + } + + if (ok) { + if (!read_state(token, &state)) { + TEST_ERROR("window-transition: production manager not reachable\n"); + ok = 0; + } else if (!state.pending) { + TEST_ERROR("window-transition: rect not pending before activation\n"); + ok = 0; + } else if (state.armed_count != 0 || state.rect_present != 0) { + TEST_ERROR("window-transition: a transition was armed before activation\n"); + ok = 0; + } + } + + /* B: the target toplevel, created but not mapped yet. */ + if (ok + && (!xdg_toplevel_client_create_pending(&connection, &target) + || wl_display_roundtrip(connection.display) < 0)) { + TEST_ERROR("window-transition: failed to create the pending target\n"); + ok = 0; + } + + if (ok) { + xdg_activation_v1_activate(activation, token, target.surface); + if (wl_display_roundtrip(connection.display) < 0) { + TEST_ERROR("window-transition: activation was rejected\n"); + ok = 0; + } + } + + /* The pending rect is consumed and the still unmapped target is armed with + * the rectangle and the live source surface. */ + if (ok) { + if (!read_state(token, &state)) { + TEST_ERROR("window-transition: production manager not reachable\n"); + ok = 0; + } else if (state.pending) { + TEST_ERROR("window-transition: pending rect survived association\n"); + ok = 0; + } else if (!expect_association(&state, 0)) { + ok = 0; + } else if (!expect_rect_size(&state, 40, 40)) { + ok = 0; + } else if (events.closed_count) { + TEST_ERROR("window-transition: associated rect was closed while the target lives\n"); + ok = 0; + } else if (read_pending("treeland-transition-token-decoy") != 1) { + TEST_ERROR("window-transition: association consumed an unrelated token's rect\n"); + ok = 0; + } else if (decoy_events.closed_count) { + TEST_ERROR("window-transition: decoy rect was closed by the activation\n"); + ok = 0; + } + } + + /* The rectangle stays live after the association. */ + if (ok) { + const struct associated_rect associated = { + .display = connection.display, + .rect = rect, + .source = source, + .events = &events, + .token = token, + }; + ok = run_associated_rect_updates(&associated); + } + + if (decoy) + treeland_window_transition_rect_v1_destroy(decoy); + if (rect) + treeland_window_transition_rect_v1_destroy(rect); + if (source) + wl_surface_destroy(source); + if (origin.surface) + xdg_toplevel_client_destroy(&origin); + if (target.surface) + xdg_toplevel_client_destroy(&target); + client_disconnect(&connection); + return ok; +} + +/* + * E: the target is already mapped when the activation arrives. The compositor + * falls back to its default animation, drops the pending rectangle and closes + * it without arming any transition. + */ +static int run_closed_when_activation_late(const char *socket_name) +{ + struct client_connection connection = { 0 }; + struct xdg_toplevel_client origin = { 0 }; + struct xdg_toplevel_client target = { 0 }; + struct treeland_window_transition_rect_v1 *rect = NULL; + if (!client_connect(&connection, socket_name)) + return 0; + + struct treeland_window_transition_manager_v1 *manager = + client_bind(&connection, + WINDOW_TRANSITION_MANAGER, + &treeland_window_transition_manager_v1_interface, + treeland_window_transition_manager_v1_interface.version); + struct xdg_activation_v1 *activation = + client_bind(&connection, + xdg_activation_v1_interface.name, + &xdg_activation_v1_interface, + xdg_activation_v1_interface.version); + if (!manager || !activation) { + TEST_ERROR("window-transition: missing manager or activation\n"); + client_disconnect(&connection); + return 0; + } + + int ok = 1; + char token[128]; + + /* Both the origin and the target are mapped before the activation. */ + if (!xdg_toplevel_client_create(&connection, &origin)) { + TEST_ERROR("window-transition: failed to create the originating toplevel\n"); + ok = 0; + } + if (ok && !xdg_toplevel_client_create(&connection, &target)) { + TEST_ERROR("window-transition: failed to create the mapped target\n"); + ok = 0; + } + if (ok && !obtain_token(&connection, activation, origin.surface, token, sizeof(token))) { + TEST_ERROR("window-transition: failed to obtain activation token\n"); + ok = 0; + } + + struct rect_events events = { 0 }; + if (ok) { + rect = treeland_window_transition_manager_v1_get_window_transition_rect(manager, token); + if (!rect) { + TEST_ERROR("window-transition: failed to create the transition rect\n"); + ok = 0; + } else { + treeland_window_transition_rect_v1_add_listener(rect, &rect_listener, &events); + treeland_window_transition_rect_v1_set_geometry(rect, 0, 0, 40, 40); + if (wl_display_roundtrip(connection.display) < 0) { + TEST_ERROR("window-transition: rect configuration was rejected\n"); + ok = 0; + } + } + } + + struct window_transition_server_state state; + if (ok) { + if (!read_state(token, &state)) { + TEST_ERROR("window-transition: production manager not reachable\n"); + ok = 0; + } else if (!state.pending) { + TEST_ERROR("window-transition: rect not pending before late activation\n"); + ok = 0; + } + } + + if (ok) { + /* target.surface is already mapped, so the compositor discards the + * pending rect and must emit closed. */ + xdg_activation_v1_activate(activation, token, target.surface); + if (wl_display_roundtrip(connection.display) < 0) { + TEST_ERROR("window-transition: late activation was rejected\n"); + ok = 0; + } + } + + if (ok && events.closed_count != 1) { + TEST_ERROR("window-transition: late activation got %d closed events, expected 1\n", + events.closed_count); + ok = 0; + } + + if (ok) { + if (!read_state(token, &state)) { + ok = 0; + } else if (state.pending) { + TEST_ERROR("window-transition: late activation left the rect pending\n"); + ok = 0; + } else if (state.armed_count != 0 || state.rect_present != 0) { + TEST_ERROR("window-transition: late activation armed a transition\n"); + ok = 0; + } + } + + if (rect) + treeland_window_transition_rect_v1_destroy(rect); + if (origin.surface) + xdg_toplevel_client_destroy(&origin); + if (target.surface) + xdg_toplevel_client_destroy(&target); + client_disconnect(&connection); + return ok; +} + +/* + * E: the activation token carries no originating surface (the client never + * called set_surface). The rectangle cannot be mapped to global coordinates, + * so the compositor must discard the pending rect right away and close it + * instead of keeping it until the pending sweep expires. + */ +static int run_discarded_without_origin_surface(const char *socket_name) +{ + struct client_connection connection = { 0 }; + struct xdg_toplevel_client target = { 0 }; + struct treeland_window_transition_rect_v1 *rect = NULL; + if (!client_connect(&connection, socket_name)) + return 0; + + struct treeland_window_transition_manager_v1 *manager = + client_bind(&connection, + WINDOW_TRANSITION_MANAGER, + &treeland_window_transition_manager_v1_interface, + treeland_window_transition_manager_v1_interface.version); + struct xdg_activation_v1 *activation = + client_bind(&connection, + xdg_activation_v1_interface.name, + &xdg_activation_v1_interface, + xdg_activation_v1_interface.version); + if (!manager || !activation) { + TEST_ERROR("window-transition: missing manager or activation\n"); + client_disconnect(&connection); + return 0; + } + + int ok = 1; + char token[128]; + struct window_transition_server_state state; + + /* The target is a real toplevel but stays unmapped, so only the missing + * originating surface can make the compositor drop the rect. */ + if (!xdg_toplevel_client_create_pending(&connection, &target) + || wl_display_roundtrip(connection.display) < 0) { + TEST_ERROR("window-transition: failed to create the pending target\n"); + ok = 0; + } + + if (ok && !obtain_token(&connection, activation, NULL, token, sizeof(token))) { + TEST_ERROR("window-transition: failed to obtain activation token\n"); + ok = 0; + } + + struct rect_events events = { 0 }; + if (ok) { + rect = treeland_window_transition_manager_v1_get_window_transition_rect(manager, token); + if (!rect) { + TEST_ERROR("window-transition: failed to create the transition rect\n"); + ok = 0; + } else { + treeland_window_transition_rect_v1_add_listener(rect, &rect_listener, &events); + treeland_window_transition_rect_v1_set_geometry(rect, 0, 0, 40, 40); + if (wl_display_roundtrip(connection.display) < 0) { + TEST_ERROR("window-transition: rect configuration was rejected\n"); + ok = 0; + } + } + } + + if (ok && read_pending(token) != 1) { + TEST_ERROR("window-transition: rect without an originating surface is not pending\n"); + ok = 0; + } + + if (ok) { + xdg_activation_v1_activate(activation, token, target.surface); + if (wl_display_roundtrip(connection.display) < 0) { + TEST_ERROR("window-transition: activation was rejected\n"); + ok = 0; + } + } + + if (ok) { + if (events.closed_count != 1) { + TEST_ERROR("window-transition: rect without an originating surface got %d closed" + " events, expected 1\n", + events.closed_count); + ok = 0; + } else if (!read_state(token, &state)) { + TEST_ERROR("window-transition: production manager not reachable\n"); + ok = 0; + } else if (state.pending) { + TEST_ERROR("window-transition: rect without an originating surface stayed pending\n"); + ok = 0; + } else if (state.armed_count != 0 || state.rect_present != 0) { + TEST_ERROR("window-transition: rect without an originating surface armed a" + " transition\n"); + ok = 0; + } + } + + if (rect) + treeland_window_transition_rect_v1_destroy(rect); + if (target.surface) + xdg_toplevel_client_destroy(&target); + client_disconnect(&connection); + return ok; +} + +static int run_invalid_geometry(const char *socket_name) +{ + struct client_connection connection = { 0 }; + if (!client_connect(&connection, socket_name)) + return 0; + + struct treeland_window_transition_manager_v1 *manager = + client_bind(&connection, + WINDOW_TRANSITION_MANAGER, + &treeland_window_transition_manager_v1_interface, + treeland_window_transition_manager_v1_interface.version); + if (!manager) { + TEST_ERROR("window-transition: manager missing\n"); + client_disconnect(&connection); + return 0; + } + + struct treeland_window_transition_rect_v1 *rect = + treeland_window_transition_manager_v1_get_window_transition_rect( + manager, "treeland-transition-token-geometry"); + if (!rect) { + TEST_ERROR("window-transition: failed to create the transition rect\n"); + client_disconnect(&connection); + return 0; + } + + /* A negative width or height is the invalid_geometry path. */ + treeland_window_transition_rect_v1_set_geometry(rect, 0, 0, -1, 4); + + const struct wl_interface *error_interface = NULL; + uint32_t error_id = 0; + const int rejected = + wl_display_roundtrip(connection.display) < 0 + && wl_display_get_protocol_error(connection.display, &error_interface, &error_id) + == TREELAND_WINDOW_TRANSITION_RECT_V1_ERROR_INVALID_GEOMETRY + && error_interface == &treeland_window_transition_rect_v1_interface; + if (!rejected) + TEST_ERROR("window-transition: negative geometry did not raise invalid_geometry\n"); + client_disconnect(&connection); + return rejected; +} + +static int run_invalid_surface(const char *socket_name) +{ + struct client_connection connection = { 0 }; + struct xdg_toplevel_client toplevel = { 0 }; + if (!client_connect(&connection, socket_name)) + return 0; + + struct treeland_window_transition_manager_v1 *manager = + client_bind(&connection, + WINDOW_TRANSITION_MANAGER, + &treeland_window_transition_manager_v1_interface, + treeland_window_transition_manager_v1_interface.version); + if (!manager) { + TEST_ERROR("window-transition: manager missing\n"); + client_disconnect(&connection); + return 0; + } + + /* A mapped toplevel already carries an xdg role, so it must be rejected as + * a transition source surface. */ + if (!xdg_toplevel_client_create(&connection, &toplevel)) { + TEST_ERROR("window-transition: failed to create the role-holding toplevel\n"); + treeland_window_transition_manager_v1_destroy(manager); + client_disconnect(&connection); + return 0; + } + + struct treeland_window_transition_rect_v1 *rect = + treeland_window_transition_manager_v1_get_window_transition_rect( + manager, "treeland-transition-token-surface"); + if (!rect) { + TEST_ERROR("window-transition: failed to create the transition rect\n"); + client_disconnect(&connection); + return 0; + } + + treeland_window_transition_rect_v1_set_source_surface(rect, toplevel.surface); + + const struct wl_interface *error_interface = NULL; + uint32_t error_id = 0; + const int rejected = + wl_display_roundtrip(connection.display) < 0 + && wl_display_get_protocol_error(connection.display, &error_interface, &error_id) + == TREELAND_WINDOW_TRANSITION_RECT_V1_ERROR_INVALID_SURFACE + && error_interface == &treeland_window_transition_rect_v1_interface; + if (!rejected) + TEST_ERROR("window-transition: role-holding surface did not raise invalid_surface\n"); + client_disconnect(&connection); + return rejected; +} + +int protocol_test_run(const char *socket_name) +{ + int failed = 0; + if (!run_pending_and_events(socket_name)) + failed = 1; + if (!run_associate_on_activation(socket_name)) + failed = 1; + if (!run_closed_when_activation_late(socket_name)) + failed = 1; + if (!run_discarded_without_origin_surface(socket_name)) + failed = 1; + if (!run_invalid_geometry(socket_name)) + failed = 1; + if (!run_invalid_surface(socket_name)) + failed = 1; + return failed; +} diff --git a/tests/protocols/treeland-window-transition-unstable-v1/treeland-window-transition-unstable-v1.h b/tests/protocols/treeland-window-transition-unstable-v1/treeland-window-transition-unstable-v1.h new file mode 100644 index 0000000000..045fe0f07a --- /dev/null +++ b/tests/protocols/treeland-window-transition-unstable-v1/treeland-window-transition-unstable-v1.h @@ -0,0 +1,44 @@ +// Copyright (C) 2026 UnionTech Software Technology Co., Ltd. +// SPDX-License-Identifier: Apache-2.0 OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Shared POD snapshot between the C client and the C++ fixture. + * + * `token` is an *input*: the client fills it with the activation token string + * passed to get_window_transition_rect before invoking the read on the + * compositor thread, so the callback must not clear it. The remaining fields + * are outputs written on the compositor thread. + * + * The `rect_*` fields describe the rectangle the production manager actually + * handed to a SurfaceWindowTransition, read from that wrapper's real state + * instead of from any wrapper that happens to be armed: + * + * armed_count wrappers whose transition is active() (non-empty rect) + * rect_present an associated rect is reachable (globalRect() has a value) + * rect_mapped that rect's wrapper surface is already mapped + * rect_w/rect_h that rect's size in pixels, 0x0 while it is disabled + * source_set that transition carries a source surface + */ +struct window_transition_server_state { + char token[128]; /* input: token string to query */ + int manager_present; /* output: production manager interface found */ + int pending; /* output: hasPendingWindowTransitionRect(token) */ + int armed_count; /* output: wrappers with an active transition rect */ + int rect_present; /* output: an associated rect is reachable */ + int rect_mapped; /* output: the associated rect's wrapper is mapped */ + int rect_w; /* output: associated rect width in px */ + int rect_h; /* output: associated rect height in px */ + int source_set; /* output: the associated transition has a source surface */ +}; + +void window_transition_read_server_state(void *data); + +#ifdef __cplusplus +} +#endif