From a0de8bb5cc129879a1f3da5cdc3532e548f7e265 Mon Sep 17 00:00:00 2001 From: wangwenx190 <2546789017@qq.com> Date: Tue, 22 Sep 2026 13:42:00 +0800 Subject: [PATCH 01/56] Fix macOS StyleAgent notification lifetime safety (AUDIT-001) The macOS theme notification dispatcher iterated the live set of raw StyleAgentPrivate pointers. Synchronous signal handlers could delete the current agent before its accent-color notification, or create/delete other agents and invalidate the ongoing iteration. Build a separate snapshot of private pointers and QPointer guards before emitting any signals. Before each theme or accent-color notification, check both the owner's lifetime and the agent's current registration. This skips destroyed agents, keeps newly created agents out of the current snapshot, and prevents a replacement allocated at the same address from being mistaken for the original agent. The registration check also covers private-object teardown before QObject clears its guarded pointers. Add explicit utility and Qt container/guard includes. Keep the public API and other platform implementations unchanged. Validation: statically reviewed self-deletion, deletion of another agent, creation during callbacks, address reuse, teardown ordering, and access safety during reentrant notification dispatch. git diff --check passed. macOS compilation and runtime validation were not performed because the available environment is Windows. --- src/core/style/styleagent_mac.mm | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/src/core/style/styleagent_mac.mm b/src/core/style/styleagent_mac.mm index 50c35505..de33c4bc 100644 --- a/src/core/style/styleagent_mac.mm +++ b/src/core/style/styleagent_mac.mm @@ -4,6 +4,12 @@ #include "styleagent_p.h" +#include + +#include +#include +#include + #include namespace QWK { @@ -82,9 +88,28 @@ - (void)interfaceModeChanged:(NSNotification *)notification { void notifyAllStyleAgents() { auto theme = getSystemTheme(); auto color = getAccentColor(); + + struct AgentEntry { + StyleAgentPrivate *agent; + QPointer owner; + }; + QVector agents; + agents.reserve(g_styleAgentSet->size()); for (auto &&ap : std::as_const(*g_styleAgentSet())) { - ap->notifyThemeChanged(theme); - ap->notifyAccentColorChanged(color); + agents.append(AgentEntry{ap, QPointer(ap->q_ptr)}); + } + + // Signals can create or destroy agents. Snapshot all owners before calling user code; + // a guard also distinguishes a destroyed agent from a new one reusing its address. + // Check membership too: the private object unregisters before QObject clears its guards. + for (const auto &entry : std::as_const(agents)) { + if (!entry.owner || !g_styleAgentSet->contains(entry.agent)) + continue; + entry.agent->notifyThemeChanged(theme); + + if (!entry.owner || !g_styleAgentSet->contains(entry.agent)) + continue; + entry.agent->notifyAccentColorChanged(color); } } @@ -111,4 +136,4 @@ void notifyAllStyleAgents() { } } -} \ No newline at end of file +} From 8d264a8e05dd0c18f74051dc683e534587d7ade7 Mon Sep 17 00:00:00 2001 From: wangwenx190 <2546789017@qq.com> Date: Tue, 22 Sep 2026 13:48:06 +0800 Subject: [PATCH 02/56] Fix macOS system button area lifetime handling (AUDIT-002) The Widgets and Quick system button area callbacks captured raw QWidget and QQuickItem pointers. Deleting a registered area while keeping its window and agent alive left the Cocoa callback pointing at freed memory. Subsequent resize or fullscreen-related layout updates could dereference that pointer. The agents' area getters also retained dangling pointers. Store the registered areas as QPointer and capture guarded pointers in both geometry callbacks, returning an empty rectangle when the object no longer exists. Remove the unused raw area pointers from the helpers. Connect each area's destroyed signal to its registration helper to clear the context callback and propagate the removal to the Cocoa proxy. Retire the previous helper before installing another area or switching to a custom callback. Keep null-area cleanup active when a helper still exists, even if QPointer has already cleared during destruction. This ensures an earlier destroyed-signal handler can replace the registration without the old cleanup connection erasing the new callback. Helper connections also disconnect before the owning agent's context is torn down. Public API signatures and coordinate calculations remain unchanged. Validation: statically reviewed area deletion, replacement, explicit clearing, custom callback transitions, replacement from destroyed-signal handlers, guarded callback copies, and agent-before-area destruction. Verified callback removal propagates through the Cocoa context and that registration helpers are destroyed before the context. git diff --check passed. macOS compilation and runtime checks were not performed because the available environment is Windows. --- src/quick/quickwindowagent_mac.cpp | 17 +++++++++++------ src/quick/quickwindowagent_p.h | 4 +++- src/widgets/widgetwindowagent_mac.cpp | 22 ++++++++++++++-------- src/widgets/widgetwindowagent_p.h | 4 +++- 4 files changed, 31 insertions(+), 16 deletions(-) diff --git a/src/quick/quickwindowagent_mac.cpp b/src/quick/quickwindowagent_mac.cpp index 3d5c86ea..7da93690 100644 --- a/src/quick/quickwindowagent_mac.cpp +++ b/src/quick/quickwindowagent_mac.cpp @@ -15,14 +15,13 @@ namespace QWK { void updateSystemButtonArea(); protected: - QQuickItem *item; AbstractWindowContext *ctx; }; SystemButtonAreaItemHandler::SystemButtonAreaItemHandler(QQuickItem *item, AbstractWindowContext *ctx, QObject *parent) - : QObject(parent), item(item), ctx(ctx) { + : QObject(parent), ctx(ctx) { connect(item, &QQuickItem::xChanged, this, &SystemButtonAreaItemHandler::updateSystemButtonArea); connect(item, &QQuickItem::yChanged, this, @@ -32,8 +31,12 @@ namespace QWK { connect(item, &QQuickItem::heightChanged, this, &SystemButtonAreaItemHandler::updateSystemButtonArea); - ctx->setSystemButtonAreaCallback([item](const QSize &) { - return QRectF(item->mapToScene(QPointF(0, 0)), item->size()).toRect(); // + // Tie cleanup to this registration so replacing it disconnects the old area. + connect(item, &QObject::destroyed, this, [ctx] { + ctx->setSystemButtonAreaCallback({}); + }); + ctx->setSystemButtonAreaCallback([item = QPointer(item)](const QSize &) { + return item ? QRectF(item->mapToScene(QPointF(0, 0)), item->size()).toRect() : QRect(); }); } @@ -48,13 +51,15 @@ namespace QWK { void QuickWindowAgent::setSystemButtonArea(QQuickItem *item) { Q_D(QuickWindowAgent); - if (d->systemButtonAreaItem == item) + if (d->systemButtonAreaItem == item && (item || !d->systemButtonAreaItemHandler)) return; auto ctx = d->context.get(); + // QPointer is already null during destroyed(); still retire the old registration + // before installing another area or a custom callback. + d->systemButtonAreaItemHandler.reset(); d->systemButtonAreaItem = item; if (!item) { - d->systemButtonAreaItemHandler.reset(); ctx->setSystemButtonAreaCallback({}); return; } diff --git a/src/quick/quickwindowagent_p.h b/src/quick/quickwindowagent_p.h index 25c30dad..f7db3c79 100644 --- a/src/quick/quickwindowagent_p.h +++ b/src/quick/quickwindowagent_p.h @@ -14,6 +14,8 @@ // version without notice, or may even be removed. // +#include + #include #include #include @@ -32,7 +34,7 @@ namespace QWK { QQuickWindow *hostWindow{}; #ifdef Q_OS_MAC - QQuickItem *systemButtonAreaItem{}; + QPointer systemButtonAreaItem; std::unique_ptr systemButtonAreaItemHandler; #endif diff --git a/src/widgets/widgetwindowagent_mac.cpp b/src/widgets/widgetwindowagent_mac.cpp index 34464af3..b965ce7f 100644 --- a/src/widgets/widgetwindowagent_mac.cpp +++ b/src/widgets/widgetwindowagent_mac.cpp @@ -16,10 +16,14 @@ namespace QWK { public: SystemButtonAreaWidgetEventFilter(QWidget *widget, AbstractWindowContext *ctx, QObject *parent = nullptr) - : QObject(parent), widget(widget), ctx(ctx) { + : QObject(parent), ctx(ctx) { widget->installEventFilter(this); - ctx->setSystemButtonAreaCallback([widget](const QSize &) { - return getWidgetSceneRect(widget); // + // Tie cleanup to this registration so replacing it disconnects the old area. + connect(widget, &QObject::destroyed, this, [ctx] { + ctx->setSystemButtonAreaCallback({}); + }); + ctx->setSystemButtonAreaCallback([widget = QPointer(widget)](const QSize &) { + return widget ? getWidgetSceneRect(widget.data()) : QRect(); }); } ~SystemButtonAreaWidgetEventFilter() override = default; @@ -41,7 +45,6 @@ namespace QWK { } protected: - QWidget *widget; AbstractWindowContext *ctx; }; @@ -61,14 +64,17 @@ namespace QWK { */ void WidgetWindowAgent::setSystemButtonArea(QWidget *widget) { Q_D(WidgetWindowAgent); - if (d->systemButtonAreaWidget == widget) + if (d->systemButtonAreaWidget == widget && + (widget || !d->systemButtonAreaWidgetEventFilter)) return; auto ctx = d->context.get(); + // QPointer is already null during destroyed(); still retire the old registration + // before installing another area or a custom callback. + d->systemButtonAreaWidgetEventFilter.reset(); d->systemButtonAreaWidget = widget; if (!widget) { - d->context->setSystemButtonAreaCallback({}); - d->systemButtonAreaWidgetEventFilter.reset(); + ctx->setSystemButtonAreaCallback({}); return; } d->systemButtonAreaWidgetEventFilter = @@ -95,4 +101,4 @@ namespace QWK { d->context->setSystemButtonAreaCallback(callback); } -} \ No newline at end of file +} diff --git a/src/widgets/widgetwindowagent_p.h b/src/widgets/widgetwindowagent_p.h index b8d50d94..af89c45e 100644 --- a/src/widgets/widgetwindowagent_p.h +++ b/src/widgets/widgetwindowagent_p.h @@ -14,6 +14,8 @@ // version without notice, or may even be removed. // +#include + #include #include #include @@ -32,7 +34,7 @@ namespace QWK { QWidget *hostWidget{}; #ifdef Q_OS_MAC - QWidget *systemButtonAreaWidget{}; + QPointer systemButtonAreaWidget; std::unique_ptr systemButtonAreaWidgetEventFilter; #endif From cc368f385c1e085345d0b07bc45ebf3ecb287641 Mon Sep 17 00:00:00 2001 From: wangwenx190 <2546789017@qq.com> Date: Tue, 22 Sep 2026 14:11:35 +0800 Subject: [PATCH 03/56] Fix Quick Windows 10 border ownership and rendering (AUDIT-003) The Quick border was owned only by the window's content item while its raw context pointer belonged to the agent. Deleting the agent first left the border, its event filters and its render callback alive with a freed context. The painted-item rendering path also queried context and host state during scene graph rendering. Keep a QPointer to the decoration in QuickWindowAgentPrivate and delete it before the base class releases the context. If the window destroys the item first, the guard prevents a second deletion. Disconnect window render callbacks before tearing down the border and let Qt release its scene graph nodes on the correct thread. Implement emulated borders as QQuickItem content backed by a rectangle node from QQuickWindow::createRectangleNode(). Use a filled rectangle at y=0 with height 1/effectiveDevicePixelRatio, public geometry updates instead of private anchors, and explicit input exclusion. Refresh state on size, DPR, screen, window-state, native-handle, activation and theme changes. Prepare border color and native handle on the GUI thread so neither updatePaintNode nor afterSynchronizing dereferences the context. Extract the existing Windows border color selection into a private hook shared with the legacy painter path. Extract native GDI drawing into a handle-only helper with DC and client-rectangle failure checks. Preserve the original native backend selection and afterSynchronizing timing, but trigger native drawing independently of painted-item dirtiness. Retain a native-only transparent painted surface with the original 0.5 height, -0.49 offset and opacity settings pending Windows 10 DWM validation; the emulated path no longer uses a painted surface or texture. Add Windows-only Qt Test/CTest regressions that force the production border through a controlled context on any Windows version. Cover both object destruction orders, post-deletion repaint, one-physical-pixel output at DPR 1/1.25/1.5/2, resizing, color refresh, maximize/restore, scene detachment/reattachment, input exclusion, and D3D11 WARP threaded rendering lifetime safety. Tests run offline, serialize desktop access, have timeouts, and preserve local text reports for CTest failure output. Validation: Release Core/Quick/Widgets builds passed with system borders both enabled and disabled using MSVC and Qt 6.12.0 on Windows 11. All five quickborder CTest jobs passed. Temporarily omitting agent-side cleanup made the lifetime regression fail as expected; cleanup was restored and the full targeted suite passed. Static review covered ownership order, filter disconnection, scene graph cleanup, GUI/render synchronization, backend and feature guards, and retained native workaround behavior. git diff --check passed. The controlled-context tests do not certify actual Windows 10 DWM appearance, physical multi-monitor DPI transitions, or all supported Qt versions and graphics backends. These remain unverified; native seam compatibility code is intentionally retained. No audit document or submodule changes are included. --- src/core/contexts/abstractwindowcontext_p.h | 1 + src/core/contexts/win32windowcontext.cpp | 54 ++--- src/core/shared/qwkwindowsextra_p.h | 16 ++ src/quick/quickwindowagent.cpp | 8 +- src/quick/quickwindowagent_p.h | 1 + src/quick/quickwindowagent_win.cpp | 251 ++++++++++---------- tests/CMakeLists.txt | 4 + tests/quickborder/CMakeLists.txt | 37 +++ tests/quickborder/RunTest.cmake | 9 + tests/quickborder/tst_quickborder.cpp | 242 +++++++++++++++++++ 10 files changed, 472 insertions(+), 151 deletions(-) create mode 100644 tests/quickborder/CMakeLists.txt create mode 100644 tests/quickborder/RunTest.cmake create mode 100644 tests/quickborder/tst_quickborder.cpp diff --git a/src/core/contexts/abstractwindowcontext_p.h b/src/core/contexts/abstractwindowcontext_p.h index 88db7f16..6b96967d 100644 --- a/src/core/contexts/abstractwindowcontext_p.h +++ b/src/core/contexts/abstractwindowcontext_p.h @@ -79,6 +79,7 @@ namespace QWK { DrawWindows10BorderHook_Emulated, // Only works on Windows 10, emulated workaround DrawWindows10BorderHook_Native, // Only works on Windows 10, native workaround SystemButtonAreaChangedHook, // Only works on Mac + Windows10BorderColorHook, // QColor output; queried on the GUI thread }; virtual void virtual_hook(int id, void *data); diff --git a/src/core/contexts/win32windowcontext.cpp b/src/core/contexts/win32windowcontext.cpp index ad4e97bb..2ccdc3b5 100644 --- a/src/core/contexts/win32windowcontext.cpp +++ b/src/core/contexts/win32windowcontext.cpp @@ -778,6 +778,25 @@ namespace QWK { } #if QWINDOWKIT_CONFIG(ENABLE_WINDOWS_SYSTEM_BORDERS) + case Windows10BorderColorHook: { + auto &color = *static_cast(data); + color = {}; + if (!m_windowId || !m_windowHandle) + return; + const auto hwnd = reinterpret_cast(m_windowId); + const bool dark = isDarkThemeActive() && isDarkWindowFrameEnabled(hwnd); + if (m_delegate->isWindowActive(m_host)) { + color = isWindowFrameBorderColorized() + ? getAccentColor() + : QColor(dark ? kWindowsColorSet.activeDark + : kWindowsColorSet.activeLight); + } else { + color = QColor(dark ? kWindowsColorSet.inactiveDark + : kWindowsColorSet.inactiveLight); + } + return; + } + // ### FIXME: May be deprecated case DrawWindows10BorderHook_Emulated: { if (!m_windowId) @@ -787,8 +806,6 @@ namespace QWK { auto &painter = *static_cast(args[0]); const auto &rect = *static_cast(args[1]); const auto ®ion = *static_cast(args[2]); - const auto hwnd = reinterpret_cast(m_windowId); - QPen pen; # if QT_VERSION_MAJOR < 6 pen.setWidth(1); @@ -796,22 +813,9 @@ namespace QWK { pen.setWidthF(1 / m_windowHandle->devicePixelRatio()); // why 0.25? # endif - const bool dark = isDarkThemeActive() && isDarkWindowFrameEnabled(hwnd); - if (m_delegate->isWindowActive(m_host)) { - if (isWindowFrameBorderColorized()) { - pen.setColor(getAccentColor()); - } else { - static QColor frameBorderActiveColorLight(kWindowsColorSet.activeLight); - static QColor frameBorderActiveColorDark(kWindowsColorSet.activeDark); - pen.setColor(dark ? frameBorderActiveColorDark - : frameBorderActiveColorLight); - } - } else { - static QColor frameBorderInactiveColorLight(kWindowsColorSet.inactiveLight); - static QColor frameBorderInactiveColorDark(kWindowsColorSet.inactiveDark); - pen.setColor(dark ? frameBorderInactiveColorDark - : frameBorderInactiveColorLight); - } + QColor color; + virtual_hook(Windows10BorderColorHook, &color); + pen.setColor(color); painter.save(); // We need antialiasing to give us better result. @@ -834,19 +838,7 @@ namespace QWK { // https://docs.microsoft.com/en-us/windows/win32/dwm/customframe#extending-the-client-frame // Draw a black rectangle to make Windows native top border show - auto hWnd = reinterpret_cast(m_windowId); - HDC hdc = ::GetDC(hWnd); - RECT windowRect{}; - ::GetClientRect(hWnd, &windowRect); - RECT rcTopBorder = { - 0, - 0, - RECT_WIDTH(windowRect), - 1, - }; - ::FillRect(hdc, &rcTopBorder, - reinterpret_cast(::GetStockObject(BLACK_BRUSH))); - ::ReleaseDC(hWnd, hdc); + drawWindows10BorderNative(reinterpret_cast(m_windowId)); return; } #endif diff --git a/src/core/shared/qwkwindowsextra_p.h b/src/core/shared/qwkwindowsextra_p.h index 68dc9367..21f01c46 100644 --- a/src/core/shared/qwkwindowsextra_p.h +++ b/src/core/shared/qwkwindowsextra_p.h @@ -65,6 +65,22 @@ extern "C" { namespace QWK { + // Takes only a native handle so the Quick render thread never needs a window context. + inline void drawWindows10BorderNative(HWND hwnd) { + if (!hwnd) + return; + HDC hdc = ::GetDC(hwnd); + if (!hdc) + return; + RECT clientRect{}; + if (::GetClientRect(hwnd, &clientRect)) { + RECT borderRect{0, 0, RECT_WIDTH(clientRect), 1}; + ::FillRect(hdc, &borderRect, + reinterpret_cast(::GetStockObject(BLACK_BRUSH))); + } + ::ReleaseDC(hwnd, hdc); + } + enum _DWMWINDOWATTRIBUTE { // [set] BOOL, Allows the use of host backdrop brushes for the window. _DWMWA_USE_HOSTBACKDROPBRUSH = 17, diff --git a/src/quick/quickwindowagent.cpp b/src/quick/quickwindowagent.cpp index 481663e8..edd883d1 100644 --- a/src/quick/quickwindowagent.cpp +++ b/src/quick/quickwindowagent.cpp @@ -22,7 +22,13 @@ namespace QWK { QuickWindowAgentPrivate::QuickWindowAgentPrivate() = default; - QuickWindowAgentPrivate::~QuickWindowAgentPrivate() = default; + QuickWindowAgentPrivate::~QuickWindowAgentPrivate() { +#if defined(Q_OS_WINDOWS) && QWINDOWKIT_CONFIG(ENABLE_WINDOWS_SYSTEM_BORDERS) + // The window may have deleted the item already. Otherwise retire its filters and + // render callbacks before the base class destroys the context they depend on. + delete borderItem.data(); +#endif + } void QuickWindowAgentPrivate::init() { } diff --git a/src/quick/quickwindowagent_p.h b/src/quick/quickwindowagent_p.h index f7db3c79..5c9529bc 100644 --- a/src/quick/quickwindowagent_p.h +++ b/src/quick/quickwindowagent_p.h @@ -40,6 +40,7 @@ namespace QWK { #if defined(Q_OS_WINDOWS) && QWINDOWKIT_CONFIG(ENABLE_WINDOWS_SYSTEM_BORDERS) void setupWindows10BorderWorkaround(); + QPointer borderItem; #endif }; diff --git a/src/quick/quickwindowagent_win.cpp b/src/quick/quickwindowagent_win.cpp index cb667960..0b966a3c 100644 --- a/src/quick/quickwindowagent_win.cpp +++ b/src/quick/quickwindowagent_win.cpp @@ -5,202 +5,215 @@ #include "quickwindowagent_p.h" #include -#include +#include +#include -#include +#include #include namespace QWK { - static inline bool isWindows1022H2OrGreater() { - QWK_OSVERSIONINFOW rovi = Private::GetRealOSVersion(); - return (rovi.dwMajorVersion > 10) || - (rovi.dwMajorVersion == 10 && - (rovi.dwMinorVersion > 0 || rovi.dwBuildNumber >= 19045)); - } - #if QWINDOWKIT_CONFIG(ENABLE_WINDOWS_SYSTEM_BORDERS) - class BorderItem : public QQuickPaintedItem, public Windows10BorderHandler { + // Retain the native path's transparent painted surface and subpixel offset until its + // DWM seam workaround can be validated on Windows 10. Emulated borders do not use it. + class NativeBorderSurface : public QQuickPaintedItem { + public: + explicit NativeBorderSurface(QQuickItem *parent) : QQuickPaintedItem(parent) { + setAntialiasing(true); + setFillColor(Qt::transparent); + setOpaquePainting(true); + setAcceptedMouseButtons(Qt::NoButton); + setAcceptTouchEvents(false); + setAcceptHoverEvents(false); + } + + void paint(QPainter *) override {} + }; + + class BorderItem : public QQuickItem, public Windows10BorderHandler { public: explicit BorderItem(QQuickItem *parent, AbstractWindowContext *context); ~BorderItem() override; - bool shouldEnableEmulatedPainter() const; void updateGeometry() override; - public: - void paint(QPainter *painter) override; - void itemChange(ItemChange change, const ItemChangeData &data) override; - protected: + QSGNode *updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData *) override; + void itemChange(ItemChange change, const ItemChangeData &data) override; bool sharedEventFilter(QObject *obj, QEvent *event) override; bool nativeEventFilter(const QByteArray &eventType, void *message, QT_NATIVE_EVENT_RESULT_TYPE *result) override; private: - bool needNativePaint = false; - - void _q_afterSynchronizing(); - void _q_windowActivityChanged(); + bool shouldEnableNativePainter() const; + void afterSynchronizing(); + + // Written on the GUI thread; read only during synchronization while it is blocked. + QColor borderColor; + WId nativeWindowId = 0; + bool nativePainting = false; + std::unique_ptr nativeSurface; }; - bool BorderItem::shouldEnableEmulatedPainter() const { + bool BorderItem::shouldEnableNativePainter() const { # if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) - const QQuickWindow *win = window(); - if (!win) { - return true; - } - auto api = win->rendererInterface()->graphicsApi(); - switch (api) { - case QSGRendererInterface::OpenGL: - // FIXME: may be wrong in earlier Windows 10. - return false; - case QSGRendererInterface::Direct3D11: + if (auto win = window()) { + switch (win->rendererInterface()->graphicsApi()) { + case QSGRendererInterface::OpenGL: + case QSGRendererInterface::Direct3D11: # if QT_VERSION >= QT_VERSION_CHECK(6, 6, 0) - case QSGRendererInterface::Direct3D12: + case QSGRendererInterface::Direct3D12: # endif - return false; - default: - break; + return true; + default: + break; + } } # endif - return true; + return false; } BorderItem::BorderItem(QQuickItem *parent, AbstractWindowContext *context) - : QQuickPaintedItem(parent), Windows10BorderHandler(context) { - setAntialiasing(true); // We need anti-aliasing to give us better result. - setFillColor(Qt::transparent); // Will improve the performance a little bit. - setOpaquePainting(true); // Will also improve the performance, we don't draw - // semi-transparent borders of course. - - auto parentPri = QQuickItemPrivate::get(parent); - auto anchors = QQuickItemPrivate::get(this)->anchors(); - - // Workaround for top border - // anchors->setTop(parentPri->top()); - - anchors->setLeft(parentPri->left()); - anchors->setRight(parentPri->right()); - - setZ(std::numeric_limits::max()); // Make sure our fake border always above - // everything in the window. - - // The item is parented to the window's content item, so there normally is a window - // here, but window() is nullable and the rest of this class already treats it as such. + : QQuickItem(parent), Windows10BorderHandler(context) { + setFlag(ItemHasContents); + setAntialiasing(false); + setAcceptedMouseButtons(Qt::NoButton); + setAcceptTouchEvents(false); + setAcceptHoverEvents(false); + setActiveFocusOnTab(false); + setEnabled(false); + // A decoration above ordinary sibling content, not above other native windows. + setZ(std::numeric_limits::max()); + + connect(parent, &QQuickItem::widthChanged, this, &BorderItem::updateGeometry); if (auto win = window()) { -# if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + connect(win, &QQuickWindow::activeChanged, this, &BorderItem::updateGeometry); + connect(win, &QQuickWindow::screenChanged, this, &BorderItem::updateGeometry); + // Backend selection may not be final before the first scene graph is initialized. + connect(win, &QQuickWindow::sceneGraphInitialized, this, + &BorderItem::updateGeometry, Qt::QueuedConnection); connect(win, &QQuickWindow::afterSynchronizing, this, - &BorderItem::_q_afterSynchronizing, Qt::DirectConnection); -# endif - connect(win, &QQuickWindow::activeChanged, this, - &BorderItem::_q_windowActivityChanged); + &BorderItem::afterSynchronizing, Qt::DirectConnection); } - // First update if (context->windowId()) { setupNecessaryAttributes(); } - BorderItem::updateGeometry(); + updateGeometry(); } - BorderItem::~BorderItem() = default; + BorderItem::~BorderItem() { + // Stop render callbacks before destroying members and unregistering context filters. + if (auto win = window()) { + disconnect(win, nullptr, this, nullptr); + } + // Qt owns the node returned by updatePaintNode and releases it on the render thread. + } void BorderItem::updateGeometry() { - const QQuickWindow *win = window(); + auto win = window(); if (!win) { + nativeWindowId = 0; + setVisible(false); return; } -# if QT_VERSION_MAJOR < 6 - setHeight(1); -# else - // Workaround for top border - // When the height is less than 0.5, it will be regarded as invisible, we apply this - // workaround to make it slightly exposed. When the height is too big, a transparent gap - // will appear on the upper frame. - setHeight(0.5); - setY(-0.49); -# endif - setVisible(isNormalWindow()); - } - void BorderItem::paint(QPainter *painter) { - Q_UNUSED(painter) - if (shouldEnableEmulatedPainter()) { - drawBorderEmulated(painter, QRect({0, 0}, size().toSize())); + nativePainting = shouldEnableNativePainter(); + nativeWindowId = ctx->windowId(); + borderColor = {}; + if (!nativePainting) { + ctx->virtual_hook(AbstractWindowContext::Windows10BorderColorHook, &borderColor); + } + + setX(0); + setWidth(parentItem() ? parentItem()->width() : 0); + if (nativePainting) { + // Preserve the original native workaround's surface geometry and opacity setup. + setY(-0.49); + setHeight(0.5); + if (!nativeSurface) { + nativeSurface = std::make_unique(this); + } + nativeSurface->setSize(size()); + nativeSurface->update(); } else { - needNativePaint = true; + nativeSurface.reset(); + setY(0); + const qreal dpr = win->effectiveDevicePixelRatio(); + setHeight(dpr > 0 ? 1 / dpr : 1); + } + setVisible(!(win->windowStates() & + (Qt::WindowMinimized | Qt::WindowMaximized | Qt::WindowFullScreen))); + update(); + } + + QSGNode *BorderItem::updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData *) { + if (nativePainting || !borderColor.isValid() || width() <= 0 || height() <= 0) { + delete oldNode; + return nullptr; + } + auto node = static_cast(oldNode); + if (!node) { + node = window()->createRectangleNode(); + if (!node) + return nullptr; } + node->setRect(QRectF(0, 0, width(), height())); + node->setColor(borderColor); + return node; } void BorderItem::itemChange(ItemChange change, const ItemChangeData &data) { - QQuickPaintedItem::itemChange(change, data); - switch (change) { - case ItemVisibleHasChanged: - case ItemDevicePixelRatioHasChanged: { - updateGeometry(); - break; - } - default: - break; + QQuickItem::itemChange(change, data); + if (change == ItemDevicePixelRatioHasChanged || change == ItemSceneChange) { + updateGeometry(); } } bool BorderItem::sharedEventFilter(QObject *obj, QEvent *event) { - Q_UNUSED(obj) - - switch (event->type()) { - case QEvent::WindowStateChange: { - updateGeometry(); - break; - } - default: - break; + const bool filtered = Windows10BorderHandler::sharedEventFilter(obj, event); + if (event->type() == QEvent::WindowStateChange || event->type() == QEvent::WinIdChange) { + updateGeometry(); } - return Windows10BorderHandler::sharedEventFilter(obj, event); + return filtered; } bool BorderItem::nativeEventFilter(const QByteArray &eventType, void *message, QT_NATIVE_EVENT_RESULT_TYPE *result) { + if (!message) + return false; + const bool filtered = Windows10BorderHandler::nativeEventFilter(eventType, message, result); const auto msg = static_cast(message); switch (msg->message) { case WM_THEMECHANGED: case WM_SYSCOLORCHANGE: - case WM_DWMCOLORIZATIONCOLORCHANGED: { - update(); + case WM_DWMCOLORIZATIONCOLORCHANGED: + updateGeometry(); break; - } - - case WM_SETTINGCHANGE: { + case WM_SETTINGCHANGE: if (isImmersiveColorSetChange(msg->wParam, msg->lParam)) { - update(); + updateGeometry(); } break; - } - default: break; } - return Windows10BorderHandler::nativeEventFilter(eventType, message, result); + return filtered; } - void BorderItem::_q_afterSynchronizing() { - if (needNativePaint) { - needNativePaint = false; - drawBorderNative(); + void BorderItem::afterSynchronizing() { + // The GUI thread is still blocked here. Paint independently of item dirtiness, + // retaining the native timing without querying ctx or its host from the render thread. + if (nativePainting && isVisible()) { + drawWindows10BorderNative(reinterpret_cast(nativeWindowId)); } } - void BorderItem::_q_windowActivityChanged() { - update(); - } - void QuickWindowAgentPrivate::setupWindows10BorderWorkaround() { - // Install painting hook auto ctx = context.get(); if (ctx->windowAttribute(QStringLiteral("win10-border-needed")).toBool()) { - std::ignore = new BorderItem(hostWindow->contentItem(), ctx); + borderItem = new BorderItem(hostWindow->contentItem(), ctx); } } #endif diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f6486c96..a5684bc4 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1 +1,5 @@ add_subdirectory(buildsystems) + +if(WIN32 AND QWINDOWKIT_BUILD_QUICK AND QWINDOWKIT_ENABLE_WINDOWS_SYSTEM_BORDERS) + add_subdirectory(quickborder) +endif() diff --git a/tests/quickborder/CMakeLists.txt b/tests/quickborder/CMakeLists.txt new file mode 100644 index 00000000..cbee969c --- /dev/null +++ b/tests/quickborder/CMakeLists.txt @@ -0,0 +1,37 @@ +qm_find_qt(Core Quick Test) + +add_executable(tst_quickborder tst_quickborder.cpp) +set_target_properties(tst_quickborder PROPERTIES AUTOMOC ON) +target_compile_features(tst_quickborder PRIVATE cxx_std_17) +target_link_libraries(tst_quickborder PRIVATE QWKQuick Qt${QT_VERSION_MAJOR}::Test) + +# Each scale runs in a fresh process. No network, external images or input driver is needed. +foreach(_scale IN ITEMS 1 1.25 1.5 2) + add_test(NAME quickborder.software.${_scale} + COMMAND ${CMAKE_COMMAND} -E env + "PATH=$;$;$ENV{PATH}" + "QT_QPA_PLATFORM=windows" "QT_QUICK_BACKEND=software" + "QT_ENABLE_HIGHDPI_SCALING=0" "QT_SCALE_FACTOR=${_scale}" + ${CMAKE_COMMAND} + "-DTEST_EXE=$" + "-DRESULT_FILE=${CMAKE_CURRENT_BINARY_DIR}/software-${_scale}-$.txt" + "-DTEST_FUNCTIONS=agentBeforeWindow;windowBeforeAgent;geometryColorAndSceneAttachment" + -P "${CMAKE_CURRENT_SOURCE_DIR}/RunTest.cmake") + set_tests_properties(quickborder.software.${_scale} PROPERTIES + TIMEOUT 60 LABELS "quick;windows;ui" RESOURCE_LOCK desktop) +endforeach() + +if(QT_VERSION_MAJOR GREATER_EQUAL 6) + add_test(NAME quickborder.native.d3d11 + COMMAND ${CMAKE_COMMAND} -E env + "PATH=$;$;$ENV{PATH}" + "QT_QPA_PLATFORM=windows" "QT_QUICK_BACKEND=" "QSG_RHI_BACKEND=d3d11" + "QSG_RHI_PREFER_SOFTWARE_RENDERER=1" "QSG_RENDER_LOOP=threaded" + ${CMAKE_COMMAND} + "-DTEST_EXE=$" + "-DRESULT_FILE=${CMAKE_CURRENT_BINARY_DIR}/native-d3d11-$.txt" + "-DTEST_FUNCTIONS=nativeSurfaceLifecycle" + -P "${CMAKE_CURRENT_SOURCE_DIR}/RunTest.cmake") + set_tests_properties(quickborder.native.d3d11 PROPERTIES + TIMEOUT 60 LABELS "quick;windows;ui" RESOURCE_LOCK desktop) +endif() diff --git a/tests/quickborder/RunTest.cmake b/tests/quickborder/RunTest.cmake new file mode 100644 index 00000000..d894f306 --- /dev/null +++ b/tests/quickborder/RunTest.cmake @@ -0,0 +1,9 @@ +execute_process(COMMAND "${TEST_EXE}" ${TEST_FUNCTIONS} -o "${RESULT_FILE},txt" + RESULT_VARIABLE _result TIMEOUT 45) +if(EXISTS "${RESULT_FILE}") + file(READ "${RESULT_FILE}" _report) + message("${_report}") +endif() +if(NOT "${_result}" STREQUAL "0") + message(FATAL_ERROR "Quick border regression failed: ${_result}") +endif() diff --git a/tests/quickborder/tst_quickborder.cpp b/tests/quickborder/tst_quickborder.cpp new file mode 100644 index 00000000..59b5f737 --- /dev/null +++ b/tests/quickborder/tst_quickborder.cpp @@ -0,0 +1,242 @@ +// Copyright (C) 2023-present Stdware Collections (https://www.github.com/stdware) +// SPDX-License-Identifier: Apache-2.0 + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +using namespace QWK; + +namespace { + QPointer observedBorder; + bool contextDestroyedWithBorder = false; + std::atomic_bool queriedOffGuiThread{false}; + QColor testColor(Qt::red); + + // Force the production border on any Windows version, without claiming to reproduce DWM. + class BorderTestContext : public AbstractWindowContext { + public: + ~BorderTestContext() override { + contextDestroyedWithBorder = !observedBorder.isNull(); + } + + QVariant windowAttribute(const QString &key) const override { + if (key == QStringLiteral("win10-border-needed")) + return true; + return AbstractWindowContext::windowAttribute(key); + } + + void virtual_hook(int id, void *data) override { + if (id == Windows10BorderColorHook) { + if (QThread::currentThread() != qGuiApp->thread()) + queriedOffGuiThread = true; + *static_cast(data) = testColor; + return; + } + AbstractWindowContext::virtual_hook(id, data); + } + + protected: + void winIdChanged(WId, WId) override {} + bool windowAttributeChanged(const QString &, const QVariant &, const QVariant &) override { + return true; + } + }; + + BorderTestContext *testContext = nullptr; + + AbstractWindowContext *createTestContext() { + testContext = new BorderTestContext; + return testContext; + } + + void showTestWindow(QQuickWindow &window) { + window.show(); + // CTest can launch with STARTUPINFO requesting that the first window stay hidden. + // A second explicit show makes exposure deterministic without requiring activation. + ::ShowWindow(reinterpret_cast(window.winId()), SW_SHOWNOACTIVATE); + } + + bool hasTopLine(QQuickWindow &window, const QColor &color) { + const QImage image = window.grabWindow(); + if (image.isNull() || image.height() < 3 || image.width() < 10) + return false; + // Check the whole interior span: exactly one physical row, no tolerance that could + // conceal a missing line or an extra row. Avoid the native side edges. + for (int x = 3; x < image.width() - 3; ++x) { + if (image.pixelColor(x, 0) != color || image.pixelColor(x, 1) != QColor(Qt::white) || + image.pixelColor(x, 2) != QColor(Qt::white)) + return false; + } + return true; + } +} + +class QuickBorderTest : public QObject { + Q_OBJECT + +private Q_SLOTS: + void init() { + previousFactory = WindowAgentBasePrivate::windowContextFactoryMethod; + WindowAgentBasePrivate::windowContextFactoryMethod = createTestContext; + contextDestroyedWithBorder = false; + queriedOffGuiThread = false; + observedBorder.clear(); + testColor = Qt::red; + } + + void cleanup() { + WindowAgentBasePrivate::windowContextFactoryMethod = previousFactory; + QVERIFY(!contextDestroyedWithBorder); + QVERIFY(!queriedOffGuiThread.load()); + } + + void agentBeforeWindow() { + QQuickWindow window; + window.resize(240, 120); + window.setColor(Qt::white); + auto agent = std::make_unique(); + QVERIFY(agent->setup(&window)); + QCOMPARE(window.contentItem()->childItems().size(), 1); + observedBorder = window.contentItem()->childItems().first(); + showTestWindow(window); + QVERIFY(QTest::qWaitForWindowExposed(&window)); + QTRY_VERIFY(hasTopLine(window, Qt::red)); + + agent.reset(); + QVERIFY(observedBorder.isNull()); + QVERIFY(window.contentItem()->childItems().isEmpty()); + window.resize(260, 140); + window.requestActivate(); + window.update(); + QTRY_VERIFY(hasTopLine(window, Qt::white)); + } + + void windowBeforeAgent() { + auto agent = std::make_unique(); + auto window = std::make_unique(); + window->resize(240, 120); + QVERIFY(agent->setup(window.get())); + QCOMPARE(window->contentItem()->childItems().size(), 1); + observedBorder = window->contentItem()->childItems().first(); + showTestWindow(*window); + QVERIFY(QTest::qWaitForWindowExposed(window.get())); + QVERIFY(!window->grabWindow().isNull()); + window.reset(); + QVERIFY(observedBorder.isNull()); + agent.reset(); // Must not delete the already-destroyed decoration again. + } + + void geometryColorAndSceneAttachment() { + QQuickWindow window; + window.resize(240, 120); + window.setColor(Qt::white); + QuickWindowAgent agent; + QVERIFY(agent.setup(&window)); + QCOMPARE(window.contentItem()->childItems().size(), 1); + observedBorder = window.contentItem()->childItems().first(); + auto border = observedBorder.data(); + QVERIFY(!qobject_cast(border)); + QVERIFY(border->childItems().isEmpty()); + QCOMPARE(border->acceptedMouseButtons(), Qt::NoButton); + QVERIFY(!border->acceptTouchEvents()); + QVERIFY(!border->acceptHoverEvents()); + QVERIFY(!border->activeFocusOnTab()); + QVERIFY(!border->isEnabled()); + + showTestWindow(window); + QVERIFY(QTest::qWaitForWindowExposed(&window)); + QCOMPARE(border->y(), qreal(0)); + QVERIFY(qAbs(border->height() * window.effectiveDevicePixelRatio() - 1) < 0.000001); + qInfo() << "Actual DPR:" << window.effectiveDevicePixelRatio(); + const qreal requestedScale = qEnvironmentVariable("QT_SCALE_FACTOR").toDouble(); + if (requestedScale > 0) + QCOMPARE(window.effectiveDevicePixelRatio(), requestedScale); + QTRY_VERIFY(hasTopLine(window, Qt::red)); + window.resize(320, 160); + QTRY_COMPARE(border->width(), window.contentItem()->width()); + QTRY_VERIFY(hasTopLine(window, Qt::red)); + + testColor = Qt::blue; + MSG message{}; + message.message = WM_SYSCOLORCHANGE; + QT_NATIVE_EVENT_RESULT_TYPE result = 0; + testContext->nativeDispatch({}, &message, &result); + QTRY_VERIFY(hasTopLine(window, Qt::blue)); + + window.setWindowStates(Qt::WindowMaximized); + QTRY_VERIFY(!border->isVisible()); + window.setWindowStates(Qt::WindowNoState); + QTRY_VERIFY(border->isVisible()); + QTRY_VERIFY(hasTopLine(window, Qt::blue)); + + // Detachment releases the item's node even on backends that retain their entire + // scene graph when a window is merely hidden or releaseResources() is requested. + border->setParentItem(nullptr); + QTRY_VERIFY(hasTopLine(window, Qt::white)); + border->setParentItem(window.contentItem()); + QTRY_VERIFY(hasTopLine(window, Qt::blue)); + } + +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + void nativeSurfaceLifecycle() { + auto window = std::make_unique(); + window->resize(240, 120); + auto agent = std::make_unique(); + QVERIFY(agent->setup(window.get())); + observedBorder = window->contentItem()->childItems().first(); + showTestWindow(*window); + QVERIFY(QTest::qWaitForWindowExposed(window.get())); + QCOMPARE(window->rendererInterface()->graphicsApi(), QSGRendererInterface::Direct3D11); + QTRY_COMPARE(observedBorder->childItems().size(), 1); + QVERIFY(qobject_cast(observedBorder->childItems().first())); + QCOMPARE(observedBorder->height(), qreal(0.5)); + QCOMPARE(observedBorder->y(), qreal(-0.49)); + + QSignalSpy frames(window.get(), &QQuickWindow::afterSynchronizing); + window->resize(260, 140); + QTRY_VERIFY(!frames.isEmpty()); + agent.reset(); + QVERIFY(observedBorder.isNull()); + frames.clear(); + window->update(); + QTRY_VERIFY(!frames.isEmpty()); + + agent = std::make_unique(); + QVERIFY(agent->setup(window.get())); + observedBorder = window->contentItem()->childItems().first(); + window->update(); + QTRY_COMPARE(observedBorder->childItems().size(), 1); + window.reset(); + QVERIFY(observedBorder.isNull()); + agent.reset(); + } +#endif + +private: + WindowAgentBasePrivate::WindowContextFactoryMethod previousFactory = nullptr; +}; + +int main(int argc, char **argv) { +#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) + QGuiApplication::setAttribute(Qt::AA_EnableHighDpiScaling); +#endif +#if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0) + QGuiApplication::setHighDpiScaleFactorRoundingPolicy(Qt::HighDpiScaleFactorRoundingPolicy::PassThrough); +#endif + QGuiApplication application(argc, argv); + QuickBorderTest test; + return QTest::qExec(&test, argc, argv); +} + +#include "tst_quickborder.moc" From 76e19f34577a5b24ec19e5b5d0ca03dea0436139 Mon Sep 17 00:00:00 2001 From: wangwenx190 <2546789017@qq.com> Date: Tue, 22 Sep 2026 14:20:03 +0800 Subject: [PATCH 04/56] fix(examples): initialize the QML window agent before registering controls Resolve AUDIT-004 in FramelessWindow.qml. Previously the root window called setup() from its Component.onCompleted handler, while the title bar and system buttons registered themselves from separate child completion handlers. Qt does not guarantee the order of these handlers. A child handler could therefore dereference QuickWindowAgent's null context before setup created it. Keep initialization in one root completion handler: require setup to succeed, register the title bar, register the four non-macOS system buttons, apply the dark-mode attribute, and finally honor showWhenReady. Log an error and return immediately when setup fails. Remove the child registration handlers and explain why the calls must stay together. Preserve the macOS button exclusion and the delayed visibility used by secondary windows. Validation: - Strict static review of setup/context creation, all completion handlers, item availability, registration order, platform conditions and visibility. - Windows Release QWKExample_QML build passed with Qt 6.12.0 and MSVC 19.51. - A temporary local harness loaded the actual FramelessWindow.qml resources for default-visible and showWhenReady=false windows. An instrumented agent forwarded successful calls to the production implementation and verified the complete call order and the identity of each registered control. - Injected setup failure in the same harness: both windows only called setup, performed no registrations or attribute writes, and remained hidden. - git diff --check passed. Validation limits: loading the full main.qml encountered the pre-existing "FrameAnimation is not a type" error, so runtime checks loaded FramelessWindow itself instead. This commit does not claim successful full-demo startup or runtime coverage on Linux/macOS. Temporary harness files remain in the ignored build directory. The audit document and submodule changes are excluded. --- examples/qml/FramelessWindow.qml | 34 +++++++++++--------------------- 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/examples/qml/FramelessWindow.qml b/examples/qml/FramelessWindow.qml index 3404820c..694c6766 100644 --- a/examples/qml/FramelessWindow.qml +++ b/examples/qml/FramelessWindow.qml @@ -15,7 +15,18 @@ Window { color: darkStyle.windowBackgroundColor title: qsTr("QWindowKit QtQuick Demo") Component.onCompleted: { - windowAgent.setup(window) + if (!windowAgent.setup(window)) { + console.error("Failed to set up the window agent.") + return + } + // Keep registration here: Component.onCompleted ordering between objects is undefined. + windowAgent.setTitleBar(titleBar) + if (!window.isMacOS) { + windowAgent.setSystemButton(WindowAgent.WindowIcon, iconButton) + windowAgent.setSystemButton(WindowAgent.Minimize, minButton) + windowAgent.setSystemButton(WindowAgent.Maximize, maxButton) + windowAgent.setSystemButton(WindowAgent.Close, closeButton) + } windowAgent.setWindowAttribute("dark-mode", true) if (window.showWhenReady) { window.visible = true @@ -71,7 +82,6 @@ Window { height: 32 //color: window.active ? "#3C3C3C" : "#505050" color: "transparent" - Component.onCompleted: windowAgent.setTitleBar(titleBar) Image { id: iconButton @@ -86,11 +96,6 @@ Window { mipmap: true source: "qrc:///app/example.png" fillMode: Image.PreserveAspectFit - Component.onCompleted: { - if (!window.isMacOS) { - windowAgent.setSystemButton(WindowAgent.WindowIcon, iconButton) - } - } } Text { @@ -124,11 +129,6 @@ Window { height: parent.height source: "qrc:///window-bar/minimize.svg" onClicked: window.showMinimized() - Component.onCompleted: { - if (!window.isMacOS) { - windowAgent.setSystemButton(WindowAgent.Minimize, minButton) - } - } } QWKButton { @@ -142,11 +142,6 @@ Window { window.showMaximized() } } - Component.onCompleted: { - if (!window.isMacOS) { - windowAgent.setSystemButton(WindowAgent.Maximize, maxButton) - } - } } QWKButton { @@ -168,11 +163,6 @@ Window { } } onClicked: window.close() - Component.onCompleted: { - if (!window.isMacOS) { - windowAgent.setSystemButton(WindowAgent.Close, closeButton) - } - } } } } From 182118b485bab134a9defdea6edc38bed7c2ddca Mon Sep 17 00:00:00 2001 From: wangwenx190 <2546789017@qq.com> Date: Tue, 22 Sep 2026 14:41:19 +0800 Subject: [PATCH 05/56] fix(quick): generate and install QML tooling metadata for Qt 6 QWKQuick previously registered WindowAgent only through qmlRegisterType(). No qmldir or qmltypes file described the module to editors and static tools, so valid QWindowKit imports, methods and enum values could be marked unknown. For Qt 6.3 and newer, define QWindowKit 1.0 with qt6_add_qml_module and mark QuickWindowAgent as the WindowAgent QML element. Extract Core metatypes and expose WindowAgentBase through an anonymous foreign declaration so generated type information includes inherited methods and the SystemButton enum without adding a Qt Qml dependency to Core. Keep the existing public registerTypes() entry point and its once-only behavior, invoking Qt's generated registration function explicitly so static linkers retain the registration object too. Older Qt releases keep their procedural registration path. Generate qmldir and QWKQuick.qmltypes under the build tree's qml/QWindowKit and install them to /qml/QWindowKit using qm_install_qml_modules. Support both the old Qml and new QtQml qmsetup module names without changing the submodule. Export/install Qt-generated auxiliary resource targets needed by static consumers. Fix the installed static package's missing Qt private component dependencies, uncovered by the new installed Quick consumer test. No separate runtime QML plugin is introduced. Expose QWindowKit_QML_IMPORT_PATH for build-tree and installed CMake users; resolve installed paths relative to the package prefix for relocation. Populate qmake's QML_IMPORT_PATH when installed metadata exists. Document Qt Creator, other editor import paths, qt_add_qml_module IMPORT_PATH and qmllint usage in both READMEs, including the Qt version and runtime setup requirements. Add a Windows CTest regression that stages a real installation, checks the metadata files, runs qmllint against only that custom import root, and builds and executes a separate qt_add_qml_module consumer of the installed package. It checks the inherited API, actual title-bar/button registration, enum values, repeated registerTypes calls, two engines and stable type IDs. A negative control without qmltypes must diagnose the missing WindowAgent. Parse qmllint's JSON success field as well as its exit status because newer Qt versions can return zero even when warnings are present. Validation on Windows, Qt 6.12.0, MSVC 19.51: - Shared Release and Debug builds passed. - All 8 registered Release CTests passed, including installed CMake/QML consumers and the existing software/D3D11 border regressions. - Installed QML consumer test passed separately for shared Debug and static Release, including lint, generated QML code, runtime and negative control. - Final scoped static review and git diff --check passed. Qt 5, Qt 6.0-6.2 and non-Windows runtime behavior were not executed; their existing registration path is retained. Editor UI highlighting itself was not exercised; tooling recognition was verified with qmllint. Submodule changes and STATIC_AUDIT.md are intentionally excluded from this commit. --- README.md | 19 +++++++++ README.zh-CN.md | 19 +++++++++ share/qmake/QWKQuick.pri.in | 7 +++- src/QWindowKitConfig.cmake.in | 26 +++++++++++- src/core/CMakeLists.txt | 5 +++ src/quick/CMakeLists.txt | 38 +++++++++++++++++ src/quick/qmltypes_p.h | 17 ++++++++ src/quick/quickwindowagent.h | 7 ++++ src/quick/qwkquickglobal.cpp | 12 +++++- tests/CMakeLists.txt | 4 ++ tests/qmltypes/CMakeLists.txt | 21 ++++++++++ tests/qmltypes/RunInstalled.cmake | 58 ++++++++++++++++++++++++++ tests/qmltypes/consumer/CMakeLists.txt | 21 ++++++++++ tests/qmltypes/consumer/Window.qml | 25 +++++++++++ tests/qmltypes/consumer/main.cpp | 44 +++++++++++++++++++ 15 files changed, 319 insertions(+), 4 deletions(-) create mode 100644 src/quick/qmltypes_p.h create mode 100644 tests/qmltypes/CMakeLists.txt create mode 100644 tests/qmltypes/RunInstalled.cmake create mode 100644 tests/qmltypes/consumer/CMakeLists.txt create mode 100644 tests/qmltypes/consumer/Window.qml create mode 100644 tests/qmltypes/consumer/main.cpp diff --git a/README.md b/README.md index e052316b..4bd90b03 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,25 @@ target_link_libraries(my_quick_app PRIVATE QWindowKit can also be added as a CMake subdirectory when it is vendored into your project. +With Qt 6.3 or later, building Quick generates `qml/QWindowKit/qmldir` and +`QWKQuick.qmltypes` in the build tree and installs them under `/qml/QWindowKit`. +`QWindowKit_QML_IMPORT_PATH` points to the import root for both CMake integration methods. +Add it to Qt Creator's code model and to the application's QML module: + +```cmake +list(APPEND QML_IMPORT_PATH "${QWindowKit_QML_IMPORT_PATH}") +list(REMOVE_DUPLICATES QML_IMPORT_PATH) +set(QML_IMPORT_PATH "${QML_IMPORT_PATH}" CACHE STRING "QML import paths" FORCE) +# When using qt_add_qml_module(my_quick_app ...), also pass: +# IMPORT_PATH "${QWindowKit_QML_IMPORT_PATH}" +``` + +For other editors, add this same directory to the QML language server's import paths. +For a direct check, run `qmllint -I /qml `. +Continue linking `QWindowKit::Quick` and calling `QWK::registerTypes(&engine)` before loading +QML, including in static builds; this module does not install a runtime plugin. +Qt 5 and Qt 6.0–6.2 retain procedural registration without generated tooling metadata. + ### qmake After installing QWindowKit with CMake, include the generated `.pri` file: diff --git a/README.zh-CN.md b/README.zh-CN.md index 36b9e6e7..75ce45f0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -147,6 +147,25 @@ target_link_libraries(my_quick_app PRIVATE 如果你将 QWindowKit 作为源码依赖放入项目,也可以通过 CMake `add_subdirectory()` 集成。 +使用 Qt 6.3 或更高版本构建 Quick 时,会在构建目录的 `qml/QWindowKit` 中生成 +`qmldir` 和 `QWKQuick.qmltypes`,并安装到 `/qml/QWindowKit`。 +上述两种 CMake 集成方式均提供 `QWindowKit_QML_IMPORT_PATH`,指向 QML 导入根目录。 +将其加入 Qt Creator 的代码模型以及应用的 QML 模块: + +```cmake +list(APPEND QML_IMPORT_PATH "${QWindowKit_QML_IMPORT_PATH}") +list(REMOVE_DUPLICATES QML_IMPORT_PATH) +set(QML_IMPORT_PATH "${QML_IMPORT_PATH}" CACHE STRING "QML import paths" FORCE) +# 使用 qt_add_qml_module(my_quick_app ...) 时,还需传入: +# IMPORT_PATH "${QWindowKit_QML_IMPORT_PATH}" +``` + +其他编辑器需要将同一目录加入 QML 语言服务器的导入路径。 +也可运行 `qmllint -I /qml ` 检查。 +仍须链接 `QWindowKit::Quick`,并在加载 QML 前调用 `QWK::registerTypes(&engine)`, +静态构建也采用这一方式;本模块不安装运行时插件。 +Qt 5 和 Qt 6.0–6.2 保留原来的手动注册方式,暂不生成工具元数据。 + ### qmake 先使用 CMake 安装 QWindowKit,然后包含生成的 `.pri` 文件: diff --git a/share/qmake/QWKQuick.pri.in b/share/qmake/QWKQuick.pri.in index 24399997..3981e393 100644 --- a/share/qmake/QWKQuick.pri.in +++ b/share/qmake/QWKQuick.pri.in @@ -10,4 +10,9 @@ } @QMAKE_QWK_QUICK_STATIC_MACRO@ -} \ No newline at end of file + + # Qt Creator's QML code model also needs the installed module metadata. + exists($$QMAKE_QWK_INSTALL_PREFIX/qml/QWindowKit/qmldir) { + QML_IMPORT_PATH += $$QMAKE_QWK_INSTALL_PREFIX/qml + } +} diff --git a/src/QWindowKitConfig.cmake.in b/src/QWindowKitConfig.cmake.in index 51d8a21d..d01d5f45 100644 --- a/src/QWindowKitConfig.cmake.in +++ b/src/QWindowKitConfig.cmake.in @@ -1,5 +1,10 @@ @PACKAGE_INIT@ +# Resolve before find_dependency(), which can change PACKAGE_PREFIX_DIR on older CMake. +if("@QWINDOWKIT_HAS_QML_MODULE@") + set_and_check(QWindowKit_QML_IMPORT_PATH "${PACKAGE_PREFIX_DIR}/qml") +endif() + include(CMakeFindDependencyMacro) find_dependency(QT NAMES Qt6 Qt5 COMPONENTS Core Gui REQUIRED) @@ -15,4 +20,23 @@ if ("Quick" IN_LIST QWindowKit_FIND_COMPONENTS) find_dependency(Qt${QT_VERSION_MAJOR} COMPONENTS Quick REQUIRED) endif() -include("${CMAKE_CURRENT_LIST_DIR}/QWindowKitTargets.cmake") \ No newline at end of file +# Static archives expose their private Qt link dependencies to consumers. Recent +# Qt 6 releases put these targets in separate packages instead of loading them +# with the public components. +if("@QWINDOWKIT_BUILD_STATIC@" AND QT_VERSION_MAJOR EQUAL 6) + set(_qwk_private_modules Core Gui) + foreach(_module Widgets Quick) + if("${_module}" IN_LIST QWindowKit_FIND_COMPONENTS) + list(APPEND _qwk_private_modules "${_module}") + endif() + endforeach() + foreach(_module IN LISTS _qwk_private_modules) + if(NOT TARGET Qt6::${_module}Private) + find_dependency(Qt6 COMPONENTS ${_module}Private REQUIRED) + endif() + endforeach() + unset(_qwk_private_modules) + unset(_module) +endif() + +include("${CMAKE_CURRENT_LIST_DIR}/QWindowKitTargets.cmake") diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 4bf684d8..36849c51 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -99,5 +99,10 @@ qwk_add_library(${PROJECT_NAME} QT_AUTOGEN qwk_sync_include(${PROJECT_NAME} FORCE ${_sync_include_options}) +# QWKQuick's type registrar needs the inherited methods and SystemButton enum. +if(QWINDOWKIT_BUILD_QUICK AND QT_VERSION VERSION_GREATER_EQUAL 6.3) + qt6_extract_metatypes(${PROJECT_NAME}) +endif() + set(QWINDOWKIT_ENABLED_TARGETS ${QWINDOWKIT_ENABLED_TARGETS} ${PROJECT_NAME} PARENT_SCOPE) set(QWINDOWKIT_ENABLED_SUBDIRECTORIES ${QWINDOWKIT_ENABLED_SUBDIRECTORIES} core PARENT_SCOPE) diff --git a/src/quick/CMakeLists.txt b/src/quick/CMakeLists.txt index a320d740..0462a8e7 100644 --- a/src/quick/CMakeLists.txt +++ b/src/quick/CMakeLists.txt @@ -31,5 +31,43 @@ qwk_add_library(${PROJECT_NAME} QT_AUTOGEN qwk_sync_include(${PROJECT_NAME} FORCE) +# Qt's registrar produces both the runtime registration and the tooling metadata. +# Older Qt releases keep the existing procedural registration path. +if(QT_VERSION VERSION_GREATER_EQUAL 6.3) + set(QWindowKit_QML_IMPORT_PATH "${CMAKE_BINARY_DIR}/qml" CACHE PATH + "QWindowKit QML tooling import directory" FORCE) + set(QWINDOWKIT_HAS_QML_MODULE ON PARENT_SCOPE) + qt6_add_qml_module(${PROJECT_NAME} + URI QWindowKit + VERSION 1.0 + NO_PLUGIN + OUTPUT_DIRECTORY "${QWindowKit_QML_IMPORT_PATH}/QWindowKit" + RESOURCE_PREFIX /qt/qml + DEPENDENCIES QtQuick/2.0 + SOURCES qmltypes_p.h + OUTPUT_TARGETS _qml_output_targets + ) + target_compile_definitions(${PROJECT_NAME} PRIVATE QWK_QUICK_HAS_QML_MODULE) + + if(QWINDOWKIT_INSTALL) + # Qml is also the module name in older qmsetup releases. + if(EXISTS "${QMSETUP_MODULES_DIR}/modules/QtQml.cmake") + qm_import(QtQml) + else() + qm_import(Qml) + endif() + qm_install_qml_modules(${PROJECT_NAME} PREFIX qml) + if(_qml_output_targets) + install(TARGETS ${_qml_output_targets} + EXPORT ${QWINDOWKIT_EXPORT} + RUNTIME DESTINATION ${QWINDOWKIT_INSTALL_RUNTIME_DIR} + LIBRARY DESTINATION ${QWINDOWKIT_INSTALL_LIBRARY_DIR} + ARCHIVE DESTINATION ${QWINDOWKIT_INSTALL_LIBRARY_DIR} + OBJECTS DESTINATION ${QWINDOWKIT_INSTALL_LIBRARY_DIR} + ) + endif() + endif() +endif() + set(QWINDOWKIT_ENABLED_TARGETS ${QWINDOWKIT_ENABLED_TARGETS} ${PROJECT_NAME} PARENT_SCOPE) set(QWINDOWKIT_ENABLED_SUBDIRECTORIES ${QWINDOWKIT_ENABLED_SUBDIRECTORIES} quick PARENT_SCOPE) diff --git a/src/quick/qmltypes_p.h b/src/quick/qmltypes_p.h new file mode 100644 index 00000000..154782bc --- /dev/null +++ b/src/quick/qmltypes_p.h @@ -0,0 +1,17 @@ +// Copyright (C) 2023-present Stdware Collections (https://www.github.com/stdware) +// SPDX-License-Identifier: Apache-2.0 + +#ifndef QWK_QMLTYPES_P_H +#define QWK_QMLTYPES_P_H + +#include +#include + +// Describe the inherited API without making Core depend on Qt Qml. +struct WindowAgentBaseForeign { + Q_GADGET + QML_FOREIGN(QWK::WindowAgentBase) + QML_ANONYMOUS +}; + +#endif // QWK_QMLTYPES_P_H diff --git a/src/quick/quickwindowagent.h b/src/quick/quickwindowagent.h index efb0d606..70d99755 100644 --- a/src/quick/quickwindowagent.h +++ b/src/quick/quickwindowagent.h @@ -8,6 +8,10 @@ #include #include +#if QT_VERSION >= QT_VERSION_CHECK(6, 3, 0) +# include +#endif + #include #include @@ -17,6 +21,9 @@ namespace QWK { class QWK_QUICK_EXPORT QuickWindowAgent : public WindowAgentBase { Q_OBJECT +#if QT_VERSION >= QT_VERSION_CHECK(6, 3, 0) + QML_NAMED_ELEMENT(WindowAgent) +#endif Q_DECLARE_PRIVATE(QuickWindowAgent) public: explicit QuickWindowAgent(QObject *parent = nullptr); diff --git a/src/quick/qwkquickglobal.cpp b/src/quick/qwkquickglobal.cpp index 9eff69ae..d6387644 100644 --- a/src/quick/qwkquickglobal.cpp +++ b/src/quick/qwkquickglobal.cpp @@ -8,9 +8,11 @@ #include "quickwindowagent.h" -namespace QWK { +#ifdef QWK_QUICK_HAS_QML_MODULE +void qml_register_types_QWindowKit(); +#endif - static constexpr const char kModuleUri[] = "QWindowKit"; +namespace QWK { void registerTypes(QQmlEngine *engine) { Q_UNUSED(engine); @@ -21,9 +23,15 @@ namespace QWK { } once = true; +#ifdef QWK_QUICK_HAS_QML_MODULE + // Explicitly reference the generated registration for static library consumers too. + qml_register_types_QWindowKit(); +#else + static constexpr const char kModuleUri[] = "QWindowKit"; // @uri QWindowKit qmlRegisterType(kModuleUri, 1, 0, "WindowAgent"); qmlRegisterModule(kModuleUri, 1, 0); +#endif } } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a5684bc4..303d23c6 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,5 +1,9 @@ add_subdirectory(buildsystems) +if(WIN32 AND QWINDOWKIT_BUILD_QUICK AND QWINDOWKIT_INSTALL) + add_subdirectory(qmltypes) +endif() + if(WIN32 AND QWINDOWKIT_BUILD_QUICK AND QWINDOWKIT_ENABLE_WINDOWS_SYSTEM_BORDERS) add_subdirectory(quickborder) endif() diff --git a/tests/qmltypes/CMakeLists.txt b/tests/qmltypes/CMakeLists.txt new file mode 100644 index 00000000..60ab53af --- /dev/null +++ b/tests/qmltypes/CMakeLists.txt @@ -0,0 +1,21 @@ +qm_find_qt(Quick) +if(QT_VERSION VERSION_LESS 6.3) + return() +endif() + +add_test(NAME qmltypes.installed + COMMAND ${CMAKE_COMMAND} + "-DSOURCE_DIR=${CMAKE_CURRENT_SOURCE_DIR}/consumer" + "-DBUILD_DIR=${PROJECT_BINARY_DIR}" + "-DWORK_DIR=${CMAKE_CURRENT_BINARY_DIR}/installed-$" + "-DBUILD_CONFIG=$" + "-DGENERATOR=${CMAKE_GENERATOR}" + "-DGENERATOR_PLATFORM=${CMAKE_GENERATOR_PLATFORM}" + "-DGENERATOR_TOOLSET=${CMAKE_GENERATOR_TOOLSET}" + "-DQT_DIR=${Qt6_DIR}" + "-DQT_BIN_DIR=$" + "-DINSTALL_BINDIR=${CMAKE_INSTALL_BINDIR}" + "-DQT_QMLLINT=$" + -P "${CMAKE_CURRENT_SOURCE_DIR}/RunInstalled.cmake" +) +set_tests_properties(qmltypes.installed PROPERTIES TIMEOUT 180 LABELS "qml;install;windows") diff --git a/tests/qmltypes/RunInstalled.cmake b/tests/qmltypes/RunInstalled.cmake new file mode 100644 index 00000000..fe97d1f6 --- /dev/null +++ b/tests/qmltypes/RunInstalled.cmake @@ -0,0 +1,58 @@ +function(run_step name) + execute_process(COMMAND ${ARGN} RESULT_VARIABLE result + OUTPUT_VARIABLE output ERROR_VARIABLE error TIMEOUT 90) + if(NOT result EQUAL 0) + message(FATAL_ERROR "${name} failed (${result})\n${output}\n${error}") + endif() +endfunction() + +function(run_lint import_path expect_success) + execute_process(COMMAND "${QT_QMLLINT}" --ignore-settings --json - + -I "${import_path}" "${SOURCE_DIR}/Window.qml" + RESULT_VARIABLE result OUTPUT_VARIABLE output ERROR_VARIABLE error TIMEOUT 30) + # Newer qmllint versions can return zero even with warnings. Check the report as well. + string(JSON success GET "${output}" files 0 success) + if(expect_success) + if(NOT result EQUAL 0 OR NOT success) + message(FATAL_ERROR "Installed QML lint failed (${result})\n${output}\n${error}") + endif() + elseif(success OR NOT output MATCHES "WindowAgent") + message(FATAL_ERROR "Negative control did not diagnose missing WindowAgent metadata\n${output}") + endif() +endfunction() + +set(prefix "${WORK_DIR}/prefix") +file(REMOVE "${prefix}/qml/QWindowKit/qmldir" "${prefix}/qml/QWindowKit/QWKQuick.qmltypes") +run_step(install "${CMAKE_COMMAND}" --install "${BUILD_DIR}" + --prefix "${prefix}" --config "${BUILD_CONFIG}") +foreach(file qmldir QWKQuick.qmltypes) + if(NOT EXISTS "${prefix}/qml/QWindowKit/${file}") + message(FATAL_ERROR "Installed QML metadata is missing: ${file}") + endif() +endforeach() + +# Only the staged installation is on the custom import path, never the build tree. +run_lint("${prefix}/qml" TRUE) + +set(generator_args -G "${GENERATOR}") +if(GENERATOR_PLATFORM) + list(APPEND generator_args -A "${GENERATOR_PLATFORM}") +endif() +if(GENERATOR_TOOLSET) + list(APPEND generator_args -T "${GENERATOR_TOOLSET}") +endif() +run_step(configure "${CMAKE_COMMAND}" -S "${SOURCE_DIR}" -B "${WORK_DIR}/build" + ${generator_args} "-DCMAKE_PREFIX_PATH=${prefix}" "-DQT_DIR=${QT_DIR}" "-DQt6_DIR=${QT_DIR}" + "-DCMAKE_BUILD_TYPE=${BUILD_CONFIG}") +run_step(build "${CMAKE_COMMAND}" --build "${WORK_DIR}/build" --config "${BUILD_CONFIG}") +file(READ "${WORK_DIR}/build/consumer-${BUILD_CONFIG}.txt" consumer) +set(ENV{PATH} "${prefix}/${INSTALL_BINDIR};${QT_BIN_DIR};$ENV{PATH}") +set(ENV{QT_QPA_PLATFORM} windows) +set(ENV{QT_QUICK_BACKEND} software) +run_step(runtime "${consumer}" "${SOURCE_DIR}/Window.qml" "${prefix}/qml") + +# Prove that the lint check needs our type information, rather than passing on imports alone. +file(MAKE_DIRECTORY "${WORK_DIR}/without-types/QWindowKit") +file(WRITE "${WORK_DIR}/without-types/QWindowKit/qmldir" "module QWindowKit\n") +run_lint("${WORK_DIR}/without-types" FALSE) +message(STATUS "Installed QML metadata, lint, runtime registration and negative control passed") diff --git a/tests/qmltypes/consumer/CMakeLists.txt b/tests/qmltypes/consumer/CMakeLists.txt new file mode 100644 index 00000000..248891b0 --- /dev/null +++ b/tests/qmltypes/consumer/CMakeLists.txt @@ -0,0 +1,21 @@ +cmake_minimum_required(VERSION 3.19) +project(qwk_qml_consumer LANGUAGES CXX) + +find_package(QWindowKit REQUIRED COMPONENTS Quick) +if(NOT EXISTS "${QWindowKit_QML_IMPORT_PATH}/QWindowKit/QWKQuick.qmltypes") + message(FATAL_ERROR "The package did not expose a usable QML import path") +endif() + +add_executable(qwk_qml_consumer main.cpp) +target_compile_features(qwk_qml_consumer PRIVATE cxx_std_17) +target_link_libraries(qwk_qml_consumer PRIVATE QWindowKit::Quick) +set_target_properties(qwk_qml_consumer PROPERTIES AUTOMOC ON) +qt6_add_qml_module(qwk_qml_consumer + URI QwkConsumer + VERSION 1.0 + RESOURCE_PREFIX /test + IMPORT_PATH "${QWindowKit_QML_IMPORT_PATH}" + QML_FILES Window.qml +) +file(GENERATE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/consumer-$.txt" + CONTENT "$") diff --git a/tests/qmltypes/consumer/Window.qml b/tests/qmltypes/consumer/Window.qml new file mode 100644 index 00000000..028285af --- /dev/null +++ b/tests/qmltypes/consumer/Window.qml @@ -0,0 +1,25 @@ +import QtQuick 2.15 +import QtQuick.Window 2.15 +import QWindowKit 1.0 + +Window { + id: window + property bool initialized: false + property int minimizeRole: WindowAgent.Minimize + Item { id: titleBar } + Item { id: minimizeButton } + WindowAgent { id: agent } + + Component.onCompleted: { + if (!agent.setup(window)) + return + agent.setTitleBar(titleBar) + agent.setSystemButton(WindowAgent.Minimize, minimizeButton) + agent.setHitTestVisible(minimizeButton, true) + agent.setWindowAttribute("dark-mode", true) + agent.centralize() + window.initialized = agent.titleBar() === titleBar + && agent.systemButton(WindowAgent.Minimize) === minimizeButton + && agent.isHitTestVisible(minimizeButton) + } +} diff --git a/tests/qmltypes/consumer/main.cpp b/tests/qmltypes/consumer/main.cpp new file mode 100644 index 00000000..0f873528 --- /dev/null +++ b/tests/qmltypes/consumer/main.cpp @@ -0,0 +1,44 @@ +// Copyright (C) 2023-present Stdware Collections (https://www.github.com/stdware) +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include + +int main(int argc, char **argv) { + QGuiApplication app(argc, argv); + if (argc != 3) + return 1; + + // Exercise the public registration entry point repeatedly and across engines. + // This also catches static archives dropping Qt's generated registration object. + int registeredType = -1; + for (int i = 0; i < 2; ++i) { + bool warnings = false; + QQmlApplicationEngine engine; + engine.addImportPath(QString::fromLocal8Bit(argv[2])); + QWK::registerTypes(&engine); + QWK::registerTypes(&engine); + const int type = qmlTypeId("QWindowKit", 1, 0, "WindowAgent"); + if (type < 0 || (i > 0 && registeredType != type)) + return 4; + registeredType = type; + QObject::connect(&engine, &QQmlEngine::warnings, [&warnings](const QList &errors) { + warnings = true; + for (const auto &error : errors) + std::cerr << error.toString().toStdString() << '\n'; + }); + engine.load(QUrl::fromLocalFile(QString::fromLocal8Bit(argv[1]))); + if (warnings || engine.rootObjects().size() != 1) + return 2; + if (qmlTypeId("QWindowKit", 1, 0, "WindowAgent") != registeredType) + return 5; + const auto window = engine.rootObjects().first(); + if (!window->property("initialized").toBool() + || window->property("minimizeRole").toInt() != QWK::WindowAgentBase::Minimize) + return 3; + } + return 0; +} From 2f9d16682b8ddfb485f982b034ed879fcc702624 Mon Sep 17 00:00:00 2001 From: wangwenx190 <2546789017@qq.com> Date: Tue, 22 Sep 2026 15:11:52 +0800 Subject: [PATCH 06/56] fix(core): guard the Wayland context by native API availability Linux builds with Qt 6.0-6.4 compiled and instantiated LinuxWaylandContext although QNativeInterface::QWaylandApplication was only introduced in Qt 6.5. Runtime platform selection could not prevent this compile error, including for applications intended to run on X11 (AUDIT-005). Define a shared availability guard in the private Wayland context header. Require Linux and Qt 6.5 or later; on Qt 6.7 and later also require the wayland feature, matching Qt's native interface declaration. Do not query that feature on Qt versions that predate it. Apply the same guard to the class, implementation, native includes and factory construction branch. Unsupported Wayland configurations reach the existing QtWindowContext fallback while the X11 and forced-Qt factory paths remain intact. Validation: - Reviewed Qt's official native interface documentation and 6.4, 6.5, 6.6, 6.7 and 6.8 headers; reviewed declarations, implementation, factory, automatic MOC input and CMake's unconditional Linux source list. - Built all configured targets on Windows with MSVC and Qt 6.12.0 Release. - Checked 20 preprocessor configurations across Qt 5.12, 6.0, 6.2, 6.4, 6.5, 6.6, 6.7 and 6.12, including feature and forced-Qt variations. Production directives with dependency includes stripped passed all branch checks; the same check found 10 mismatches on the original HEAD. This is a local preprocessor check, not a Linux compilation or unit test. - All eight registered CTest cases passed. The CMake consumer and installed QML tests initially hit MSBuild FileTracker E_ACCESSDENIED in the sandbox; rerunning those two tests and their install fixture outside it passed. - git diff --check passed. Linux Qt SDKs and WSL are unavailable here, so real Linux compilation, linking, MOC generation, X11 and Wayland runtime behavior remain untested. No unit test was added: this is a platform/header availability defect requiring actual Linux Qt build coverage, and the audit's current test scope defers Linux test infrastructure. Existing qmake/MSBuild consumer tests were not registered because their tools were not found in this shell. The local audit report is maintained separately and is not part of this commit; existing submodule changes are excluded. --- src/core/contexts/linuxwaylandcontext.cpp | 5 ++--- src/core/contexts/linuxwaylandcontext_p.h | 14 ++++++++++++-- src/core/windowagentbase.cpp | 2 ++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/core/contexts/linuxwaylandcontext.cpp b/src/core/contexts/linuxwaylandcontext.cpp index 50211e74..057554eb 100644 --- a/src/core/contexts/linuxwaylandcontext.cpp +++ b/src/core/contexts/linuxwaylandcontext.cpp @@ -5,9 +5,8 @@ #include "linuxwaylandcontext_p.h" +#ifdef QWK_HAS_WAYLAND_CONTEXT #include "qwindowkit_linux.h" - -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) #include namespace QWK { @@ -63,4 +62,4 @@ namespace QWK { } } } -#endif // QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) +#endif // QWK_HAS_WAYLAND_CONTEXT diff --git a/src/core/contexts/linuxwaylandcontext_p.h b/src/core/contexts/linuxwaylandcontext_p.h index 4157e4d2..b63f72d7 100644 --- a/src/core/contexts/linuxwaylandcontext_p.h +++ b/src/core/contexts/linuxwaylandcontext_p.h @@ -19,7 +19,17 @@ #include "qtwindowcontext_p.h" -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) +// QWaylandApplication was introduced in Qt 6.5. Since Qt 6.7 its declaration +// also depends on the wayland feature, which older Qt versions do not define. +#if defined(Q_OS_LINUX) && QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) +# if QT_VERSION < QT_VERSION_CHECK(6, 7, 0) +# define QWK_HAS_WAYLAND_CONTEXT +# elif QT_CONFIG(wayland) +# define QWK_HAS_WAYLAND_CONTEXT +# endif +#endif + +#ifdef QWK_HAS_WAYLAND_CONTEXT namespace QWK { class LinuxWaylandContext : public QtWindowContext { @@ -33,5 +43,5 @@ namespace QWK { }; } -#endif // QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) +#endif // QWK_HAS_WAYLAND_CONTEXT #endif // LINUXWAYLANDCONTEXT_P_H diff --git a/src/core/windowagentbase.cpp b/src/core/windowagentbase.cpp index 45b30bc1..b4d7afba 100644 --- a/src/core/windowagentbase.cpp +++ b/src/core/windowagentbase.cpp @@ -60,9 +60,11 @@ namespace QWK { # elif defined(Q_OS_MAC) return new CocoaWindowContext(); # elif defined(Q_OS_LINUX) && QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) +# ifdef QWK_HAS_WAYLAND_CONTEXT if (Private::isWaylandPlatform() && Private::waylandAPI().isValid()) { return new LinuxWaylandContext(); } +# endif if (Private::isX11Platform() && Private::x11API().isValid()) { return new LinuxX11Context(); } From 77bd2189c77f2a118f1544eca8b26861a6b324b6 Mon Sep 17 00:00:00 2001 From: wangwenx190 <2546789017@qq.com> Date: Tue, 22 Sep 2026 15:17:58 +0800 Subject: [PATCH 07/56] refactor(core): name X11 and Wayland backends by protocol Remove the Linux prefix from the private X11Context and WaylandContext classes, their source/header filenames and include guards. Update factory references and the CMake source list to match. Split the shared qwindowkit_linux helpers into qwindowkit_x11 and qwindowkit_wayland header/source pairs. Rename their private API structs to X11API and WaylandAPI and update includes at each call site. Refresh the tracked TODO references and the separately maintained local audit. This is a naming and file-organization change only. Preserve platform and Qt-version guards, context selection order, native library loading, function bodies, coordinates and fallback behavior. No additional platform support or audit fixes are included. Validation: - Compared every context file and the factory against HEAD with only the intended name/include substitutions; all matched exactly. - Verified both API structs and all four moved helper functions against HEAD after the API type rename; their bodies are unchanged. - Checked the source list, header ownership and remaining old references. - Built all configured targets on Windows/MSVC with Qt 6.12.0 Release. MSBuild FileTracker initially hit sandbox E_ACCESSDENIED; the build succeeded outside the sandbox. - All eight registered CTest cases passed outside the sandbox. - git diff --check passed. No new tests for this behavior-preserving rename. Actual Linux compilation and X11/Wayland execution remain untested because this host has no Linux Qt SDK or WSL. Existing qmsetup changes and the local STATIC_AUDIT.md are excluded from this commit. --- docs/TODO.md | 6 +- src/core/CMakeLists.txt | 14 +-- ...xwaylandcontext.cpp => waylandcontext.cpp} | 12 +-- ...xwaylandcontext_p.h => waylandcontext_p.h} | 12 +-- .../{linuxx11context.cpp => x11context.cpp} | 12 +-- .../{linuxx11context_p.h => x11context_p.h} | 12 +-- src/core/qwindowkit_linux.cpp | 87 ------------------- src/core/qwindowkit_wayland.cpp | 53 +++++++++++ ...windowkit_linux.h => qwindowkit_wayland.h} | 46 ++-------- src/core/qwindowkit_x11.cpp | 51 +++++++++++ src/core/qwindowkit_x11.h | 59 +++++++++++++ src/core/windowagentbase.cpp | 11 +-- 12 files changed, 211 insertions(+), 164 deletions(-) rename src/core/contexts/{linuxwaylandcontext.cpp => waylandcontext.cpp} (88%) rename src/core/contexts/{linuxwaylandcontext_p.h => waylandcontext_p.h} (83%) rename src/core/contexts/{linuxx11context.cpp => x11context.cpp} (96%) rename src/core/contexts/{linuxx11context_p.h => x11context_p.h} (78%) delete mode 100644 src/core/qwindowkit_linux.cpp create mode 100644 src/core/qwindowkit_wayland.cpp rename src/core/{qwindowkit_linux.h => qwindowkit_wayland.h} (55%) create mode 100644 src/core/qwindowkit_x11.cpp create mode 100644 src/core/qwindowkit_x11.h diff --git a/docs/TODO.md b/docs/TODO.md index ba4b5d63..bcea32ab 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -2,11 +2,11 @@ ## Wayland: `showSystemMenu()` may be passing the wrong coordinate space -**File:** `src/core/contexts/linuxwaylandcontext.cpp` +**File:** `src/core/contexts/waylandcontext.cpp` **Introduced by:** the Wayland/X11 backend contribution (Wing-summer), needs the original author's eyes. **Status:** unverified — reported from code reading only, no Wayland machine was available to test. -`LinuxWaylandContext::virtual_hook()` forwards the incoming point straight to the +`WaylandContext::virtual_hook()` forwards the incoming point straight to the xdg-shell request: ```cpp @@ -25,7 +25,7 @@ window's own position on screen, and the error disappears only when the window h the origin. Circumstantial evidence that the two Linux backends disagree about what they are handed: the X11 -path in `linuxx11context.cpp` treats the same argument as global and converts it to root +path in `x11context.cpp` treats the same argument as global and converts it to root coordinates, ```cpp diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 36849c51..d2a57b6f 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -37,8 +37,10 @@ if(WIN32) elseif(APPLE) else() list(APPEND _src - qwindowkit_linux.h - qwindowkit_linux.cpp + qwindowkit_x11.h + qwindowkit_x11.cpp + qwindowkit_wayland.h + qwindowkit_wayland.cpp ) endif() @@ -60,10 +62,10 @@ elseif(APPLE) ) else() list(APPEND _src - contexts/linuxx11context_p.h - contexts/linuxx11context.cpp - contexts/linuxwaylandcontext_p.h - contexts/linuxwaylandcontext.cpp + contexts/x11context_p.h + contexts/x11context.cpp + contexts/waylandcontext_p.h + contexts/waylandcontext.cpp ) endif() diff --git a/src/core/contexts/linuxwaylandcontext.cpp b/src/core/contexts/waylandcontext.cpp similarity index 88% rename from src/core/contexts/linuxwaylandcontext.cpp rename to src/core/contexts/waylandcontext.cpp index 057554eb..3372d79f 100644 --- a/src/core/contexts/linuxwaylandcontext.cpp +++ b/src/core/contexts/waylandcontext.cpp @@ -3,10 +3,10 @@ // Copyright (C) 2025-2027 Wing-summer (wingsummer) // SPDX-License-Identifier: Apache-2.0 -#include "linuxwaylandcontext_p.h" +#include "waylandcontext_p.h" #ifdef QWK_HAS_WAYLAND_CONTEXT -#include "qwindowkit_linux.h" +#include "qwindowkit_wayland.h" #include namespace QWK { @@ -22,15 +22,15 @@ namespace QWK { seat, serial, x, y); } - LinuxWaylandContext::LinuxWaylandContext() = default; + WaylandContext::WaylandContext() = default; - LinuxWaylandContext::~LinuxWaylandContext() = default; + WaylandContext::~WaylandContext() = default; - QString LinuxWaylandContext::key() const { + QString WaylandContext::key() const { return QStringLiteral("wayland"); } - void LinuxWaylandContext::virtual_hook(int id, void *data) { + void WaylandContext::virtual_hook(int id, void *data) { if (id == ShowSystemMenuHook) { auto *waylandApp = qApp->nativeInterface(); if (!waylandApp) { diff --git a/src/core/contexts/linuxwaylandcontext_p.h b/src/core/contexts/waylandcontext_p.h similarity index 83% rename from src/core/contexts/linuxwaylandcontext_p.h rename to src/core/contexts/waylandcontext_p.h index b63f72d7..1494f045 100644 --- a/src/core/contexts/linuxwaylandcontext_p.h +++ b/src/core/contexts/waylandcontext_p.h @@ -4,8 +4,8 @@ // SPDX-License-Identifier: Apache-2.0 -#ifndef LINUXWAYLANDCONTEXT_P_H -#define LINUXWAYLANDCONTEXT_P_H +#ifndef WAYLANDCONTEXT_P_H +#define WAYLANDCONTEXT_P_H // // W A R N I N G !!! @@ -32,11 +32,11 @@ #ifdef QWK_HAS_WAYLAND_CONTEXT namespace QWK { - class LinuxWaylandContext : public QtWindowContext { + class WaylandContext : public QtWindowContext { Q_OBJECT public: - LinuxWaylandContext(); - ~LinuxWaylandContext() override; + WaylandContext(); + ~WaylandContext() override; QString key() const override; void virtual_hook(int id, void *data) override; @@ -44,4 +44,4 @@ namespace QWK { } #endif // QWK_HAS_WAYLAND_CONTEXT -#endif // LINUXWAYLANDCONTEXT_P_H +#endif // WAYLANDCONTEXT_P_H diff --git a/src/core/contexts/linuxx11context.cpp b/src/core/contexts/x11context.cpp similarity index 96% rename from src/core/contexts/linuxx11context.cpp rename to src/core/contexts/x11context.cpp index bd1a7b28..25b77d20 100644 --- a/src/core/contexts/linuxx11context.cpp +++ b/src/core/contexts/x11context.cpp @@ -4,9 +4,9 @@ // SPDX-License-Identifier: Apache-2.0 -#include "linuxx11context_p.h" +#include "x11context_p.h" -#include "qwindowkit_linux.h" +#include "qwindowkit_x11.h" #if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) // copy from X11 library and simplify it @@ -125,15 +125,15 @@ union _XEvent { namespace QWK { - LinuxX11Context::LinuxX11Context() = default; + X11Context::X11Context() = default; - LinuxX11Context::~LinuxX11Context() = default; + X11Context::~X11Context() = default; - QString LinuxX11Context::key() const { + QString X11Context::key() const { return QStringLiteral("xcb"); } - void LinuxX11Context::virtual_hook(int id, void *data) { + void X11Context::virtual_hook(int id, void *data) { if (id == ShowSystemMenuHook) { // showSystemMenu() is public API and may be called before the window is created or // after it has been destroyed. diff --git a/src/core/contexts/linuxx11context_p.h b/src/core/contexts/x11context_p.h similarity index 78% rename from src/core/contexts/linuxx11context_p.h rename to src/core/contexts/x11context_p.h index 741a29f3..a73a8054 100644 --- a/src/core/contexts/linuxx11context_p.h +++ b/src/core/contexts/x11context_p.h @@ -3,8 +3,8 @@ // Copyright (C) 2025-2027 Wing-summer (wingsummer) // SPDX-License-Identifier: Apache-2.0 -#ifndef LINUXX11CONTEXT_P_H -#define LINUXX11CONTEXT_P_H +#ifndef X11CONTEXT_P_H +#define X11CONTEXT_P_H // // W A R N I N G !!! @@ -20,11 +20,11 @@ #if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) namespace QWK { - class LinuxX11Context : public QtWindowContext { + class X11Context : public QtWindowContext { Q_OBJECT public: - LinuxX11Context(); - ~LinuxX11Context() override; + X11Context(); + ~X11Context() override; QString key() const override; void virtual_hook(int id, void *data) override; @@ -32,4 +32,4 @@ namespace QWK { } #endif // QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) -#endif // LINUXX11CONTEXT_P_H +#endif // X11CONTEXT_P_H diff --git a/src/core/qwindowkit_linux.cpp b/src/core/qwindowkit_linux.cpp deleted file mode 100644 index f8e2fbf6..00000000 --- a/src/core/qwindowkit_linux.cpp +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright (C) 2021-2023 wangwenx190 (Yuhang Zhao) -// Copyright (C) 2023-present Stdware Collections (https://www.github.com/stdware) -// Copyright (C) 2025-2027 Wing-summer (wingsummer) -// SPDX-License-Identifier: Apache-2.0 - -#include "qwindowkit_linux.h" - -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) -#include -#include - -namespace QWK { - namespace Private { - - bool isX11Platform() { - static const bool isX11 = QGuiApplication::platformName().startsWith( - QStringLiteral("xcb"), Qt::CaseInsensitive); - return isX11; - } - - bool isWaylandPlatform() { - static const bool isWayland = QGuiApplication::platformName().startsWith( - QStringLiteral("wayland"), Qt::CaseInsensitive); - return isWayland; - } - - const LinuxWaylandAPI &waylandAPI() { - static LinuxWaylandAPI api; - static bool guard = true; - if (guard && isWaylandPlatform()) { - QLibrary waylib(QStringLiteral("libwayland-client.so")); - bool loaded = false; - if (waylib.load()) { - loaded = true; - } else { - waylib.setFileName(QStringLiteral("libwayland-client.so.0")); - if (waylib.load()) { - loaded = true; - } - } - - if (loaded) { - api.wl_display_flush = reinterpret_cast( - waylib.resolve("wl_display_flush")); - api.wl_proxy_marshal_flags = - reinterpret_cast( - waylib.resolve("wl_proxy_marshal_flags")); - api.wl_proxy_get_version = - reinterpret_cast( - waylib.resolve("wl_proxy_get_version")); - } - } - guard = false; - return api; - } - - const LinuxX11API &x11API() { - static LinuxX11API api; - static bool guard = true; - if (guard && isX11Platform()) { - QString libName = QStringLiteral( -#if defined(__CYGWIN__) - "libX11-6.so" -#elif defined(__OpenBSD__) || defined(__NetBSD__) - "libX11.so" -#else - "libX11.so.6" -#endif - ); - QLibrary x11lib(libName); - if (x11lib.load()) { - api.XInternAtom = - reinterpret_cast(x11lib.resolve("XInternAtom")); - api.XSendEvent = - reinterpret_cast(x11lib.resolve("XSendEvent")); - api.XFlush = reinterpret_cast(x11lib.resolve("XFlush")); - api.XUngrabPointer = reinterpret_cast( - x11lib.resolve("XUngrabPointer")); - } - } - guard = false; - return api; - } - - } -} -#endif // QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) diff --git a/src/core/qwindowkit_wayland.cpp b/src/core/qwindowkit_wayland.cpp new file mode 100644 index 00000000..4de73564 --- /dev/null +++ b/src/core/qwindowkit_wayland.cpp @@ -0,0 +1,53 @@ +// Copyright (C) 2021-2023 wangwenx190 (Yuhang Zhao) +// Copyright (C) 2023-present Stdware Collections (https://www.github.com/stdware) +// Copyright (C) 2025-2027 Wing-summer (wingsummer) +// SPDX-License-Identifier: Apache-2.0 + +#include "qwindowkit_wayland.h" + +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) +#include +#include + +namespace QWK { + namespace Private { + + bool isWaylandPlatform() { + static const bool isWayland = QGuiApplication::platformName().startsWith( + QStringLiteral("wayland"), Qt::CaseInsensitive); + return isWayland; + } + + const WaylandAPI &waylandAPI() { + static WaylandAPI api; + static bool guard = true; + if (guard && isWaylandPlatform()) { + QLibrary waylib(QStringLiteral("libwayland-client.so")); + bool loaded = false; + if (waylib.load()) { + loaded = true; + } else { + waylib.setFileName(QStringLiteral("libwayland-client.so.0")); + if (waylib.load()) { + loaded = true; + } + } + + if (loaded) { + api.wl_display_flush = reinterpret_cast( + waylib.resolve("wl_display_flush")); + api.wl_proxy_marshal_flags = + reinterpret_cast( + waylib.resolve("wl_proxy_marshal_flags")); + api.wl_proxy_get_version = + reinterpret_cast( + waylib.resolve("wl_proxy_get_version")); + } + } + guard = false; + return api; + } + + } +} +#endif // QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) diff --git a/src/core/qwindowkit_linux.h b/src/core/qwindowkit_wayland.h similarity index 55% rename from src/core/qwindowkit_linux.h rename to src/core/qwindowkit_wayland.h index 62d51d84..ec6f7a1f 100644 --- a/src/core/qwindowkit_linux.h +++ b/src/core/qwindowkit_wayland.h @@ -3,8 +3,8 @@ // Copyright (C) 2025-2027 Wing-summer (wingsummer) // SPDX-License-Identifier: Apache-2.0 -#ifndef QWINDOWKIT_LINUX_H -#define QWINDOWKIT_LINUX_H +#ifndef QWINDOWKIT_WAYLAND_H +#define QWINDOWKIT_WAYLAND_H // // W A R N I N G !!! @@ -20,42 +20,14 @@ #if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) #include -// some declarations about x11 -using Atom = unsigned long; -using Bool = int; -using XID = unsigned long; -using Window = XID; - -union _XEvent; -using XEvent = union _XEvent; - // for wayland struct wl_proxy; namespace QWK { namespace Private { - struct LinuxX11API { - LinuxX11API() = default; - Q_DISABLE_COPY(LinuxX11API) - - using XInternAtomFn = Atom (*)(Display *, const char *, Bool); - using XSendEventFn = int (*)(Display *, Window, Bool, long, XEvent *); - using XFlushFn = int (*)(Display *); - using XUngrabPointerFn = int (*)(Display *, unsigned long); - - XInternAtomFn XInternAtom = nullptr; - XSendEventFn XSendEvent = nullptr; - XFlushFn XFlush = nullptr; - XUngrabPointerFn XUngrabPointer = nullptr; - - inline bool isValid() const { - return XInternAtom && XSendEvent && XFlush && XUngrabPointer; - } - }; - - struct LinuxWaylandAPI { - LinuxWaylandAPI() = default; - Q_DISABLE_COPY(LinuxWaylandAPI) + struct WaylandAPI { + WaylandAPI() = default; + Q_DISABLE_COPY(WaylandAPI) using wl_display_flush_fn = int (*)(struct wl_display *); using wl_proxy_marshal_flags_fn = void (*)(struct wl_proxy *, uint32_t, @@ -75,12 +47,8 @@ namespace QWK { bool isWaylandPlatform(); - bool isX11Platform(); - - const LinuxX11API &x11API(); - - const LinuxWaylandAPI &waylandAPI(); + const WaylandAPI &waylandAPI(); } } #endif // QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) -#endif // QWINDOWKIT_LINUX_H +#endif // QWINDOWKIT_WAYLAND_H diff --git a/src/core/qwindowkit_x11.cpp b/src/core/qwindowkit_x11.cpp new file mode 100644 index 00000000..2edb1629 --- /dev/null +++ b/src/core/qwindowkit_x11.cpp @@ -0,0 +1,51 @@ +// Copyright (C) 2021-2023 wangwenx190 (Yuhang Zhao) +// Copyright (C) 2023-present Stdware Collections (https://www.github.com/stdware) +// Copyright (C) 2025-2027 Wing-summer (wingsummer) +// SPDX-License-Identifier: Apache-2.0 + +#include "qwindowkit_x11.h" + +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) +#include +#include + +namespace QWK { + namespace Private { + + bool isX11Platform() { + static const bool isX11 = QGuiApplication::platformName().startsWith( + QStringLiteral("xcb"), Qt::CaseInsensitive); + return isX11; + } + + const X11API &x11API() { + static X11API api; + static bool guard = true; + if (guard && isX11Platform()) { + QString libName = QStringLiteral( +#if defined(__CYGWIN__) + "libX11-6.so" +#elif defined(__OpenBSD__) || defined(__NetBSD__) + "libX11.so" +#else + "libX11.so.6" +#endif + ); + QLibrary x11lib(libName); + if (x11lib.load()) { + api.XInternAtom = + reinterpret_cast(x11lib.resolve("XInternAtom")); + api.XSendEvent = + reinterpret_cast(x11lib.resolve("XSendEvent")); + api.XFlush = reinterpret_cast(x11lib.resolve("XFlush")); + api.XUngrabPointer = reinterpret_cast( + x11lib.resolve("XUngrabPointer")); + } + } + guard = false; + return api; + } + + } +} +#endif // QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) diff --git a/src/core/qwindowkit_x11.h b/src/core/qwindowkit_x11.h new file mode 100644 index 00000000..7d073df1 --- /dev/null +++ b/src/core/qwindowkit_x11.h @@ -0,0 +1,59 @@ +// Copyright (C) 2021-2023 wangwenx190 (Yuhang Zhao) +// Copyright (C) 2023-present Stdware Collections (https://www.github.com/stdware) +// Copyright (C) 2025-2027 Wing-summer (wingsummer) +// SPDX-License-Identifier: Apache-2.0 + +#ifndef QWINDOWKIT_X11_H +#define QWINDOWKIT_X11_H + +// +// W A R N I N G !!! +// ----------------- +// +// This file is not part of the QWindowKit API. It is used purely as an +// implementation detail. This header file may change from version to +// version without notice, or may even be removed. +// + +#include + +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) +#include + +// some declarations about x11 +using Atom = unsigned long; +using Bool = int; +using XID = unsigned long; +using Window = XID; + +union _XEvent; +using XEvent = union _XEvent; + +namespace QWK { + namespace Private { + struct X11API { + X11API() = default; + Q_DISABLE_COPY(X11API) + + using XInternAtomFn = Atom (*)(Display *, const char *, Bool); + using XSendEventFn = int (*)(Display *, Window, Bool, long, XEvent *); + using XFlushFn = int (*)(Display *); + using XUngrabPointerFn = int (*)(Display *, unsigned long); + + XInternAtomFn XInternAtom = nullptr; + XSendEventFn XSendEvent = nullptr; + XFlushFn XFlush = nullptr; + XUngrabPointerFn XUngrabPointer = nullptr; + + inline bool isValid() const { + return XInternAtom && XSendEvent && XFlush && XUngrabPointer; + } + }; + + bool isX11Platform(); + + const X11API &x11API(); + } +} +#endif // QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) +#endif // QWINDOWKIT_X11_H diff --git a/src/core/windowagentbase.cpp b/src/core/windowagentbase.cpp index b4d7afba..127facba 100644 --- a/src/core/windowagentbase.cpp +++ b/src/core/windowagentbase.cpp @@ -14,9 +14,10 @@ #elif defined(Q_OS_MAC) # include "cocoawindowcontext_p.h" #elif defined(Q_OS_LINUX) && QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) -# include "qwindowkit_linux.h" -# include "linuxwaylandcontext_p.h" -# include "linuxx11context_p.h" +# include "qwindowkit_x11.h" +# include "qwindowkit_wayland.h" +# include "waylandcontext_p.h" +# include "x11context_p.h" #endif #include "qtwindowcontext_p.h" @@ -62,11 +63,11 @@ namespace QWK { # elif defined(Q_OS_LINUX) && QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) # ifdef QWK_HAS_WAYLAND_CONTEXT if (Private::isWaylandPlatform() && Private::waylandAPI().isValid()) { - return new LinuxWaylandContext(); + return new WaylandContext(); } # endif if (Private::isX11Platform() && Private::x11API().isValid()) { - return new LinuxX11Context(); + return new X11Context(); } # endif // Final fallback, no native features. From 255ca87c2ef96eeb665d63a77e2c074184f0408f Mon Sep 17 00:00:00 2001 From: wangwenx190 <2546789017@qq.com> Date: Tue, 22 Sep 2026 15:29:01 +0800 Subject: [PATCH 08/56] fix(windows): preserve caption hits for single-axis fixed windows When system borders are disabled, a normal window with only its width or height fixed returns HTCLIENT for every non-edge point in the single-axis WM_NCHITTEST branch. That early return bypasses the later title-bar check, preventing native caption dragging for both Widgets and Quick (AUDIT-006). Return HTCAPTION for a draggable title-bar point in that branch and keep HTCLIENT for other interior points. Preserve all edge/corner handling, excluded controls, system-button precedence, fullscreen/maximized paths, fully fixed windows and system-border behavior. Add a Windows native-message integration test using real WidgetWindowAgent and QuickWindowAgent fixtures in separate processes. Validate native Windows QPA, the production Win32 context, HWND ownership, size constraints and the actual system-border mode. Query WM_NCHITTEST with cross-process timeouts: - Title bar, excluded control and content for fixed width, fixed height, both fixed and unconstrained windows. - All four edges and corners for each single-axis constraint. CTest serializes desktop access. The driver closes or kills fixture children; fixtures have a shutdown deadline. Text/XML reports are regenerated on each run and the wrapper verifies the expected pass count without skipped rows. No external assets, network services or commercial test tools are needed. The new regression runs in existing Windows test builds; add a separate no-system-borders build/test step to the Windows MSVC CI job. Validation on Windows 11, MSVC, Qt 6.12.0 Release: - Before the production fix, exactly four title-bar rows failed: Widgets and Quick with fixed width or fixed height returned HTCLIENT instead of HTCAPTION. The other rows passed (10 passes, 4 failures including Qt Test initialization/cleanup). - After the fix, all 12 data rows plus initialization/cleanup passed in both system-border ON and OFF builds, with zero skips. - Built the default configuration and the no-system-borders test target. All nine registered default CTest cases passed, including existing installation, QML tooling and Quick border regressions. - Rechecked both native-message runs after finalizing report cleanup. - Reviewed result recording, size constraints, platform/version branches and unchanged edge/system-button/state handling; git diff --check passed. Coverage limits: these are native message integration tests, not unit tests or SendInput end-to-end tests. Actual mouse dragging/resizing, hosted runner execution, CI Debug/Qt 6.8.1, Qt 5, other Windows versions, physical multi-DPI setups and firewall-enforced offline execution were not tested. Local qmake and MSBuild consumer tests were not registered in this shell. Local builds and installation-consumer checks ran outside the sandbox because of the previously confirmed MSBuild FileTracker access restriction. Remove the resolved item from the locally maintained audit and record the new regression's scope there without staging that document. Preserve and exclude existing qmsetup submodule changes; do not push this commit. --- .github/workflows/ci.yml | 25 ++ src/core/contexts/win32windowcontext.cpp | 2 +- tests/CMakeLists.txt | 5 + tests/windows/hittest/CMakeLists.txt | 30 +++ tests/windows/hittest/README.md | 20 ++ tests/windows/hittest/RunTest.cmake | 16 ++ tests/windows/hittest/tst_windowhittest.cpp | 272 ++++++++++++++++++++ 7 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 tests/windows/hittest/CMakeLists.txt create mode 100644 tests/windows/hittest/README.md create mode 100644 tests/windows/hittest/RunTest.cmake create mode 100644 tests/windows/hittest/tst_windowhittest.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9391855..f83786e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,6 +121,31 @@ jobs: - name: Test run: ctest --test-dir build --output-on-failure --no-tests=error + # Single-axis caption hit testing has a separate compile-time path without + # system borders. Exercise it with real Widgets and Quick HWNDs as well. + - name: Configure without system borders + if: matrix.platform == 'windows-msvc' + shell: bash + env: + CC: ${{ matrix.cc }} + CXX: ${{ matrix.cxx }} + run: | + cmake -G Ninja -S . -B build-no-system-borders \ + -DCMAKE_BUILD_TYPE=Debug \ + -DQWINDOWKIT_BUILD_WIDGETS=ON \ + -DQWINDOWKIT_BUILD_QUICK=ON \ + -DQWINDOWKIT_BUILD_TESTS=ON \ + -DQWINDOWKIT_INSTALL=OFF \ + -DQWINDOWKIT_ENABLE_WINDOWS_SYSTEM_BORDERS=OFF + + - name: Build native hit-test regression without system borders + if: matrix.platform == 'windows-msvc' + run: cmake --build build-no-system-borders --target tst_windowhittest + + - name: Test native hit testing without system borders + if: matrix.platform == 'windows-msvc' + run: ctest --test-dir build-no-system-borders -R "^windows.hittest$" --output-on-failure --no-tests=error + # MinGW is a job of its own because it wants msys2 rather than the toolchain the matrix above # sets up with an environment variable, and because Qt comes from the same place as the compiler # here. What aqt ships for Windows is built by MSVC or by a MinGW of its own choosing, and a Qt diff --git a/src/core/contexts/win32windowcontext.cpp b/src/core/contexts/win32windowcontext.cpp index 2ccdc3b5..c3e255ce 100644 --- a/src/core/contexts/win32windowcontext.cpp +++ b/src/core/contexts/win32windowcontext.cpp @@ -2021,7 +2021,7 @@ namespace QWK { *result = isInTopBorder ? HTTOP : HTBOTTOM; } } else { - *result = HTCLIENT; + *result = isInTitleBar ? HTCAPTION : HTCLIENT; } return true; } else { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 303d23c6..57d52f8a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,5 +1,10 @@ add_subdirectory(buildsystems) +if(WIN32 AND NOT QWINDOWKIT_FORCE_QT_WINDOW_CONTEXT + AND (QWINDOWKIT_BUILD_WIDGETS OR QWINDOWKIT_BUILD_QUICK)) + add_subdirectory(windows/hittest) +endif() + if(WIN32 AND QWINDOWKIT_BUILD_QUICK AND QWINDOWKIT_INSTALL) add_subdirectory(qmltypes) endif() diff --git a/tests/windows/hittest/CMakeLists.txt b/tests/windows/hittest/CMakeLists.txt new file mode 100644 index 00000000..4cb7143d --- /dev/null +++ b/tests/windows/hittest/CMakeLists.txt @@ -0,0 +1,30 @@ +qm_find_qt(Core Gui Test) + +add_executable(tst_windowhittest tst_windowhittest.cpp) +set_target_properties(tst_windowhittest PROPERTIES AUTOMOC ON) +target_compile_features(tst_windowhittest PRIVATE cxx_std_17) +target_link_libraries(tst_windowhittest PRIVATE QWKCore Qt${QT_VERSION_MAJOR}::Test user32) +set(_expected_passes 2) # Qt Test initialization and cleanup. + +if(QWINDOWKIT_BUILD_WIDGETS) + target_link_libraries(tst_windowhittest PRIVATE QWKWidgets) + target_compile_definitions(tst_windowhittest PRIVATE TEST_WIDGETS) + math(EXPR _expected_passes "${_expected_passes} + 6") +endif() +if(QWINDOWKIT_BUILD_QUICK) + target_link_libraries(tst_windowhittest PRIVATE QWKQuick) + target_compile_definitions(tst_windowhittest PRIVATE TEST_QUICK) + math(EXPR _expected_passes "${_expected_passes} + 6") +endif() + +add_test(NAME windows.hittest + COMMAND ${CMAKE_COMMAND} -E env + "PATH=$;$;$ENV{PATH}" + "QT_QPA_PLATFORM=windows" "QT_QUICK_BACKEND=software" "QT_SCALE_FACTOR=1" + ${CMAKE_COMMAND} + "-DTEST_EXE=$" + "-DRESULT_FILE=${CMAKE_CURRENT_BINARY_DIR}/hittest-$" + "-DEXPECTED_PASSES=${_expected_passes}" + -P "${CMAKE_CURRENT_SOURCE_DIR}/RunTest.cmake") +set_tests_properties(windows.hittest PROPERTIES + TIMEOUT 120 LABELS "windows;integration" RESOURCE_LOCK desktop) diff --git a/tests/windows/hittest/README.md b/tests/windows/hittest/README.md new file mode 100644 index 00000000..20df017b --- /dev/null +++ b/tests/windows/hittest/README.md @@ -0,0 +1,20 @@ +# Native hit-test regression + +Build with `QWINDOWKIT_BUILD_TESTS=ON` and Widgets and/or Quick enabled, then run +`ctest --test-dir -C Release -R windows.hittest --output-on-failure`. +Repeat in a separate build with `QWINDOWKIT_ENABLE_WINDOWS_SYSTEM_BORDERS=OFF`. +The test requires native Windows QPA and the production Win32 context; it is not +registered when `QWINDOWKIT_FORCE_QT_WINDOW_CONTEXT=ON`. + +Each data row launches a fresh fixture process using the production window agent. +The driver queries the real HWND with time-limited cross-process `WM_NCHITTEST` +messages. It checks title bars, excluded controls and client content for fixed +width, fixed height, both fixed and unconstrained windows. Four edges and four +corners are also checked for each single-axis constraint. With both modules enabled +there are 12 data rows (plus Qt Test initialization and cleanup). + +These are native message integration tests. They do not inject mouse input or +prove actual dragging/resizing, compositor appearance, or GitHub-hosted desktop +availability. Fixtures communicate over stdout and need no network or external +assets. CTest serializes desktop access; fixtures have a shutdown deadline and +the driver closes or kills each child on failure. diff --git a/tests/windows/hittest/RunTest.cmake b/tests/windows/hittest/RunTest.cmake new file mode 100644 index 00000000..d46fe5b3 --- /dev/null +++ b/tests/windows/hittest/RunTest.cmake @@ -0,0 +1,16 @@ +file(REMOVE "${RESULT_FILE}.txt" "${RESULT_FILE}.xml") +execute_process(COMMAND "${TEST_EXE}" -o "${RESULT_FILE}.txt,txt" -o "${RESULT_FILE}.xml,xml" + RESULT_VARIABLE _result TIMEOUT 100) +if(EXISTS "${RESULT_FILE}.txt") + file(READ "${RESULT_FILE}.txt" _report) + message("${_report}") +endif() +if(NOT "${_result}" STREQUAL "0") + message(FATAL_ERROR "Native hit-test regression failed: ${_result}") +endif() +file(READ "${RESULT_FILE}.xml" _xml) +string(REGEX MATCHALL " + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#ifdef TEST_WIDGETS +# include +# include +#endif +#ifdef TEST_QUICK +# include +#endif + +namespace { + // Observe the real factory result without replacing the production context. + template class InspectableAgent : public Agent { + public: + QString contextKey() const { return this->d_ptr->context->key(); } + }; + + void reportReady(QWindow *window, const QString &context, bool borders) { + const QJsonObject info{ + {QStringLiteral("hwnd"), QString::number(quintptr(window->winId()))}, + {QStringLiteral("context"), context}, + {QStringLiteral("borders"), borders}, + {QStringLiteral("dpr"), window->devicePixelRatio()}, + {QStringLiteral("fixedWidth"), window->minimumWidth() == window->maximumWidth()}, + {QStringLiteral("fixedHeight"), window->minimumHeight() == window->maximumHeight()}}; + const auto json = QJsonDocument(info).toJson(QJsonDocument::Compact); + std::printf("%s\n", json.constData()); + std::fflush(stdout); + } + + int runFixture(const QString &kind, const QString &constraint) { + const bool fixedWidth = constraint == QStringLiteral("width") || constraint == QStringLiteral("both"); + const bool fixedHeight = constraint == QStringLiteral("height") || constraint == QStringLiteral("both"); + // Also clean up if the test driver is interrupted before its normal cleanup. + QTimer::singleShot(20000, qApp, [] { QCoreApplication::exit(3); }); +#ifdef TEST_WIDGETS + if (kind == QStringLiteral("widgets")) { + QWidget window; + window.setGeometry(200, 200, 640, 400); + if (fixedWidth) + window.setFixedWidth(640); + if (fixedHeight) + window.setFixedHeight(400); + QWidget title(&window); + title.setGeometry(40, 20, 500, 60); + QWidget excluded(&title); + excluded.setGeometry(160, 10, 80, 40); + InspectableAgent agent; + if (!agent.setup(&window)) + return 2; + agent.setTitleBar(&title); + agent.setHitTestVisible(&excluded); + window.show(); + ::ShowWindow(reinterpret_cast(window.winId()), SW_SHOWNOACTIVATE); + if (!QTest::qWaitForWindowExposed(window.windowHandle())) + return 2; + reportReady(window.windowHandle(), agent.contextKey(), + agent.windowAttribute(QStringLiteral("windows-system-border-enabled")).toBool()); + return QCoreApplication::exec(); + } +#endif +#ifdef TEST_QUICK + if (kind == QStringLiteral("quick")) { + QQuickWindow window; + window.setGeometry(200, 200, 640, 400); + if (fixedWidth) { + window.setMinimumWidth(640); + window.setMaximumWidth(640); + } + if (fixedHeight) { + window.setMinimumHeight(400); + window.setMaximumHeight(400); + } + QQuickItem title(window.contentItem()); + title.setPosition(QPointF(40, 20)); + title.setSize(QSizeF(500, 60)); + QQuickItem excluded(&title); + excluded.setPosition(QPointF(160, 10)); + excluded.setSize(QSizeF(80, 40)); + InspectableAgent agent; + if (!agent.setup(&window)) + return 2; + agent.setTitleBar(&title); + agent.setHitTestVisible(&excluded); + window.show(); + ::ShowWindow(reinterpret_cast(window.winId()), SW_SHOWNOACTIVATE); + if (!QTest::qWaitForWindowExposed(&window)) + return 2; + reportReady(&window, agent.contextKey(), + agent.windowAttribute(QStringLiteral("windows-system-border-enabled")).toBool()); + return QCoreApplication::exec(); + } +#endif + return 2; + } +} + +class WindowHitTest : public QObject { + Q_OBJECT + +private: + QProcess fixture; + HWND hwnd = nullptr; + qreal dpr = 1; + bool borders = false; + + void addRows(bool onlySingleAxis) { + QTest::addColumn("kind"); + QTest::addColumn("constraint"); + QStringList kinds; +#ifdef TEST_WIDGETS + kinds << QStringLiteral("widgets"); +#endif +#ifdef TEST_QUICK + kinds << QStringLiteral("quick"); +#endif + QStringList constraints{QStringLiteral("width"), QStringLiteral("height")}; + if (!onlySingleAxis) + constraints << QStringLiteral("both") << QStringLiteral("none"); + for (const auto &kind : kinds) { + for (const auto &constraint : constraints) { + const auto name = (kind + QLatin1Char('-') + constraint).toLatin1(); + QTest::newRow(name.constData()) << kind << constraint; + } + } + } + + void startFixture(const QString &kind, const QString &constraint) { + fixture.start(QCoreApplication::applicationFilePath(), + {QStringLiteral("--fixture"), kind, constraint}); + QVERIFY2(fixture.waitForStarted(5000), qPrintable(fixture.errorString())); + QTRY_VERIFY_WITH_TIMEOUT(fixture.canReadLine(), 7000); + const auto info = QJsonDocument::fromJson(fixture.readLine()).object(); + QCOMPARE(info.value(QStringLiteral("context")).toString(), QStringLiteral("win32")); + QCOMPARE(info.value(QStringLiteral("fixedWidth")).toBool(), + constraint == QStringLiteral("width") || constraint == QStringLiteral("both")); + QCOMPARE(info.value(QStringLiteral("fixedHeight")).toBool(), + constraint == QStringLiteral("height") || constraint == QStringLiteral("both")); + borders = info.value(QStringLiteral("borders")).toBool(); + QCOMPARE(borders, bool(QWINDOWKIT_CONFIG(ENABLE_WINDOWS_SYSTEM_BORDERS))); + dpr = info.value(QStringLiteral("dpr")).toDouble(); + QVERIFY(dpr > 0); + hwnd = reinterpret_cast(info.value(QStringLiteral("hwnd")).toString().toULongLong()); + QVERIFY(::IsWindow(hwnd)); + QVERIFY(::IsWindowVisible(hwnd)); + DWORD pid = 0; + QVERIFY(::GetWindowThreadProcessId(hwnd, &pid)); + QCOMPARE(quint64(pid), quint64(fixture.processId())); + } + + void checkHit(POINT screenPoint, LRESULT expected, const char *location) { + QVERIFY2(screenPoint.x >= -32768 && screenPoint.x <= 32767 && + screenPoint.y >= -32768 && screenPoint.y <= 32767, + "Screen coordinates must fit WM_NCHITTEST's signed 16-bit fields"); + DWORD_PTR result = 0; + QVERIFY2(::SendMessageTimeoutW(hwnd, WM_NCHITTEST, 0, + MAKELPARAM(screenPoint.x, screenPoint.y), + SMTO_ABORTIFHUNG | SMTO_BLOCK | SMTO_ERRORONEXIT, 1000, &result), + "WM_NCHITTEST failed or timed out"); + const auto message = QStringLiteral("%1: expected %2, got %3") + .arg(QString::fromLatin1(location)).arg(expected).arg(LRESULT(result)); + QVERIFY2(LRESULT(result) == expected, qPrintable(message)); + } + + void checkClientPoint(int x, int y, LRESULT expected, const char *location) { + POINT point{qRound(x * dpr), qRound(y * dpr)}; + QVERIFY(::ClientToScreen(hwnd, &point)); + checkHit(point, expected, location); + } + +private Q_SLOTS: + void initTestCase() { + QCOMPARE(QGuiApplication::platformName(), QStringLiteral("windows")); + qInfo() << "Qt" << qVersion() << "system borders" + << bool(QWINDOWKIT_CONFIG(ENABLE_WINDOWS_SYSTEM_BORDERS)); + } + + void cleanup() { + bool closedNormally = true; + bool stopped = true; + if (fixture.state() != QProcess::NotRunning) { + if (hwnd) + ::PostMessageW(hwnd, WM_CLOSE, 0, 0); + if (!fixture.waitForFinished(3000)) { + closedNormally = false; + fixture.kill(); + stopped = fixture.waitForFinished(3000); + } + } + hwnd = nullptr; + const auto errors = fixture.readAllStandardError(); + if (!errors.isEmpty()) + qInfo().noquote() << errors; + QVERIFY(stopped); + QVERIFY2(closedNormally, "Fixture did not close normally"); + QCOMPARE(fixture.exitStatus(), QProcess::NormalExit); + QCOMPARE(fixture.exitCode(), 0); + } + + void titleBarAndClient_data() { addRows(false); } + void titleBarAndClient() { + QFETCH(QString, kind); + QFETCH(QString, constraint); + startFixture(kind, constraint); + if (QTest::currentTestFailed()) + return; + // These points are defined by the fixture layout, not production hit-test helpers. + checkClientPoint(100, 50, HTCAPTION, "title bar"); + checkClientPoint(230, 50, HTCLIENT, "excluded control"); + checkClientPoint(300, 200, HTCLIENT, "content"); + } + + void resizeEdges_data() { addRows(true); } + void resizeEdges() { + QFETCH(QString, kind); + QFETCH(QString, constraint); + startFixture(kind, constraint); + if (QTest::currentTestFailed()) + return; + RECT frame{}; + RECT client{}; + POINT origin{0, 0}; + QVERIFY(::GetWindowRect(hwnd, &frame)); + QVERIFY(::GetClientRect(hwnd, &client)); + QVERIFY(::ClientToScreen(hwnd, &origin)); + const LONG left = frame.left + 1; + const LONG right = frame.right - 1; + const LONG top = origin.y + 1; + const LONG bottom = frame.bottom - 1; + const LONG midX = origin.x + client.right / 2; + const LONG midY = origin.y + client.bottom / 2; + const bool fixedWidth = constraint == QStringLiteral("width"); + const auto fixedBorder = borders ? HTBORDER : HTCLIENT; + checkHit({left, midY}, fixedWidth ? fixedBorder : HTLEFT, "left"); + checkHit({right, midY}, fixedWidth ? fixedBorder : HTRIGHT, "right"); + checkHit({midX, top}, fixedWidth ? HTTOP : HTCLIENT, "top"); + checkHit({midX, bottom}, fixedWidth ? HTBOTTOM : fixedBorder, "bottom"); + checkHit({left, top}, fixedWidth ? HTTOP : HTLEFT, "top left"); + checkHit({right, top}, fixedWidth ? HTTOP : HTRIGHT, "top right"); + checkHit({left, bottom}, fixedWidth ? HTBOTTOM : HTLEFT, "bottom left"); + checkHit({right, bottom}, fixedWidth ? HTBOTTOM : HTRIGHT, "bottom right"); + } +}; + +int main(int argc, char **argv) { +#ifdef TEST_WIDGETS + QApplication app(argc, argv); +#else + QGuiApplication app(argc, argv); +#endif + const auto args = app.arguments(); + if (args.size() == 4 && args.at(1) == QStringLiteral("--fixture")) + return runFixture(args.at(2), args.at(3)); + WindowHitTest test; + return QTest::qExec(&test, argc, argv); +} + +#include "tst_windowhittest.moc" From 3b67afc69a7ecd693de0b0ea20f8bf8dae7bd118 Mon Sep 17 00:00:00 2001 From: wangwenx190 <2546789017@qq.com> Date: Tue, 22 Sep 2026 15:42:02 +0800 Subject: [PATCH 09/56] fix(quick): hit test transformed items in local coordinates QuickItemDelegate previously mapped only the item's origin to scene space and kept its untransformed size. Scaling therefore lost visible hit areas or retained invisible ones, and rotation disagreed with title-bar, system button and hit-test-visible control geometry (AUDIT-007). Add a private containsScenePoint delegate query and use it for all three shared hit-test paths. Its default implementation preserves the existing rectangle-based behavior for Widgets and other delegates. Quick inverts the complete item-to-scene transform, including ancestor transforms, then checks the point against the local item dimensions with exclusive right and bottom edges. Reject non-invertible transforms instead of accepting QTransform's identity fallback for collapsed items. Keep rectangular item semantics without invoking custom containment masks or adding callbacks. Map the full Quick item rectangle for geometry queries and use outward integer alignment so fractional transformed geometry is not truncated. Retain this bounding rectangle for the existing title/window intersection check and Windows system-menu geometry; do not use the rotated bounding box as the precise hit area. Visibility, enabled-state and exclusion order remain unchanged. No public window-agent API changes are introduced. Add 12 delegate unit cases for mapped bounds, item/ancestor scale and rotation, mirroring, empty rotated bounding-box corners, fractional edges, singular transforms and live transform changes. These use QQuickItems without windows under offscreen QPA and are reported as unit tests. Extend the Windows cross-process native-message regression with a bundled QML fixture and 30 transform scenarios: title bar, excluded control and system button, transformed directly or through an ancestor, with scale-up, scale-down, rotation, nonuniform transform-list scaling and zero scale. Use independently calculated interior/exterior points, including empty corners inside rotated bounding boxes. Keep these reported as QML/native message integration, not mouse-input or rendering tests. The existing Windows CI builds automatically include the new coverage; the no-system- borders CI target includes all native transform rows. Validation on Windows 11, MSVC, Qt 6.12.0 Release: - A pre-fix run failed all 30 new transform rows while the existing rows passed. Rotation samples were moved strictly inside the shape during test review to avoid exact-edge floating-point rounding ambiguity. - Final default build and all 10 registered CTest cases passed. - Final system-border OFF build passed quickgeometry.unit (14 passes including initialization/cleanup) and windows.hittest (44 passes, including 42 data rows and initialization/cleanup). The same counts passed with system borders ON, with no skips in either configuration. - Existing Widgets, single-axis sizing, installation, QML tooling and Quick border regressions passed in the default configuration. - Reviewed all mapping/hit-test callers, item lifetime assumptions, singular and fractional behavior, and Qt 5.12/6.8 itemTransform implementations; git diff --check passed. Not covered: actual mouse input, visual pixel alignment, physical multi-DPI setups, custom 3D projective scenes, non-Windows platforms, Qt 5 runtime or hosted GitHub Actions execution. Local qmake/MSBuild consumer tests were not registered in this shell. Build/consumer verification used the already established sandbox exception for MSBuild FileTracker. Update the local audit separately and remove the resolved entry, leaving AUDIT-008 and AUDIT-009. Do not stage that report or the existing qmsetup submodule changes, and do not push. --- src/core/contexts/abstractwindowcontext.cpp | 6 +- src/core/windowitemdelegate.cpp | 4 + src/core/windowitemdelegate_p.h | 2 + src/quick/quickitemdelegate.cpp | 21 +++- src/quick/quickitemdelegate_p.h | 1 + tests/CMakeLists.txt | 4 + tests/quickgeometry/CMakeLists.txt | 17 +++ tests/quickgeometry/tst_quickgeometry.cpp | 112 ++++++++++++++++++++ tests/windows/hittest/CMakeLists.txt | 4 +- tests/windows/hittest/README.md | 15 ++- tests/windows/hittest/RunTest.cmake | 2 +- tests/windows/hittest/TransformFixture.qml | 38 +++++++ tests/windows/hittest/hittest.qrc | 5 + tests/windows/hittest/tst_windowhittest.cpp | 83 +++++++++++++++ 14 files changed, 303 insertions(+), 11 deletions(-) create mode 100644 tests/quickgeometry/CMakeLists.txt create mode 100644 tests/quickgeometry/tst_quickgeometry.cpp create mode 100644 tests/windows/hittest/TransformFixture.qml create mode 100644 tests/windows/hittest/hittest.qrc diff --git a/src/core/contexts/abstractwindowcontext.cpp b/src/core/contexts/abstractwindowcontext.cpp index ded9614b..90ac8bce 100644 --- a/src/core/contexts/abstractwindowcontext.cpp +++ b/src/core/contexts/abstractwindowcontext.cpp @@ -91,7 +91,7 @@ namespace QWK { if (!currentButton || !m_delegate->isVisible(currentButton) || !m_delegate->isEnabled(currentButton)) { continue; } - if (m_delegate->mapGeometryToScene(currentButton).contains(pos)) { + if (m_delegate->containsScenePoint(currentButton, pos)) { *button = static_cast(i); return true; } @@ -117,7 +117,7 @@ namespace QWK { return false; } - if (!titleBarRect.contains(pos)) { + if (!m_delegate->containsScenePoint(m_titleBar, pos)) { return false; } @@ -128,7 +128,7 @@ namespace QWK { for (auto &&item : std::as_const(m_hitTestVisibleItems)) { if (item && m_delegate->isVisible(item) && - m_delegate->mapGeometryToScene(item).contains(pos)) { + m_delegate->containsScenePoint(item, pos)) { return false; } } diff --git a/src/core/windowitemdelegate.cpp b/src/core/windowitemdelegate.cpp index 5017db36..43229f75 100644 --- a/src/core/windowitemdelegate.cpp +++ b/src/core/windowitemdelegate.cpp @@ -10,6 +10,10 @@ namespace QWK { WindowItemDelegate::~WindowItemDelegate() = default; + bool WindowItemDelegate::containsScenePoint(const QObject *obj, const QPoint &pos) const { + return mapGeometryToScene(obj).contains(pos); + } + void WindowItemDelegate::resetQtGrabbedControl(QObject *host) const { Q_UNUSED(host); } diff --git a/src/core/windowitemdelegate_p.h b/src/core/windowitemdelegate_p.h index a79cf604..27d0f9c9 100644 --- a/src/core/windowitemdelegate_p.h +++ b/src/core/windowitemdelegate_p.h @@ -33,7 +33,9 @@ namespace QWK { virtual QWindow *window(const QObject *obj) const = 0; virtual bool isEnabled(const QObject *obj) const = 0; virtual bool isVisible(const QObject *obj) const = 0; + // Bounding rectangle for geometry queries; hit testing can be more precise. virtual QRect mapGeometryToScene(const QObject *obj) const = 0; + virtual bool containsScenePoint(const QObject *obj, const QPoint &pos) const; // Host property query virtual QWindow *hostWindow(const QObject *host) const = 0; diff --git a/src/quick/quickitemdelegate.cpp b/src/quick/quickitemdelegate.cpp index baf8244a..5482761d 100644 --- a/src/quick/quickitemdelegate.cpp +++ b/src/quick/quickitemdelegate.cpp @@ -27,9 +27,22 @@ namespace QWK { QRect QuickItemDelegate::mapGeometryToScene(const QObject *obj) const { auto item = static_cast(obj); - const QPointF originPoint = item->mapToScene(QPointF(0.0, 0.0)); - const QSizeF size = item->size(); - return QRectF(originPoint, size).toRect(); + return item->mapRectToScene(QRectF(QPointF(), item->size())).toAlignedRect(); + } + + bool QuickItemDelegate::containsScenePoint(const QObject *obj, const QPoint &pos) const { + auto item = static_cast(obj); + // Invert the full item-to-scene transform, including ancestors. An axis-aligned + // scene bounding box includes empty corners when the item is rotated. + bool invertible = false; + const auto sceneToItem = item->itemTransform(nullptr, nullptr).inverted(&invertible); + if (!invertible) { + // A collapsed item has no hit area; QTransform otherwise returns identity. + return false; + } + const auto local = sceneToItem.map(QPointF(pos)); + return local.x() >= 0 && local.x() < item->width() && + local.y() >= 0 && local.y() < item->height(); } QWindow *QuickItemDelegate::hostWindow(const QObject *host) const { @@ -80,4 +93,4 @@ namespace QWK { static_cast(host)->raise(); } -} \ No newline at end of file +} diff --git a/src/quick/quickitemdelegate_p.h b/src/quick/quickitemdelegate_p.h index 418aa250..2ccb652e 100644 --- a/src/quick/quickitemdelegate_p.h +++ b/src/quick/quickitemdelegate_p.h @@ -32,6 +32,7 @@ namespace QWK { bool isEnabled(const QObject *obj) const override; bool isVisible(const QObject *obj) const override; QRect mapGeometryToScene(const QObject *obj) const override; + bool containsScenePoint(const QObject *obj, const QPoint &pos) const override; QWindow *hostWindow(const QObject *host) const override; bool isWindowActive(const QObject *host) const override; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 57d52f8a..49505e46 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,5 +1,9 @@ add_subdirectory(buildsystems) +if(WIN32 AND QWINDOWKIT_BUILD_QUICK) + add_subdirectory(quickgeometry) +endif() + if(WIN32 AND NOT QWINDOWKIT_FORCE_QT_WINDOW_CONTEXT AND (QWINDOWKIT_BUILD_WIDGETS OR QWINDOWKIT_BUILD_QUICK)) add_subdirectory(windows/hittest) diff --git a/tests/quickgeometry/CMakeLists.txt b/tests/quickgeometry/CMakeLists.txt new file mode 100644 index 00000000..312447a3 --- /dev/null +++ b/tests/quickgeometry/CMakeLists.txt @@ -0,0 +1,17 @@ +qm_find_qt(Core Gui Quick Test) + +add_executable(tst_quickgeometry tst_quickgeometry.cpp) +set_target_properties(tst_quickgeometry PROPERTIES AUTOMOC ON) +target_compile_features(tst_quickgeometry PRIVATE cxx_std_17) +target_link_libraries(tst_quickgeometry PRIVATE QWKQuick Qt${QT_VERSION_MAJOR}::Test) + +add_test(NAME quickgeometry.unit + COMMAND ${CMAKE_COMMAND} -E env + "PATH=$;$;$ENV{PATH}" + "QT_QPA_PLATFORM=offscreen" + ${CMAKE_COMMAND} + "-DTEST_EXE=$" + "-DRESULT_FILE=${CMAKE_CURRENT_BINARY_DIR}/geometry-$" + "-DEXPECTED_PASSES=14" + -P "${CMAKE_CURRENT_SOURCE_DIR}/../windows/hittest/RunTest.cmake") +set_tests_properties(quickgeometry.unit PROPERTIES TIMEOUT 30 LABELS "quick;unit") diff --git a/tests/quickgeometry/tst_quickgeometry.cpp b/tests/quickgeometry/tst_quickgeometry.cpp new file mode 100644 index 00000000..537a89a5 --- /dev/null +++ b/tests/quickgeometry/tst_quickgeometry.cpp @@ -0,0 +1,112 @@ +// Copyright (C) 2023-present Stdware Collections (https://www.github.com/stdware) +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include + +class QuickGeometryTest : public QObject { + Q_OBJECT + +private: + QWK::QuickItemDelegate delegate; + +private Q_SLOTS: + void mappedGeometry_data() { + QTest::addColumn("ancestor"); + QTest::addColumn("scale"); + QTest::addColumn("rotation"); + QTest::addColumn("expected"); + for (bool ancestor : {false, true}) { + const QByteArray prefix = ancestor ? "ancestor-" : "item-"; + QTest::newRow(prefix + "scale") << ancestor << qreal(2) << qreal(0) << QRect(100, 100, 200, 80); + QTest::newRow(prefix + "rotation") << ancestor << qreal(1) << qreal(90) << QRect(60, 100, 40, 100); + QTest::newRow(prefix + "shrink") << ancestor << qreal(0.5) << qreal(0) << QRect(100, 100, 50, 20); + QTest::newRow(prefix + "mirror") << ancestor << qreal(-1) << qreal(0) << QRect(0, 60, 100, 40); + } + } + + void mappedGeometry() { + QFETCH(bool, ancestor); + QFETCH(qreal, scale); + QFETCH(qreal, rotation); + QFETCH(QRect, expected); + QQuickItem parent; + parent.setPosition(QPointF(100, 100)); + parent.setTransformOrigin(QQuickItem::TopLeft); + QQuickItem item(&parent); + item.setSize(QSizeF(100, 40)); + item.setTransformOrigin(QQuickItem::TopLeft); + auto *transformed = ancestor ? &parent : &item; + transformed->setScale(scale); + transformed->setRotation(rotation); + QCOMPARE(delegate.mapGeometryToScene(&item), expected); + } + + void rotationRejectsBoundingCorners() { + QQuickItem item; + item.setPosition(QPointF(100, 100)); + item.setSize(QSizeF(100, 40)); + item.setTransformOrigin(QQuickItem::TopLeft); + item.setRotation(45); + QVERIFY(delegate.containsScenePoint(&item, QPoint(150, 160))); + // This is inside the bounding box but outside the rotated rectangle. + QVERIFY(delegate.mapGeometryToScene(&item).contains(QPoint(105, 180))); + QVERIFY(!delegate.containsScenePoint(&item, QPoint(105, 180))); + } + + void fractionalBounds() { + QQuickItem item; + item.setPosition(QPointF(0.75, 0.75)); + item.setSize(QSizeF(0.5, 0.5)); + QCOMPARE(delegate.mapGeometryToScene(&item), QRect(0, 0, 2, 2)); + QVERIFY(delegate.containsScenePoint(&item, QPoint(1, 1))); + QVERIFY(!delegate.containsScenePoint(&item, QPoint(0, 0))); + item.setPosition(QPointF(0, 0)); + item.setSize(QSizeF(100, 40)); + QVERIFY(!delegate.containsScenePoint(&item, QPoint(100, 20))); + QVERIFY(!delegate.containsScenePoint(&item, QPoint(50, 40))); + } + + void singularTransforms() { + QQuickItem parent; + QQuickItem item(&parent); + item.setSize(QSizeF(100, 40)); + item.setTransformOrigin(QQuickItem::TopLeft); + item.setScale(0); + QVERIFY(!delegate.containsScenePoint(&item, QPoint(10, 10))); + item.setScale(1); + parent.setScale(0); + QVERIFY(!delegate.containsScenePoint(&item, QPoint(10, 10))); + parent.setScale(1); + item.setSize(QSizeF(0, 40)); + QVERIFY(!delegate.containsScenePoint(&item, QPoint(0, 10))); + } + + void dynamicChanges() { + QQuickItem parent; + parent.setPosition(QPointF(100, 100)); + parent.setTransformOrigin(QQuickItem::TopLeft); + QQuickItem item(&parent); + item.setSize(QSizeF(100, 40)); + QVERIFY(!delegate.containsScenePoint(&item, QPoint(250, 140))); + parent.setScale(2); + QVERIFY(delegate.containsScenePoint(&item, QPoint(250, 140))); + parent.setRotation(90); + QVERIFY(!delegate.containsScenePoint(&item, QPoint(250, 140))); + QVERIFY(delegate.containsScenePoint(&item, QPoint(60, 250))); + parent.setScale(1); + parent.setRotation(0); + item.setScale(-1); // Default center origin: mirror inside the original footprint. + QVERIFY(delegate.containsScenePoint(&item, QPoint(150, 120))); + } +}; + +int main(int argc, char **argv) { + QGuiApplication app(argc, argv); + QuickGeometryTest test; + return QTest::qExec(&test, argc, argv); +} + +#include "tst_quickgeometry.moc" diff --git a/tests/windows/hittest/CMakeLists.txt b/tests/windows/hittest/CMakeLists.txt index 4cb7143d..5ade4c9c 100644 --- a/tests/windows/hittest/CMakeLists.txt +++ b/tests/windows/hittest/CMakeLists.txt @@ -12,9 +12,11 @@ if(QWINDOWKIT_BUILD_WIDGETS) math(EXPR _expected_passes "${_expected_passes} + 6") endif() if(QWINDOWKIT_BUILD_QUICK) + set_target_properties(tst_windowhittest PROPERTIES AUTORCC ON) + target_sources(tst_windowhittest PRIVATE hittest.qrc) target_link_libraries(tst_windowhittest PRIVATE QWKQuick) target_compile_definitions(tst_windowhittest PRIVATE TEST_QUICK) - math(EXPR _expected_passes "${_expected_passes} + 6") + math(EXPR _expected_passes "${_expected_passes} + 36") # 6 existing + 30 QML transform rows. endif() add_test(NAME windows.hittest diff --git a/tests/windows/hittest/README.md b/tests/windows/hittest/README.md index 20df017b..6bf77a0c 100644 --- a/tests/windows/hittest/README.md +++ b/tests/windows/hittest/README.md @@ -10,8 +10,19 @@ Each data row launches a fresh fixture process using the production window agent The driver queries the real HWND with time-limited cross-process `WM_NCHITTEST` messages. It checks title bars, excluded controls and client content for fixed width, fixed height, both fixed and unconstrained windows. Four edges and four -corners are also checked for each single-axis constraint. With both modules enabled -there are 12 data rows (plus Qt Test initialization and cleanup). +corners are also checked for each single-axis constraint. + +Quick adds 30 QML transform rows: title bar, excluded control and system button, +each transformed directly or through an ancestor, with enlargement, shrinkage, +rotation, nonuniform `transform` scaling and zero scale. Expected points are +calculated independently of the production mapping, including an empty corner +inside a rotated bounding box. With both modules enabled there are 42 data rows +(plus Qt Test initialization and cleanup). + +`quickgeometry.unit` separately checks the production Quick delegate using items +without windows and the offscreen QPA. Its 12 cases cover complete mapped bounds, +mirroring, precise containment, fractional edges, singular transforms and dynamic +changes. It is a unit test, not evidence of native input or rendering. These are native message integration tests. They do not inject mouse input or prove actual dragging/resizing, compositor appearance, or GitHub-hosted desktop diff --git a/tests/windows/hittest/RunTest.cmake b/tests/windows/hittest/RunTest.cmake index d46fe5b3..86fde0bf 100644 --- a/tests/windows/hittest/RunTest.cmake +++ b/tests/windows/hittest/RunTest.cmake @@ -6,7 +6,7 @@ if(EXISTS "${RESULT_FILE}.txt") message("${_report}") endif() if(NOT "${_result}" STREQUAL "0") - message(FATAL_ERROR "Native hit-test regression failed: ${_result}") + message(FATAL_ERROR "Hit-test regression failed: ${_result}") endif() file(READ "${RESULT_FILE}.xml" _xml) string(REGEX MATCHALL " + + TransformFixture.qml + + diff --git a/tests/windows/hittest/tst_windowhittest.cpp b/tests/windows/hittest/tst_windowhittest.cpp index 57240368..51933be0 100644 --- a/tests/windows/hittest/tst_windowhittest.cpp +++ b/tests/windows/hittest/tst_windowhittest.cpp @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 #include +#include #include #include @@ -19,6 +20,8 @@ # include #endif #ifdef TEST_QUICK +# include +# include # include #endif @@ -74,6 +77,41 @@ namespace { } #endif #ifdef TEST_QUICK + if (kind == QStringLiteral("quick-transform")) { + const auto parts = constraint.split(QLatin1Char('-')); + if (parts.size() != 3) + return 2; + QQuickWindow window; + window.setGeometry(200, 200, 640, 400); + QQmlEngine engine; + QQmlComponent component(&engine, QUrl(QStringLiteral("qrc:/hittest/TransformFixture.qml"))); + std::unique_ptr object(component.create()); + auto *root = qobject_cast(object.get()); + if (!root) { + qWarning() << component.errors(); + return 2; + } + root->setParentItem(window.contentItem()); + root->setProperty("transformAncestor", parts.at(1) == QStringLiteral("ancestor")); + root->setProperty("mode", parts.at(2)); + auto *target = root->findChild(QStringLiteral("target")); + auto *background = root->findChild(QStringLiteral("backgroundTitle")); + InspectableAgent agent; + if (!target || !background || !agent.setup(&window)) + return 2; + agent.setTitleBar(parts.at(0) == QStringLiteral("title") ? target : background); + if (parts.at(0) == QStringLiteral("excluded")) + agent.setHitTestVisible(target); + if (parts.at(0) == QStringLiteral("button")) + agent.setSystemButton(QWK::WindowAgentBase::Close, target); + window.show(); + ::ShowWindow(reinterpret_cast(window.winId()), SW_SHOWNOACTIVATE); + if (!QTest::qWaitForWindowExposed(&window)) + return 2; + reportReady(&window, agent.contextKey(), + agent.windowAttribute(QStringLiteral("windows-system-border-enabled")).toBool()); + return QCoreApplication::exec(); + } if (kind == QStringLiteral("quick")) { QQuickWindow window; window.setGeometry(200, 200, 640, 400); @@ -254,6 +292,51 @@ private Q_SLOTS: checkHit({left, bottom}, fixedWidth ? HTBOTTOM : HTLEFT, "bottom left"); checkHit({right, bottom}, fixedWidth ? HTBOTTOM : HTRIGHT, "bottom right"); } + +#ifdef TEST_QUICK + void quickTransforms_data() { + QTest::addColumn("scenario"); + for (const auto *role : {"title", "excluded", "button"}) { + for (const auto *owner : {"item", "ancestor"}) { + for (const auto *mode : {"scale", "shrink", "rotation", "matrix", "zero"}) { + const auto name = QStringLiteral("%1-%2-%3").arg(role, owner, mode); + QTest::newRow(qPrintable(name)) << name; + } + } + } + } + + void quickTransforms() { + QFETCH(QString, scenario); + startFixture(QStringLiteral("quick-transform"), scenario); + if (QTest::currentTestFailed()) + return; + const auto parts = scenario.split(QLatin1Char('-')); + const auto role = parts.at(0); + const auto mode = parts.at(2); + const LRESULT inside = role == QStringLiteral("title") ? HTCAPTION + : role == QStringLiteral("button") ? HTCLOSE : HTCLIENT; + const LRESULT outside = role == QStringLiteral("title") ? HTCLIENT : HTCAPTION; + // Coordinates are calculated from the fixture's simple geometry, independently + // of QQuickItem mapping or production hit-test helpers. + if (mode == QStringLiteral("scale")) { + checkClientPoint(450, 200, inside, "scaled interior"); + checkClientPoint(520, 200, outside, "outside scaled item"); + } else if (mode == QStringLiteral("shrink")) { + checkClientPoint(325, 170, inside, "shrunk interior"); + checkClientPoint(375, 180, outside, "outside shrunk item"); + } else if (mode == QStringLiteral("rotation")) { + checkClientPoint(350, 220, inside, "rotated interior"); + checkClientPoint(305, 240, outside, "empty corner within rotated bounding box"); + checkClientPoint(380, 170, outside, "old unrotated footprint"); + } else if (mode == QStringLiteral("matrix")) { + checkClientPoint(450, 170, inside, "nonuniform transform interior"); + checkClientPoint(350, 190, outside, "outside nonuniform transform"); + } else { + checkClientPoint(310, 170, outside, "zero scale has no hit area"); + } + } +#endif }; int main(int argc, char **argv) { From 0645c9f6413af351370eedcb1084ce58cafe2913 Mon Sep 17 00:00:00 2001 From: wangwenx190 <2546789017@qq.com> Date: Tue, 22 Sep 2026 15:54:51 +0800 Subject: [PATCH 10/56] fix(wayland): convert system menu positions to surface coordinates The title-bar mouse path and public showSystemMenu API supply global Qt coordinates, but the Wayland hook forwarded them unchanged to the xdg_toplevel.show_window_menu request. That request uses surface-local coordinates, so a window offset or Qt coordinate scaling could displace the menu anchor. Map the global point through QWindow::mapFromGlobal, then convert the floating-point local result with QHighDpi::toNativeLocalPosition before rounding for the protocol. This accounts for Qt coordinate scaling without multiplying by the Wayland buffer scale. QtWindowContext installs FramelessWindowHint, which disables Qt client-side decorations, so the content and surface origins coincide. Guard absent window handles/IDs before mapping or looking up the native toplevel. Document the public coordinate contract and replace the obsolete TODO with explicit compositor runtime validation steps. Other backends and the existing input serial, seat and flush behavior are unchanged. Validation: - Reviewed the xdg-shell coordinate contract, Qt window mapping, Wayland decoration behavior, existing context lifecycle and Qt feature guards. - Windows Qt 6.12 / MSVC Release build passed. - All 10 existing CTest tests passed, including install consumers, QML metadata, hit testing, geometry and software/D3D11 border checks. - git diff --check passed. Limitations: no Linux/Wayland toolchain or compositor is available here. The Windows build excludes this backend, and these tests do not validate its compilation or runtime placement. No mocked Wayland test was added: reliable coverage needs a real backend/compositor with input serials and scale variations; Linux UI test infrastructure is outside the current Windows-only test scope. Remaining manual scenarios are in docs/TODO.md. --- docs/TODO.md | 75 ++++++++++------------------ src/core/contexts/waylandcontext.cpp | 13 ++++- src/core/windowagentbase.cpp | 3 +- 3 files changed, 39 insertions(+), 52 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index bcea32ab..bdc67008 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,53 +1,30 @@ # TODO -## Wayland: `showSystemMenu()` may be passing the wrong coordinate space +## Wayland: validate system menu placement on a compositor **File:** `src/core/contexts/waylandcontext.cpp` -**Introduced by:** the Wayland/X11 backend contribution (Wing-summer), needs the original author's eyes. -**Status:** unverified — reported from code reading only, no Wayland machine was available to test. - -`WaylandContext::virtual_hook()` forwards the incoming point straight to the -xdg-shell request: - -```cpp -auto pos = static_cast(data); -xdg_toplevel_show_window_menu(toplevel, seat, serial, pos->x(), pos->y()); -``` - -The point that arrives here is a **global** position. `AbstractWindowContext::showSystemMenu()` -is fed from `QtWindowEventFilter::sharedEventFilter()`, which passes -`getMouseEventGlobalPos(me)`, and the public `WindowAgentBase::showSystemMenu()` is documented -in terms of global coordinates too. - -`xdg_toplevel.show_window_menu` is believed to expect **surface-local** coordinates (relative to -the top-left of the window geometry). If that is right, the menu pops up displaced by the -window's own position on screen, and the error disappears only when the window happens to sit at -the origin. - -Circumstantial evidence that the two Linux backends disagree about what they are handed: the X11 -path in `x11context.cpp` treats the same argument as global and converts it to root -coordinates, - -```cpp -qreal dpr = m_windowHandle->devicePixelRatio(); -int root_x = qRound(pos->x() * dpr); -int root_y = qRound(pos->y() * dpr); -``` - -which is the correct thing to do for `_GTK_SHOW_WINDOW_MENU`. Both backends implement the same -hook and receive the same input, so at most one of them can be right. - -### To check - -1. Confirm against the xdg-shell protocol XML which coordinate space `show_window_menu` wants. -2. If it is surface-local, subtract the window position before sending, e.g. - `*pos - m_windowHandle->position()`, and decide whether a device pixel ratio conversion is - needed at all (Wayland surface-local coordinates are logical, so probably not). -3. Test with a window that is **not** at the top-left of the screen — the bug is invisible at the - origin. - -### Related, same file - -`m_windowHandle` is passed to `nativeResourceForWindow()` without a null check. It happens to be -safe today because a null window yields a null `xdg_toplevel` and the function returns early, but -the X11 path had the same shape and did crash; it has since been given an explicit guard. +**Status:** coordinate conversion fixed and statically reviewed; Wayland runtime validation pending. + +`showSystemMenu()` accepts global Qt device-independent coordinates. The Wayland backend maps +them to window-local coordinates and uses Qt's native local conversion before issuing +`xdg_toplevel.show_window_menu`, which requires surface-local coordinates. QWindowKit sets +`Qt::FramelessWindowHint`, so there is no Qt client-side decoration offset to add. Wayland's +buffer scale must not be applied to the request coordinates. + +References: + +- [xdg-shell protocol](https://raw.githubusercontent.com/wayland-mirror/wayland-protocols/main/stable/xdg-shell/xdg-shell.xml), `show_window_menu`. +- [Qt Wayland window implementation](https://github.com/qt/qtwayland/blob/6.8/src/client/qwaylandwindow.cpp), `mapFromWlSurface()` and `createDecoration()`. +- [Qt window coordinate mapping](https://github.com/qt/qtbase/blob/6.8/src/gui/kernel/qwindow.cpp), `mapFromGlobal()`. + +On a Wayland compositor that supports this menu, check both Widgets and Quick windows: + +1. Right-click different title-bar positions, then move the window and repeat. The menu anchor + should follow the click, subject to compositor placement constraints. +2. Repeat with compositor scaling at 100%, 150%, and 200%, and with `QT_SCALE_FACTOR=2` to + exercise Qt's extra coordinate scaling separately from the Wayland buffer scale. +3. Trigger the public `showSystemMenu()` API during a valid input event using a point obtained + from `QWindow::mapToGlobal()`, and confirm the corresponding local anchor is used. + +Record the compositor and Qt versions. Windows builds and tests do not execute this backend; +no Wayland compile or runtime result is claimed for the fix. diff --git a/src/core/contexts/waylandcontext.cpp b/src/core/contexts/waylandcontext.cpp index 3372d79f..200bd9f5 100644 --- a/src/core/contexts/waylandcontext.cpp +++ b/src/core/contexts/waylandcontext.cpp @@ -7,6 +7,7 @@ #ifdef QWK_HAS_WAYLAND_CONTEXT #include "qwindowkit_wayland.h" +#include #include namespace QWK { @@ -32,6 +33,9 @@ namespace QWK { void WaylandContext::virtual_hook(int id, void *data) { if (id == ShowSystemMenuHook) { + if (!m_windowId || !m_windowHandle) { + return; + } auto *waylandApp = qApp->nativeInterface(); if (!waylandApp) { return; @@ -48,8 +52,13 @@ namespace QWK { if (!toplevel) { return; } - auto pos = static_cast(data); - xdg_toplevel_show_window_menu(toplevel, seat, serial, pos->x(), pos->y()); + const auto &globalPos = *static_cast(data); + const QPointF localPos = m_windowHandle->mapFromGlobal(QPointF(globalPos)); + // QtWindowContext uses FramelessWindowHint, so the content and surface origins + // coincide. Undo Qt's coordinate scaling, without applying Wayland's buffer scale. + const QPoint surfacePos = + QHighDpi::toNativeLocalPosition(localPos, m_windowHandle.data()).toPoint(); + xdg_toplevel_show_window_menu(toplevel, seat, serial, surfacePos.x(), surfacePos.y()); wl_display *d = waylandApp->display(); if (d) { diff --git a/src/core/windowagentbase.cpp b/src/core/windowagentbase.cpp index 127facba..ce4ec994 100644 --- a/src/core/windowagentbase.cpp +++ b/src/core/windowagentbase.cpp @@ -146,7 +146,8 @@ namespace QWK { } /*! - Shows the system menu, it's only implemented on Windows. + Shows the system menu at the global position \a pos, in Qt device-independent + coordinates, on supported platforms. */ void WindowAgentBase::showSystemMenu(const QPoint &pos) { Q_D(WindowAgentBase); From 39b8393a8f8d324b7fc1d6d04a0f4449ac9679bd Mon Sep 17 00:00:00 2001 From: wangwenx190 <2546789017@qq.com> Date: Tue, 22 Sep 2026 16:03:51 +0800 Subject: [PATCH 11/56] fix(msbuild): pass static import macros to the C++ compiler Installed static-library property sheets wrote QWK_CORE_STATIC, QWK_WIDGETS_STATIC and QWK_QUICK_STATIC into PropertyGroup instead of ClCompile item definitions. MSBuild did not pass those properties to cl, so consumers still treated static exports as dllimport and could fail to link methods or staticMetaObject references. Generate the static definitions as a semicolon-delimited prefix in ItemDefinitionGroup/ClCompile/PreprocessorDefinitions and retain inherited metadata. Explicitly leave the prefix empty for shared builds and remove the ineffective property group. Extend the installed MSBuild consumer to cover Core, Widgets and Quick. Check static/shared macro selection and a compiler definition established before the property-sheet import. Read each module's static metaobject and call exported agent methods so optimized builds retain real linker references. The test supplies the static Windows backend's existing uxtheme dependency separately from the property sheet under test. Normalize the new CMake booleans before passing them to MSBuild. Add a Windows MSVC CI configuration that builds a static install, requires the MSBuild regression to register, and runs it with the install fixture. Validation on Windows x64, MSVC 19.51, Qt 6.12: - The new static Release consumer failed before the production fix with the explicit missing QWK_CORE_STATIC compiler error. - The same installed static consumer passed after the fix in Release and Debug, compiling and linking all three QWindowKit modules. - Inspected compiler command logs for the three static macros, enabled modules and inherited definition; shared builds contain no static macros. - Shared Release build and all 11 registered CTests passed, including the expanded MSBuild consumer and existing CMake, QML and native UI checks. - Property-sheet/project XML parsing and git diff --check passed. This is an installation/compile/link regression, not a UI or unit test. GitHub Actions execution and other Qt/compiler/architecture combinations were not run. The local audit report is maintained outside this commit; pre-existing qmsetup submodule changes are untouched. --- .github/workflows/ci.yml | 26 +++++++++++ share/install.cmake | 7 +-- share/msbuild/QWindowKit.props.in | 5 +-- tests/buildsystems/CMakeLists.txt | 2 + tests/buildsystems/msbuild/RunBuild.cmake | 15 ++++++- tests/buildsystems/msbuild/consumer.cpp | 49 +++++++++++++++++++++ tests/buildsystems/msbuild/consumer.vcxproj | 17 +++++-- 7 files changed, 109 insertions(+), 12 deletions(-) create mode 100644 tests/buildsystems/msbuild/consumer.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f83786e0..19f32c06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,6 +121,32 @@ jobs: - name: Test run: ctest --test-dir build --output-on-failure --no-tests=error + - name: Configure static MSBuild consumer regression + if: matrix.platform == 'windows-msvc' + shell: bash + env: + CC: ${{ matrix.cc }} + CXX: ${{ matrix.cxx }} + run: | + cmake -G Ninja -S . -B build-static \ + -DCMAKE_BUILD_TYPE=Release \ + -DQWINDOWKIT_BUILD_STATIC=ON \ + -DQWINDOWKIT_BUILD_WIDGETS=ON \ + -DQWINDOWKIT_BUILD_QUICK=ON \ + -DQWINDOWKIT_BUILD_TESTS=ON + if ! ctest --test-dir build-static -N -R '^buildsystems.msbuild$' | grep -q 'buildsystems.msbuild'; then + echo 'The static MSBuild consumer test did not register' + exit 1 + fi + + - name: Build static configuration + if: matrix.platform == 'windows-msvc' + run: cmake --build build-static + + - name: Test static MSBuild consumer + if: matrix.platform == 'windows-msvc' + run: ctest --test-dir build-static -R "^buildsystems.msbuild$" --output-on-failure --no-tests=error + # Single-axis caption hit testing has a separate compile-time path without # system borders. Exercise it with real Widgets and Quick HWNDs as well. - name: Configure without system borders diff --git a/share/install.cmake b/share/install.cmake index 55279934..6e32aa0b 100644 --- a/share/install.cmake +++ b/share/install.cmake @@ -79,9 +79,10 @@ if(MSVC) to_dos_separator(MSBUILD_QWK_INSTALL_LIBDIR) to_dos_separator(MSBUILD_QWK_INSTALL_INCDIR) + set(MSBUILD_QWK_STATIC_MACRO "") if(QWINDOWKIT_BUILD_STATIC) - set(MSBUILD_QWK_STATIC_MACRO - "QWK_CORE_STATIC;QWK_WIDGETS_STATIC;QWK_QUICK_STATIC" + set(MSBUILD_QWK_STATIC_MACRO + "QWK_CORE_STATIC;QWK_WIDGETS_STATIC;QWK_QUICK_STATIC;" ) endif() @@ -93,4 +94,4 @@ if(MSVC) install(DIRECTORY ${_build_data_dir}/msbuild/ DESTINATION ${_msbuild_install_dir} ) -endif() \ No newline at end of file +endif() diff --git a/share/msbuild/QWindowKit.props.in b/share/msbuild/QWindowKit.props.in index 53282a03..f5dd24b6 100644 --- a/share/msbuild/QWindowKit.props.in +++ b/share/msbuild/QWindowKit.props.in @@ -8,14 +8,11 @@ - %(PreprocessorDefinitions) + @MSBUILD_QWK_STATIC_MACRO@%(PreprocessorDefinitions) @MSBUILD_QWK_LIBRARY_LIST_DEBUG@;%(AdditionalDependencies); @MSBUILD_QWK_LIBRARY_LIST_RELEASE@;%(AdditionalDependencies); - - @MSBUILD_QWK_STATIC_MACRO@ - diff --git a/tests/buildsystems/CMakeLists.txt b/tests/buildsystems/CMakeLists.txt index a41aa6da..b591ffe4 100644 --- a/tests/buildsystems/CMakeLists.txt +++ b/tests/buildsystems/CMakeLists.txt @@ -129,6 +129,8 @@ if(QWINDOWKIT_TEST_MSBUILD_EXECUTABLE AND _platform AND _qt_prefix) -D "QT_PREFIX=${_qt_prefix}" -D "QT_MAJOR_VERSION=${QT_VERSION_MAJOR}" -D "USE_WIDGETS=${_use_widgets}" + -D "USE_QUICK=${QWINDOWKIT_BUILD_QUICK}" + -D "EXPECT_STATIC=${QWINDOWKIT_BUILD_STATIC}" -P "${CMAKE_CURRENT_SOURCE_DIR}/msbuild/RunBuild.cmake" ) set_tests_properties(buildsystems.msbuild PROPERTIES FIXTURES_REQUIRED qwk_install_tree) diff --git a/tests/buildsystems/msbuild/RunBuild.cmake b/tests/buildsystems/msbuild/RunBuild.cmake index bdf141f4..ab41dfe7 100644 --- a/tests/buildsystems/msbuild/RunBuild.cmake +++ b/tests/buildsystems/msbuild/RunBuild.cmake @@ -2,12 +2,13 @@ # # cmake -D MSBUILD_EXECUTABLE=... -D PROJECT_FILE=... -D INSTALL_PREFIX=... -D WORK_DIR=... # -D BUILD_CONFIG=Debug|Release -D PLATFORM=x64 -D QT_PREFIX=... -D QT_MAJOR_VERSION=6 -# -D USE_WIDGETS=true|false -P msbuild/RunBuild.cmake +# -D USE_WIDGETS=true|false -D USE_QUICK=ON|OFF -D EXPECT_STATIC=ON|OFF +# -P msbuild/RunBuild.cmake include("${CMAKE_CURRENT_LIST_DIR}/../testing/ConsumerBuildCommon.cmake") qwk_require_variables(MSBUILD_EXECUTABLE PROJECT_FILE INSTALL_PREFIX WORK_DIR BUILD_CONFIG - PLATFORM QT_PREFIX QT_MAJOR_VERSION USE_WIDGETS + PLATFORM QT_PREFIX QT_MAJOR_VERSION USE_WIDGETS USE_QUICK EXPECT_STATIC ) file(REMOVE_RECURSE "${WORK_DIR}") @@ -20,6 +21,14 @@ file(MAKE_DIRECTORY "${WORK_DIR}") file(TO_NATIVE_PATH "${INSTALL_PREFIX}" _native_prefix) file(TO_NATIVE_PATH "${QT_PREFIX}" _native_qt_prefix) +foreach(_option USE_QUICK EXPECT_STATIC) + if(${_option}) + set(_${_option} true) + else() + set(_${_option} false) + endif() +endforeach() + qwk_run_step("msbuild" "${MSBUILD_EXECUTABLE}" "${PROJECT_FILE}" /nologo /verbosity:minimal @@ -29,6 +38,8 @@ qwk_run_step("msbuild" "${MSBUILD_EXECUTABLE}" "${PROJECT_FILE}" "/p:QTDIR=${_native_qt_prefix}" "/p:QtVersionMajor=${QT_MAJOR_VERSION}" "/p:QwkUseWidgets=${USE_WIDGETS}" + "/p:QwkUseQuick=${_USE_QUICK}" + "/p:QwkExpectStatic=${_EXPECT_STATIC}" "/p:OutDir=${WORK_DIR}/bin/" "/p:IntDir=${WORK_DIR}/obj/" ) diff --git a/tests/buildsystems/msbuild/consumer.cpp b/tests/buildsystems/msbuild/consumer.cpp new file mode 100644 index 00000000..6ac27fe5 --- /dev/null +++ b/tests/buildsystems/msbuild/consumer.cpp @@ -0,0 +1,49 @@ +// Copyright (C) 2023-present Stdware Collections (https://www.github.com/stdware) +// SPDX-License-Identifier: Apache-2.0 + +// Compile and link only: exercise the installed property sheet, including static imports. +#include + +#ifdef CONSUMER_USE_WIDGETS +# include +#endif +#ifdef CONSUMER_USE_QUICK +# include +#endif + +#ifndef CONSUMER_INHERITED_DEFINITION +# error The property sheet discarded inherited compiler definitions. +#endif + +#ifdef CONSUMER_EXPECT_STATIC +# ifndef QWK_CORE_STATIC +# error The static property sheet did not define QWK_CORE_STATIC. +# endif +# if defined(CONSUMER_USE_WIDGETS) && !defined(QWK_WIDGETS_STATIC) +# error The static property sheet did not define QWK_WIDGETS_STATIC. +# endif +# if defined(CONSUMER_USE_QUICK) && !defined(QWK_QUICK_STATIC) +# error The static property sheet did not define QWK_QUICK_STATIC. +# endif +#else +# if defined(QWK_CORE_STATIC) || defined(QWK_WIDGETS_STATIC) || defined(QWK_QUICK_STATIC) +# error The shared property sheet must not define static import macros. +# endif +#endif + +int main() { + // Reading metaobjects and calling exported methods keeps the linker references alive + // even in optimized builds; merely comparing their addresses can be optimized away. + int result = QWK::WindowAgentBase::staticMetaObject.className()[0]; +#ifdef CONSUMER_USE_WIDGETS + QWK::WidgetWindowAgent widgets; + result += QWK::WidgetWindowAgent::staticMetaObject.className()[0]; + result += widgets.titleBar() != nullptr; +#endif +#ifdef CONSUMER_USE_QUICK + QWK::QuickWindowAgent quick; + result += QWK::QuickWindowAgent::staticMetaObject.className()[0]; + result += quick.titleBar() != nullptr; +#endif + return result; +} diff --git a/tests/buildsystems/msbuild/consumer.vcxproj b/tests/buildsystems/msbuild/consumer.vcxproj index ad1c622f..e6e37642 100644 --- a/tests/buildsystems/msbuild/consumer.vcxproj +++ b/tests/buildsystems/msbuild/consumer.vcxproj @@ -1,7 +1,7 @@ @@ -66,15 +71,21 @@ MultiThreadedDLL $(QtHeaderSearchPath);$(QTDIR)\include;$(QTDIR)\include\QtCore;$(QTDIR)\include\QtGui;$(QTDIR)\include\QtWidgets;%(AdditionalIncludeDirectories) CONSUMER_USE_WIDGETS;%(PreprocessorDefinitions) + CONSUMER_USE_QUICK;%(PreprocessorDefinitions) + CONSUMER_EXPECT_STATIC;%(PreprocessorDefinitions) + $(QTDIR)\include\QtQuick;$(QTDIR)\include\QtQml;%(AdditionalIncludeDirectories) Console $(QTDIR)\lib;%(AdditionalLibraryDirectories) Qt$(QtVersionMajor)Core$(QtLibSuffix).lib;Qt$(QtVersionMajor)Gui$(QtLibSuffix).lib;Qt$(QtVersionMajor)Widgets$(QtLibSuffix).lib;%(AdditionalDependencies) + Qt$(QtVersionMajor)Quick$(QtLibSuffix).lib;Qt$(QtVersionMajor)Qml$(QtLibSuffix).lib;%(AdditionalDependencies) + + uxtheme.lib;%(AdditionalDependencies) - + From e20e6155e57811ea73b276f7146dca8a444ceaed Mon Sep 17 00:00:00 2001 From: wangwenx190 <2546789017@qq.com> Date: Tue, 22 Sep 2026 16:50:16 +0800 Subject: [PATCH 12/56] test: add a bounded offline Qt unit test runner Introduce shared CMake helpers for registering fast Qt Test executables and an aggregate qwk_fast_unit_tests target that builds only those executables and their library dependencies. Load the helpers before the other test directories and migrate the existing Quick geometry suite to this runner. Run GUI-dependent cases with offscreen QPA and the software Quick backend. Set local Qt/QWindowKit DLL search paths on Windows, regenerate text and XML reports, enforce an eight-second process deadline and a ten-second CTest timeout, and fail on unexpected pass counts, skips or expected failures. Label these tests unit and fast so developers can exclude installation, consumer compilation and native desktop tests from short feedback cycles. The runner uses the existing CMake/CTest tools and Qt only. It performs no downloads, package installation or network access and needs no visible desktop or external testing framework. Preserve the geometry suite's existing Windows/Quick registration conditions and all 12 business cases. Validation on the completed series, Windows/MSVC with Qt 6.12.0: - Quick geometry passed in Release and Debug with 14 Qt Test passes, including initialization and cleanup, and no skips. - All five fast suites passed in approximately 0.20 seconds in Release and 0.28 seconds in Debug; the aggregate target built successfully. - git diff --check passed. Other platforms and hosted CI were not run. --- tests/CMakeLists.txt | 1 + tests/quickgeometry/CMakeLists.txt | 12 ++---------- tests/unit/CMakeLists.txt | 25 +++++++++++++++++++++++++ tests/unit/RunTest.cmake | 23 +++++++++++++++++++++++ 4 files changed, 51 insertions(+), 10 deletions(-) create mode 100644 tests/unit/CMakeLists.txt create mode 100644 tests/unit/RunTest.cmake diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 49505e46..66f791f1 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,3 +1,4 @@ +add_subdirectory(unit) add_subdirectory(buildsystems) if(WIN32 AND QWINDOWKIT_BUILD_QUICK) diff --git a/tests/quickgeometry/CMakeLists.txt b/tests/quickgeometry/CMakeLists.txt index 312447a3..fa8aa6c0 100644 --- a/tests/quickgeometry/CMakeLists.txt +++ b/tests/quickgeometry/CMakeLists.txt @@ -5,13 +5,5 @@ set_target_properties(tst_quickgeometry PROPERTIES AUTOMOC ON) target_compile_features(tst_quickgeometry PRIVATE cxx_std_17) target_link_libraries(tst_quickgeometry PRIVATE QWKQuick Qt${QT_VERSION_MAJOR}::Test) -add_test(NAME quickgeometry.unit - COMMAND ${CMAKE_COMMAND} -E env - "PATH=$;$;$ENV{PATH}" - "QT_QPA_PLATFORM=offscreen" - ${CMAKE_COMMAND} - "-DTEST_EXE=$" - "-DRESULT_FILE=${CMAKE_CURRENT_BINARY_DIR}/geometry-$" - "-DEXPECTED_PASSES=14" - -P "${CMAKE_CURRENT_SOURCE_DIR}/../windows/hittest/RunTest.cmake") -set_tests_properties(quickgeometry.unit PROPERTIES TIMEOUT 30 LABELS "quick;unit") +qwk_register_fast_test(tst_quickgeometry quickgeometry.unit 14) +set_property(TEST quickgeometry.unit APPEND PROPERTY LABELS quick) diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt new file mode 100644 index 00000000..d2cb348a --- /dev/null +++ b/tests/unit/CMakeLists.txt @@ -0,0 +1,25 @@ +qm_find_qt(Core Gui Test) + +add_custom_target(qwk_fast_unit_tests) + +# Only Qt and the production libraries: no downloads, external test runner or desktop. +function(qwk_register_fast_test target name expected_passes) + add_dependencies(qwk_fast_unit_tests ${target}) + add_test(NAME ${name} + COMMAND ${CMAKE_COMMAND} + "-DTEST_EXE=$" + "-DQWK_BIN_DIR=$" + "-DQT_BIN_DIR=$" + "-DRESULT_FILE=${CMAKE_CURRENT_BINARY_DIR}/${name}-$" + "-DEXPECTED_PASSES=${expected_passes}" + -P "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/RunTest.cmake") + set_tests_properties(${name} PROPERTIES TIMEOUT 10 LABELS "unit;fast") +endfunction() + +function(qwk_add_unit_test target name expected_passes) + add_executable(${target} ${target}.cpp) + set_target_properties(${target} PROPERTIES AUTOMOC ON) + target_compile_features(${target} PRIVATE cxx_std_17) + target_link_libraries(${target} PRIVATE QWKCore Qt${QT_VERSION_MAJOR}::Test) + qwk_register_fast_test(${target} ${name} ${expected_passes}) +endfunction() diff --git a/tests/unit/RunTest.cmake b/tests/unit/RunTest.cmake new file mode 100644 index 00000000..b76c6ec6 --- /dev/null +++ b/tests/unit/RunTest.cmake @@ -0,0 +1,23 @@ +# Test execution is offline. Build/install/consumer tests are deliberately not invoked here. +if(WIN32) + set(ENV{PATH} "${QWK_BIN_DIR};${QT_BIN_DIR};$ENV{PATH}") +endif() +set(ENV{QT_QPA_PLATFORM} offscreen) +set(ENV{QT_QUICK_BACKEND} software) +file(REMOVE "${RESULT_FILE}.txt" "${RESULT_FILE}.xml") +execute_process(COMMAND "${TEST_EXE}" + -o "${RESULT_FILE}.txt,txt" -o "${RESULT_FILE}.xml,xml" + RESULT_VARIABLE result TIMEOUT 8) +if(EXISTS "${RESULT_FILE}.txt") + file(READ "${RESULT_FILE}.txt" report) + message("${report}") +endif() +if(NOT "${result}" STREQUAL "0") + message(FATAL_ERROR "Unit test failed or exceeded the 8-second limit: ${result}") +endif() +file(READ "${RESULT_FILE}.xml" report_xml) +string(REGEX MATCHALL " Date: Tue, 22 Sep 2026 16:50:35 +0800 Subject: [PATCH 13/56] test(core): cover event dispatcher mutation and reentrancy Add 22 business cases against the production shared/native event dispatchers and register them as core.eventdispatch.unit with the bounded offline runner. Use small adapters to run the same independently specified call sequences against both dispatch implementations without duplicating dispatch logic. Cover empty dispatch, argument and native-result forwarding, registration order, event consumption, duplicate and foreign registration, transfer between dispatchers, self-removal, destruction of a subsequent filter, self-destruction with consumption, appending filters during dispatch, nested removal, and both dispatcher/filter destruction orders. Exercise application-wide native filter removal through Qt's event dispatcher using synthetic events. Verify that deleting the last filter from inside its callback leaves subsequent dispatch safe and permits a replacement filter to register and unregister normally. No OS messages or input are injected, and no native window or display server is required. Validation on the completed series, Windows/MSVC with Qt 6.12.0: - Release and Debug passed all 24 Qt Test entries, including lifecycle entries, with no failures or skips. - Twenty consecutive Release runs passed. - The suite also passed in a Core-only static build with Widgets, Quick, StyleAgent and installation disabled and the Qt context selected. - Other platforms and hosted CI were not executed locally. --- tests/unit/CMakeLists.txt | 2 + tests/unit/tst_eventdispatch.cpp | 341 +++++++++++++++++++++++++++++++ 2 files changed, 343 insertions(+) create mode 100644 tests/unit/tst_eventdispatch.cpp diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index d2cb348a..c314fd20 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -23,3 +23,5 @@ function(qwk_add_unit_test target name expected_passes) target_link_libraries(${target} PRIVATE QWKCore Qt${QT_VERSION_MAJOR}::Test) qwk_register_fast_test(${target} ${name} ${expected_passes}) endfunction() + +qwk_add_unit_test(tst_eventdispatch core.eventdispatch.unit 24) diff --git a/tests/unit/tst_eventdispatch.cpp b/tests/unit/tst_eventdispatch.cpp new file mode 100644 index 00000000..3878f107 --- /dev/null +++ b/tests/unit/tst_eventdispatch.cpp @@ -0,0 +1,341 @@ +// Copyright (C) 2023-present Stdware Collections (https://www.github.com/stdware) +// SPDX-License-Identifier: Apache-2.0 + +#include +#include + +#include +#include +#include +#include + +namespace { + // The adapters only select the production dispatcher. All expected call sequences below + // are literal values, independent of the dispatcher's list/depth implementation. + class Filter : public QWK::SharedEventFilter, public QWK::NativeEventFilter { + public: + std::function callback = [] { return false; }; + QObject *objectSeen = nullptr; + QEvent *eventSeen = nullptr; + QByteArray typeSeen; + void *messageSeen = nullptr; + QT_NATIVE_EVENT_RESULT_TYPE *resultSeen = nullptr; + + bool sharedEventFilter(QObject *object, QEvent *event) override { + objectSeen = object; + eventSeen = event; + auto action = callback; // A callback may destroy this filter, including callback. + return action(); + } + bool nativeEventFilter(const QByteArray &type, void *message, + QT_NATIVE_EVENT_RESULT_TYPE *result) override { + typeSeen = type; + messageSeen = message; + resultSeen = result; + auto action = callback; + return action(); + } + bool detached() const { return !m_sharedDispatcher && !m_nativeDispatcher; } + }; + + class Dispatcher { + public: + explicit Dispatcher(bool native) : native(native) {} + void install(Filter *filter) { + if (native) + nativeDispatcher.installNativeEventFilter(filter); + else + sharedDispatcher.installSharedEventFilter(filter); + } + void remove(Filter *filter) { + if (native) + nativeDispatcher.removeNativeEventFilter(filter); + else + sharedDispatcher.removeSharedEventFilter(filter); + } + bool dispatch() { + return native ? nativeDispatcher.nativeDispatch("unit-test", &message, &result) + : sharedDispatcher.sharedDispatch(&object, &event); + } + QObject object; + QEvent event{QEvent::User}; + int message = 17; + QT_NATIVE_EVENT_RESULT_TYPE result = 0; + + private: + bool native; + QWK::NativeEventDispatcher nativeDispatcher; + QWK::SharedEventDispatcher sharedDispatcher; + }; + + void dispatcherRows() { + QTest::addColumn("native"); + QTest::newRow("shared") << false; + QTest::newRow("native") << true; + } + + class AppFilter : public QWK::AppNativeEventFilter { + public: + std::function callback; + bool nativeEventFilter(const QByteArray &type, void *, QT_NATIVE_EVENT_RESULT_TYPE *) override { + if (type != "qwk-unit-test") + return false; + auto action = callback; + return action(); + } + }; + + bool dispatchAppEvent() { + QT_NATIVE_EVENT_RESULT_TYPE result = 0; + int message = 0; + return QAbstractEventDispatcher::instance()->filterNativeEvent("qwk-unit-test", &message, &result); + } +} + +class EventDispatchTest : public QObject { + Q_OBJECT +private Q_SLOTS: + void argumentsAndEmpty_data() { dispatcherRows(); } + void argumentsAndEmpty() { + QFETCH(bool, native); + Dispatcher dispatcher(native); + QVERIFY(!dispatcher.dispatch()); + dispatcher.install(nullptr); + Filter filter; + dispatcher.install(&filter); + filter.callback = [&] { + if (native) + *filter.resultSeen = 73; + return true; + }; + QVERIFY(dispatcher.dispatch()); + if (native) { + QCOMPARE(filter.typeSeen, QByteArray("unit-test")); + QCOMPARE(filter.messageSeen, static_cast(&dispatcher.message)); + QCOMPARE(filter.resultSeen, &dispatcher.result); + QCOMPARE(dispatcher.result, QT_NATIVE_EVENT_RESULT_TYPE(73)); + } else { + QCOMPARE(filter.objectSeen, &dispatcher.object); + QCOMPARE(filter.eventSeen, &dispatcher.event); + } + } + + void orderAndConsumption_data() { dispatcherRows(); } + void orderAndConsumption() { + QFETCH(bool, native); + Dispatcher dispatcher(native); + Filter a, b, c; + QString calls; + bool consume = true; + a.callback = [&] { calls += 'A'; return false; }; + b.callback = [&] { calls += 'B'; return consume; }; + c.callback = [&] { calls += 'C'; return false; }; + for (auto *filter : {&a, &b, &c}) + dispatcher.install(filter); + QVERIFY(dispatcher.dispatch()); + QCOMPARE(calls, QStringLiteral("AB")); + calls.clear(); + consume = false; + QVERIFY(!dispatcher.dispatch()); + QCOMPARE(calls, QStringLiteral("ABC")); + } + + void registrationAndTransfer_data() { dispatcherRows(); } + void registrationAndTransfer() { + QFETCH(bool, native); + Dispatcher first(native), second(native); + Filter filter; + int calls = 0; + filter.callback = [&] { ++calls; return false; }; + first.install(&filter); + first.install(&filter); + second.install(&filter); // One dispatcher owns the registration at a time. + second.remove(&filter); // Removing a foreign filter must leave it attached. + QVERIFY(!filter.detached()); + first.dispatch(); + second.dispatch(); + QCOMPARE(calls, 1); + first.remove(&filter); + first.remove(&filter); + QVERIFY(filter.detached()); + second.install(&filter); + first.dispatch(); + second.dispatch(); + QCOMPARE(calls, 2); + } + + void removeSelf_data() { dispatcherRows(); } + void removeSelf() { + QFETCH(bool, native); + Dispatcher dispatcher(native); + Filter a, b; + QString calls; + a.callback = [&] { calls += 'A'; dispatcher.remove(&a); return false; }; + b.callback = [&] { calls += 'B'; return false; }; + dispatcher.install(&a); + dispatcher.install(&b); + QVERIFY(!dispatcher.dispatch()); + QCOMPARE(calls, QStringLiteral("AB")); + QVERIFY(a.detached()); + calls.clear(); + dispatcher.dispatch(); + QCOMPARE(calls, QStringLiteral("B")); + } + + void destroyNext_data() { dispatcherRows(); } + void destroyNext() { + QFETCH(bool, native); + Dispatcher dispatcher(native); + Filter a, c; + auto b = std::make_unique(); + QString calls; + a.callback = [&] { calls += 'A'; b.reset(); return false; }; + b->callback = [&] { calls += 'B'; return false; }; + c.callback = [&] { calls += 'C'; return false; }; + dispatcher.install(&a); + dispatcher.install(b.get()); + dispatcher.install(&c); + dispatcher.dispatch(); + QCOMPARE(calls, QStringLiteral("AC")); + calls.clear(); + dispatcher.dispatch(); + QCOMPARE(calls, QStringLiteral("AC")); + } + + void destroySelfAndConsume_data() { dispatcherRows(); } + void destroySelfAndConsume() { + QFETCH(bool, native); + Dispatcher dispatcher(native); + auto a = std::make_unique(); + Filter b; + QString calls; + a->callback = [&] { calls += 'A'; a.reset(); return true; }; + b.callback = [&] { calls += 'B'; return false; }; + dispatcher.install(a.get()); + dispatcher.install(&b); + QVERIFY(dispatcher.dispatch()); + QCOMPARE(calls, QStringLiteral("A")); + calls.clear(); + QVERIFY(!dispatcher.dispatch()); + QCOMPARE(calls, QStringLiteral("B")); + } + + void appendDuringDispatch_data() { dispatcherRows(); } + void appendDuringDispatch() { + QFETCH(bool, native); + Dispatcher dispatcher(native); + Filter a, b, c; + QString calls; + a.callback = [&] { calls += 'A'; dispatcher.install(&c); return false; }; + b.callback = [&] { calls += 'B'; return false; }; + c.callback = [&] { calls += 'C'; return false; }; + dispatcher.install(&a); + dispatcher.install(&b); + dispatcher.dispatch(); + QCOMPARE(calls, QStringLiteral("ABC")); + calls.clear(); + dispatcher.dispatch(); + QCOMPARE(calls, QStringLiteral("ABC")); + } + + void nestedRemoval_data() { dispatcherRows(); } + void nestedRemoval() { + QFETCH(bool, native); + Dispatcher dispatcher(native); + Filter a, b, c; + QString calls; + bool nested = false; + bool innerConsumed = false; + a.callback = [&] { + calls += nested ? 'a' : 'A'; + if (!nested) { + nested = true; + innerConsumed = dispatcher.dispatch(); + nested = false; + } + return false; + }; + b.callback = [&] { + calls += 'b'; + dispatcher.remove(&a); + dispatcher.remove(&b); + return true; + }; + c.callback = [&] { calls += 'C'; return false; }; + for (auto *filter : {&a, &b, &c}) + dispatcher.install(filter); + QVERIFY(!dispatcher.dispatch()); + QVERIFY(innerConsumed); + QCOMPARE(calls, QStringLiteral("AabC")); + QVERIFY(a.detached()); + QVERIFY(b.detached()); + calls.clear(); + dispatcher.dispatch(); + QCOMPARE(calls, QStringLiteral("C")); + } + + void dispatcherDestroyedFirst_data() { dispatcherRows(); } + void dispatcherDestroyedFirst() { + QFETCH(bool, native); + Filter filter; + int calls = 0; + filter.callback = [&] { ++calls; return false; }; + { + Dispatcher dispatcher(native); + dispatcher.install(&filter); + QVERIFY(!filter.detached()); + } + QVERIFY(filter.detached()); + Dispatcher replacement(native); + replacement.install(&filter); + replacement.dispatch(); + QCOMPARE(calls, 1); + } + + void filterDestroyedFirst_data() { dispatcherRows(); } + void filterDestroyedFirst() { + QFETCH(bool, native); + Dispatcher dispatcher(native); + int calls = 0; + { + Filter filter; + filter.callback = [&] { ++calls; return true; }; + dispatcher.install(&filter); + QVERIFY(dispatcher.dispatch()); + } + QVERIFY(!dispatcher.dispatch()); + QCOMPARE(calls, 1); + } + + void appFilterUnregisters() { + int calls = 0; + { + AppFilter filter; + filter.callback = [&] { ++calls; return true; }; + QVERIFY(dispatchAppEvent()); + QCOMPARE(calls, 1); + } + QVERIFY(!dispatchAppEvent()); + QCOMPARE(calls, 1); + } + + void lastAppFilterDestroyedInCallback() { + auto filter = std::make_unique(); + int calls = 0; + filter->callback = [&] { ++calls; filter.reset(); return true; }; + QVERIFY(dispatchAppEvent()); + QVERIFY(!filter); + QVERIFY(!dispatchAppEvent()); + QCOMPARE(calls, 1); + { + AppFilter replacement; + replacement.callback = [&] { ++calls; return true; }; + QVERIFY(dispatchAppEvent()); + QCOMPARE(calls, 2); + } + QVERIFY(!dispatchAppEvent()); + } +}; + +QTEST_GUILESS_MAIN(EventDispatchTest) +#include "tst_eventdispatch.moc" From 5fb26730e0c2d6f6d70391e1a59fd89a4a05760c Mon Sep 17 00:00:00 2001 From: wangwenx190 <2546789017@qq.com> Date: Tue, 22 Sep 2026 16:50:41 +0800 Subject: [PATCH 14/56] test(core): cover window context attributes and hit-test state Add 15 business cases for AbstractWindowContext and register them as core.windowcontext.unit. Supply deterministic window IDs, item properties and platform callback results while retaining the production attribute storage, replay, registration and hit-test implementations. Verify attribute creation, replacement and deletion without a handle; atomic rejection of writes and deletes; successful-write replay order; adjacent replay failures and removal from subsequent replays; unchanged handles; handle loss; and reuse of a previous handle value. Record platform callbacks to assert both values and ordering instead of merely checking that execution does not crash. Cover title-bar replacement and registration cleanup, destroyed title/button/ exclusion objects, hidden or disabled titles, titles outside the window, exclusion toggles, system-button visibility and overlap priority, and six fixed-size configurations including a missing window. Validation on the completed series, Windows/MSVC with Qt 6.12.0: - Release and Debug passed 17 Qt Test entries, including initialization and cleanup, with no failures or skips. - Twenty consecutive Release runs and the Core-only static configuration passed. The latter disables UI modules, StyleAgent and installation. These tests validate the base context contract with simulated handle transitions; they do not claim to recreate real Win32/Cocoa/X11/Wayland handles or verify native window interaction. Other platforms were not run. --- tests/unit/CMakeLists.txt | 1 + tests/unit/tst_windowcontext.cpp | 339 +++++++++++++++++++++++++++++++ 2 files changed, 340 insertions(+) create mode 100644 tests/unit/tst_windowcontext.cpp diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index c314fd20..9d2eed0b 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -25,3 +25,4 @@ function(qwk_add_unit_test target name expected_passes) endfunction() qwk_add_unit_test(tst_eventdispatch core.eventdispatch.unit 24) +qwk_add_unit_test(tst_windowcontext core.windowcontext.unit 17) diff --git a/tests/unit/tst_windowcontext.cpp b/tests/unit/tst_windowcontext.cpp new file mode 100644 index 00000000..e219cf9b --- /dev/null +++ b/tests/unit/tst_windowcontext.cpp @@ -0,0 +1,339 @@ +// Copyright (C) 2023-present Stdware Collections (https://www.github.com/stdware) +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include + +namespace { + using Button = QWK::WindowAgentBase; + + class Item : public QObject { + public: + QRect rect{0, 0, 200, 40}; + bool visible = true; + bool enabled = true; + }; + + class HandleFilter : public QWK::WinIdChangeEventFilter { + public: + HandleFilter(QObject *host, QWK::AbstractWindowContext *context, const WId &id) + : WinIdChangeEventFilter(host, context), id(id) {} + WId winId() const override { return id; } + private: + const WId &id; + }; + + // Supply deterministic platform inputs. Attribute storage, replay, registration and hit + // testing remain the production AbstractWindowContext implementation. + class Delegate : public QWK::WindowItemDelegate { + public: + explicit Delegate(QWindow *window) : host(window) {} + QWindow *host; + WId id = 0; + QWindow *window(const QObject *) const override { return host; } + QWindow *hostWindow(const QObject *) const override { return host; } + bool isEnabled(const QObject *obj) const override { return static_cast(obj)->enabled; } + bool isVisible(const QObject *obj) const override { return static_cast(obj)->visible; } + QRect mapGeometryToScene(const QObject *obj) const override { return static_cast(obj)->rect; } + bool isWindowActive(const QObject *) const override { return false; } + Qt::WindowStates getWindowState(const QObject *) const override { return {}; } + Qt::WindowFlags getWindowFlags(const QObject *) const override { return {}; } + QRect getGeometry(const QObject *) const override { return host->geometry(); } + void setWindowState(QObject *, Qt::WindowStates) const override {} + void setCursorShape(QObject *, Qt::CursorShape) const override {} + void restoreCursorShape(QObject *) const override {} + void setWindowFlags(QObject *, Qt::WindowFlags) const override {} + void setWindowVisible(QObject *, bool) const override {} + void setGeometry(QObject *, const QRect &) override {} + void bringWindowToTop(QObject *) const override {} + QWK::WinIdChangeEventFilter *createWinIdEventFilter( + QObject *host, QWK::AbstractWindowContext *context) const override { + return new HandleFilter(host, context, id); + } + }; + + struct AttributeCall { + QString key; + QVariant value; + QVariant oldValue; + }; + + class Context : public QWK::AbstractWindowContext { + public: + QList calls; + QStringList rejectedKeys; + QList> handles; + QStringList keys() const { + QStringList result; + for (const auto &call : calls) + result.append(call.key); + return result; + } + protected: + void winIdChanged(WId id, WId oldId) override { handles.append(qMakePair(id, oldId)); } + bool windowAttributeChanged(const QString &key, const QVariant &value, + const QVariant &oldValue) override { + calls.append({key, value, oldValue}); + return !rejectedKeys.contains(key); + } + }; + + struct Fixture { + QWindow window; + Delegate *delegate = new Delegate(&window); // Owned by context after setup. + Context context; + Fixture() { + window.resize(300, 200); + context.setup(&window, delegate); + } + void changeHandle(WId id) { + delegate->id = id; + context.notifyWinIdChange(); + } + }; +} + +class WindowContextTest : public QObject { + Q_OBJECT +private Q_SLOTS: + void attributesWithoutHandle() { + Fixture f; + QVERIFY(!f.context.windowAttribute("missing").isValid()); + QVERIFY(f.context.setWindowAttribute("missing", {})); + QVERIFY(f.context.setWindowAttribute("alpha", 1)); + QCOMPARE(f.context.windowAttribute("alpha").toInt(), 1); + QVERIFY(f.context.setWindowAttribute("alpha", 2)); + QCOMPARE(f.context.windowAttribute("alpha").toInt(), 2); + QVERIFY(f.context.setWindowAttribute("alpha", {})); + QVERIFY(!f.context.windowAttribute("alpha").isValid()); + QVERIFY(f.context.calls.isEmpty()); // No platform callback without a handle. + f.changeHandle(1); + QVERIFY(f.context.calls.isEmpty()); // Deleted attributes must not be replayed. + } + + void rejectedChangesAreAtomic() { + Fixture f; + f.changeHandle(1); + QVERIFY(f.context.setWindowAttribute("alpha", 7)); + QCOMPARE(f.context.calls.size(), 1); + QVERIFY(!f.context.calls.first().oldValue.isValid()); + f.context.rejectedKeys << "alpha" << "beta"; + QVERIFY(!f.context.setWindowAttribute("alpha", 9)); + QCOMPARE(f.context.windowAttribute("alpha").toInt(), 7); + QCOMPARE(f.context.calls.last().value.toInt(), 9); + QCOMPARE(f.context.calls.last().oldValue.toInt(), 7); + QVERIFY(!f.context.setWindowAttribute("alpha", {})); + QCOMPARE(f.context.windowAttribute("alpha").toInt(), 7); + QVERIFY(!f.context.calls.last().value.isValid()); + QVERIFY(!f.context.setWindowAttribute("beta", 11)); + QVERIFY(!f.context.windowAttribute("beta").isValid()); + f.context.rejectedKeys.clear(); + QVERIFY(f.context.setWindowAttribute("alpha", 13)); + QCOMPARE(f.context.calls.last().oldValue.toInt(), 7); + QCOMPARE(f.context.windowAttribute("alpha").toInt(), 13); + QVERIFY(f.context.setWindowAttribute("alpha", {})); + QVERIFY(!f.context.windowAttribute("alpha").isValid()); + } + + void replayFollowsSuccessfulWriteOrder() { + Fixture f; + QVERIFY(f.context.setWindowAttribute("alpha", 1)); + QVERIFY(f.context.setWindowAttribute("beta", 2)); + QVERIFY(f.context.setWindowAttribute("gamma", 3)); + QVERIFY(f.context.setWindowAttribute("alpha", 4)); + f.changeHandle(1); + QCOMPARE(f.context.keys(), QStringList({"beta", "gamma", "alpha"})); + QCOMPARE(f.context.calls.last().value.toInt(), 4); + for (const auto &call : f.context.calls) + QVERIFY(!call.oldValue.isValid()); + + f.context.rejectedKeys << "beta"; + QVERIFY(!f.context.setWindowAttribute("beta", 5)); + f.context.rejectedKeys.clear(); + QVERIFY(f.context.setWindowAttribute("gamma", {})); + f.context.calls.clear(); + f.changeHandle(2); + QCOMPARE(f.context.keys(), QStringList({"beta", "alpha"})); + QCOMPARE(f.context.calls.first().value.toInt(), 2); // Rejected write did not reorder/update. + QCOMPARE(f.context.calls.last().value.toInt(), 4); + } + + void rejectedReplayRemovesOnlyFailedAttributes() { + Fixture f; + QVERIFY(f.context.setWindowAttribute("alpha", 1)); + QVERIFY(f.context.setWindowAttribute("beta", 2)); + QVERIFY(f.context.setWindowAttribute("gamma", 3)); + f.context.rejectedKeys << "alpha" << "beta"; // Adjacent failures exercise iterator removal. + f.changeHandle(1); + QCOMPARE(f.context.keys(), QStringList({"alpha", "beta", "gamma"})); + QVERIFY(!f.context.windowAttribute("alpha").isValid()); + QVERIFY(!f.context.windowAttribute("beta").isValid()); + QCOMPARE(f.context.windowAttribute("gamma").toInt(), 3); + f.context.calls.clear(); + f.changeHandle(2); + QCOMPARE(f.context.keys(), QStringList({"gamma"})); + f.context.rejectedKeys.clear(); + QVERIFY(f.context.setWindowAttribute("alpha", 4)); + QCOMPARE(f.context.windowAttribute("alpha").toInt(), 4); + f.context.calls.clear(); + f.changeHandle(3); + QCOMPARE(f.context.keys(), QStringList({"gamma", "alpha"})); + } + + void handleLossAndUnchangedHandle() { + Fixture f; + QVERIFY(f.context.setWindowAttribute("alpha", 1)); + f.changeHandle(1); + QCOMPARE(f.context.handles.size(), 1); + QCOMPARE(f.context.handles.last(), qMakePair(WId(1), WId(0))); + f.context.calls.clear(); + f.changeHandle(1); + QVERIFY(f.context.calls.isEmpty()); + QCOMPARE(f.context.handles.size(), 1); + f.changeHandle(0); + QCOMPARE(f.context.handles.last(), qMakePair(WId(0), WId(1))); + QVERIFY(f.context.calls.isEmpty()); + QVERIFY(f.context.setWindowAttribute("alpha", 2)); + QVERIFY(f.context.calls.isEmpty()); + f.changeHandle(1); // Handle values may be reused after destruction. + QCOMPARE(f.context.handles.size(), 3); + QCOMPARE(f.context.keys(), QStringList({"alpha"})); + QCOMPARE(f.context.calls.first().value.toInt(), 2); + QVERIFY(!f.context.calls.first().oldValue.isValid()); + } + + void replacingTitleClearsRegistrations() { + Fixture f; + Item title, replacement, button, excluded; + QVERIFY(f.context.setTitleBar(&title)); + QVERIFY(f.context.setSystemButton(Button::Close, &button)); + QVERIFY(f.context.setHitTestVisible(&excluded, true)); + QVERIFY(!f.context.setTitleBar(&title)); + QCOMPARE(f.context.systemButton(Button::Close), &button); + QVERIFY(f.context.isHitTestVisible(&excluded)); + QVERIFY(f.context.setTitleBar(&replacement)); + QCOMPARE(f.context.titleBar(), &replacement); + QVERIFY(!f.context.systemButton(Button::Close)); + QVERIFY(!f.context.isHitTestVisible(&excluded)); + } + + void destroyedItemsStopAffectingHitTests() { + Fixture f; + auto title = std::make_unique(); + auto button = std::make_unique(); + auto excluded = std::make_unique(); + const QPoint point(10, 10); + QVERIFY(f.context.setTitleBar(title.get())); + QVERIFY(f.context.setSystemButton(Button::Close, button.get())); + QVERIFY(!f.context.isInTitleBarDraggableArea(point)); + button.reset(); + QVERIFY(!f.context.systemButton(Button::Close)); + QVERIFY(f.context.isInTitleBarDraggableArea(point)); + QVERIFY(f.context.setHitTestVisible(excluded.get(), true)); + QVERIFY(!f.context.isInTitleBarDraggableArea(point)); + excluded.reset(); + QVERIFY(f.context.isInTitleBarDraggableArea(point)); + title.reset(); + QVERIFY(!f.context.titleBar()); + QVERIFY(!f.context.isInTitleBarDraggableArea(point)); + } + + void titleAndExclusionVisibility() { + Fixture f; + Item title, excluded; + const QPoint point(10, 10); + QVERIFY(!f.context.isInTitleBarDraggableArea(point)); + QVERIFY(f.context.setTitleBar(&title)); + QVERIFY(f.context.isInTitleBarDraggableArea(point)); + QVERIFY(!f.context.isInTitleBarDraggableArea(QPoint(210, 10))); + title.visible = false; + QVERIFY(!f.context.isInTitleBarDraggableArea(point)); + title.visible = true; + title.enabled = false; + QVERIFY(!f.context.isInTitleBarDraggableArea(point)); + title.enabled = true; + title.rect.moveTopLeft(QPoint(400, 400)); + QVERIFY(!f.context.isInTitleBarDraggableArea(QPoint(410, 410))); + title.rect.moveTopLeft(QPoint(0, 0)); + excluded.rect = QRect(0, 0, 20, 20); + QVERIFY(f.context.setHitTestVisible(&excluded, true)); + QVERIFY(f.context.setHitTestVisible(&excluded, true)); + QVERIFY(!f.context.isInTitleBarDraggableArea(point)); + QVERIFY(f.context.isInTitleBarDraggableArea(QPoint(30, 10))); + excluded.visible = false; + QVERIFY(f.context.isInTitleBarDraggableArea(point)); + excluded.visible = true; + QVERIFY(f.context.setHitTestVisible(&excluded, false)); + QVERIFY(!f.context.isHitTestVisible(&excluded)); + QVERIFY(f.context.isInTitleBarDraggableArea(point)); + } + + void systemButtonVisibilityAndPriority() { + Fixture f; + Item title, minimize, close; + QVERIFY(f.context.setTitleBar(&title)); + QVERIFY(f.context.setSystemButton(Button::Close, &close)); + QVERIFY(f.context.setSystemButton(Button::Minimize, &minimize)); + Button::SystemButton role = Button::Unknown; + QVERIFY(f.context.isInSystemButtons(QPoint(10, 10), &role)); + QCOMPARE(role, Button::Minimize); // Enum order wins for overlapping buttons. + minimize.visible = false; + QVERIFY(f.context.isInSystemButtons(QPoint(10, 10), &role)); + QCOMPARE(role, Button::Close); + close.enabled = false; + QVERIFY(!f.context.isInSystemButtons(QPoint(10, 10), &role)); + QCOMPARE(role, Button::Unknown); + QVERIFY(f.context.isInTitleBarDraggableArea(QPoint(10, 10))); + close.enabled = true; + QVERIFY(!f.context.isInTitleBarDraggableArea(QPoint(10, 10))); + QVERIFY(f.context.setSystemButton(Button::Close, nullptr)); + QVERIFY(!f.context.setSystemButton(Button::Close, nullptr)); + QVERIFY(f.context.isInTitleBarDraggableArea(QPoint(10, 10))); + } + + void fixedSizeConstraints_data() { + QTest::addColumn("mode"); + QTest::addColumn("widthFixed"); + QTest::addColumn("heightFixed"); + QTest::newRow("free") << QStringLiteral("free") << false << false; + QTest::newRow("width") << QStringLiteral("width") << true << false; + QTest::newRow("height") << QStringLiteral("height") << false << true; + QTest::newRow("both") << QStringLiteral("both") << true << true; + QTest::newRow("dialog-flag") << QStringLiteral("flag") << true << true; + QTest::newRow("no-window") << QStringLiteral("no-window") << false << false; + } + + void fixedSizeConstraints() { + QFETCH(QString, mode); + QFETCH(bool, widthFixed); + QFETCH(bool, heightFixed); + Fixture f; + if (mode == "width" || mode == "both") { + f.window.setMinimumWidth(300); + f.window.setMaximumWidth(300); + } + if (mode == "height" || mode == "both") { + f.window.setMinimumHeight(200); + f.window.setMaximumHeight(200); + } + if (mode == "flag") + f.window.setFlags(f.window.flags() | Qt::MSWindowsFixedSizeDialogHint); + if (mode == "no-window") { + f.delegate->host = nullptr; + f.context.notifyWinIdChange(); + } + QCOMPARE(f.context.isHostWidthFixed(), widthFixed); + QCOMPARE(f.context.isHostHeightFixed(), heightFixed); + QCOMPARE(f.context.isHostSizeFixed(), widthFixed && heightFixed); + } +}; + +int main(int argc, char **argv) { + QGuiApplication app(argc, argv); + WindowContextTest test; + return QTest::qExec(&test, argc, argv); +} + +#include "tst_windowcontext.moc" From 06a38fce0fd1908c83c221ba111d7b566aba9b43 Mon Sep 17 00:00:00 2001 From: wangwenx190 <2546789017@qq.com> Date: Tue, 22 Sep 2026 16:51:06 +0800 Subject: [PATCH 15/56] test(core): isolate and verify StyleAgent notifications Add nine business cases for StyleAgent's platform-independent state and notification behavior, registered only when StyleAgent is enabled. Compile the unchanged production styleagent.cpp and its moc output into the test because StyleAgentPrivate notification methods are not exported. Replace only platform subscription hooks with deterministic local counters and avoid linking another StyleAgent implementation from QWKCore. This keeps tests independent of the user's actual theme, registry and native notification delivery without changing production exports or behavior. Assert initial unknown/invalid values, transitions to every theme, duplicate notification suppression, valid and invalid accent colors, observable state at signal delivery, reentrant theme changes, and balanced hook lifetime across multiple agents. Use Qt Test and QSignalSpy without additional mocking libraries or external services. Validation on the completed series, Windows/MSVC with Qt 6.12.0: - Release and Debug passed all 11 Qt Test entries, including lifecycle entries, with no failures or skips. - Twenty consecutive Release runs passed. - A StyleAgent-disabled Core-only build correctly omitted this suite. OS theme detection and native subscription delivery remain outside this unit suite. Other platforms and hosted CI were not executed locally. --- tests/unit/CMakeLists.txt | 15 ++++ tests/unit/tst_styleagent.cpp | 131 ++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 tests/unit/tst_styleagent.cpp diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index 9d2eed0b..4dac6e97 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -26,3 +26,18 @@ endfunction() qwk_add_unit_test(tst_eventdispatch core.eventdispatch.unit 24) qwk_add_unit_test(tst_windowcontext core.windowcontext.unit 17) + +if(QWINDOWKIT_ENABLE_STYLE_AGENT) + # StyleAgentPrivate is not exported. Compile the real notification implementation into + # this test, with deterministic platform-hook substitutes, without linking QWKCore. + add_executable(tst_styleagent tst_styleagent.cpp + ../../src/core/style/styleagent.cpp ../../src/core/style/styleagent.h) + set_target_properties(tst_styleagent PROPERTIES AUTOMOC ON) + target_compile_features(tst_styleagent PRIVATE cxx_std_17) + target_compile_definitions(tst_styleagent PRIVATE QWK_CORE_STATIC) + target_include_directories(tst_styleagent PRIVATE + "$") + target_link_libraries(tst_styleagent PRIVATE Qt${QT_VERSION_MAJOR}::Gui Qt${QT_VERSION_MAJOR}::Test) + add_dependencies(tst_styleagent QWKCore) + qwk_register_fast_test(tst_styleagent core.styleagent.unit 11) +endif() diff --git a/tests/unit/tst_styleagent.cpp b/tests/unit/tst_styleagent.cpp new file mode 100644 index 00000000..81c005e2 --- /dev/null +++ b/tests/unit/tst_styleagent.cpp @@ -0,0 +1,131 @@ +// Copyright (C) 2023-present Stdware Collections (https://www.github.com/stdware) +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include + +namespace { + int setupCalls = 0; + int removalCalls = 0; + + class Agent : public QWK::StyleAgent { + public: + QWK::StyleAgentPrivate &state() { return *d_ptr; } + }; +} + +// Only the OS subscription boundary is replaced. Theme/color setters, getters, signals, +// constructors and destructors are compiled from the unchanged production styleagent.cpp. +// This test does not modify or depend on the user's actual theme or registry. +namespace QWK { + void StyleAgentPrivate::setupSystemThemeHook() { ++setupCalls; } + void StyleAgentPrivate::removeSystemThemeHook() { ++removalCalls; } +} + +class StyleAgentTest : public QObject { + Q_OBJECT +private Q_SLOTS: + void init() { setupCalls = removalCalls = 0; } + + void initialState() { + Agent agent; + QCOMPARE(agent.systemTheme(), Agent::Unknown); + QVERIFY(!agent.systemAccentColor().isValid()); + QSignalSpy theme(&agent, &Agent::systemThemeChanged); + QSignalSpy color(&agent, &Agent::systemAccentColorChanged); + QVERIFY(theme.isValid()); + QVERIFY(color.isValid()); + agent.state().notifyThemeChanged(Agent::Unknown); + agent.state().notifyAccentColorChanged({}); + QCOMPARE(theme.count(), 0); + QCOMPARE(color.count(), 0); + } + + void themeChanges_data() { + QTest::addColumn("theme"); + QTest::newRow("light") << int(Agent::Light); + QTest::newRow("dark") << int(Agent::Dark); + QTest::newRow("high-contrast") << int(Agent::HighContrast); + QTest::newRow("unknown") << int(Agent::Unknown); + } + + void themeChanges() { + QFETCH(int, theme); + Agent agent; + const auto target = static_cast(theme); + agent.state().notifyThemeChanged(target == Agent::Unknown ? Agent::Light : Agent::Unknown); + QSignalSpy changed(&agent, &Agent::systemThemeChanged); + agent.state().notifyThemeChanged(target); + QCOMPARE(changed.count(), 1); + QCOMPARE(agent.systemTheme(), target); + agent.state().notifyThemeChanged(target); + QCOMPARE(changed.count(), 1); + } + + void colorChangesIncludingInvalid() { + Agent agent; + QSignalSpy changed(&agent, &Agent::systemAccentColorChanged); + const QColor red(Qt::red), blue(Qt::blue); + agent.state().notifyAccentColorChanged(red); + QCOMPARE(changed.count(), 1); + QCOMPARE(agent.systemAccentColor(), red); + agent.state().notifyAccentColorChanged(red); + QCOMPARE(changed.count(), 1); + agent.state().notifyAccentColorChanged(blue); + QCOMPARE(changed.count(), 2); + QCOMPARE(agent.systemAccentColor(), blue); + agent.state().notifyAccentColorChanged({}); + QCOMPARE(changed.count(), 3); + QVERIFY(!agent.systemAccentColor().isValid()); + agent.state().notifyAccentColorChanged({}); + QCOMPARE(changed.count(), 3); + } + + void signalsObserveNewValues() { + Agent agent; + Agent::SystemTheme observedTheme = Agent::Unknown; + QColor observedColor; + connect(&agent, &Agent::systemThemeChanged, &agent, + [&] { observedTheme = agent.systemTheme(); }); + connect(&agent, &Agent::systemAccentColorChanged, &agent, + [&] { observedColor = agent.systemAccentColor(); }); + agent.state().notifyThemeChanged(Agent::Dark); + QCOMPARE(observedTheme, Agent::Dark); + agent.state().notifyAccentColorChanged(Qt::green); + QCOMPARE(observedColor, QColor(Qt::green)); + } + + void reentrantNotifications() { + Agent agent; + QList themes; + connect(&agent, &Agent::systemThemeChanged, &agent, [&] { + themes.append(int(agent.systemTheme())); + if (agent.systemTheme() == Agent::Light) + agent.state().notifyThemeChanged(Agent::Dark); + }); + agent.state().notifyThemeChanged(Agent::Light); + QCOMPARE(themes, QList({int(Agent::Light), int(Agent::Dark)})); + QCOMPARE(agent.systemTheme(), Agent::Dark); + agent.state().notifyThemeChanged(Agent::Dark); + QCOMPARE(themes.size(), 2); + } + + void hookLifetime() { + { + Agent first; + QCOMPARE(setupCalls, 1); + QCOMPARE(removalCalls, 0); + { + Agent second; + QCOMPARE(setupCalls, 2); + QCOMPARE(removalCalls, 0); + } + QCOMPARE(removalCalls, 1); + } + QCOMPARE(removalCalls, 2); + } +}; + +QTEST_GUILESS_MAIN(StyleAgentTest) +#include "tst_styleagent.moc" From 23ab77a81f850322bddab4f87514689d55294890 Mon Sep 17 00:00:00 2001 From: wangwenx190 <2546789017@qq.com> Date: Tue, 22 Sep 2026 16:51:12 +0800 Subject: [PATCH 16/56] test: cover Widgets and Quick window agent contracts Add the agents.unit suite with five business cases per enabled UI module. Register and link each module conditionally, adjusting the expected pass count for Widgets-only, Quick-only or combined builds. Omit the target when neither UI module is enabled. Use real public agents, delegates and base context state with the existing context factory seam to bypass native hooks and border installation. Restore the factory on exit and keep all windows hidden under offscreen QPA; no desktop input, rendering waits or network access is needed. Verify rejection of repeated setup on the same or another window, title-bar signal counts and arguments, state visibility during signal delivery, registration preservation on identical title assignment and cleanup on replacement. Check registration/replacement/removal signals for all five system-button roles, hit-test exclusion toggles, and queries and subsequent registration after referenced objects have been destroyed. Validation on the completed series, Windows/MSVC with Qt 6.12.0: - Combined Widgets/Quick Release and Debug runs passed all ten business cases plus initialization and cleanup, with no failures or skips. - Twenty consecutive Release runs passed. - A Core-only static build correctly omitted this target. The individual Widgets-only/Quick-only combinations and other platforms were not run locally. Native hooks, real dragging and compositor output remain the responsibility of separate integration tests. --- tests/unit/CMakeLists.txt | 19 +++ tests/unit/tst_windowagents.cpp | 243 ++++++++++++++++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 tests/unit/tst_windowagents.cpp diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index 4dac6e97..bac50160 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -41,3 +41,22 @@ if(QWINDOWKIT_ENABLE_STYLE_AGENT) add_dependencies(tst_styleagent QWKCore) qwk_register_fast_test(tst_styleagent core.styleagent.unit 11) endif() + +if(QWINDOWKIT_BUILD_WIDGETS OR QWINDOWKIT_BUILD_QUICK) + set(_agent_passes 2) # Qt Test initialization and cleanup. + if(QWINDOWKIT_BUILD_WIDGETS) + math(EXPR _agent_passes "${_agent_passes} + 5") + endif() + if(QWINDOWKIT_BUILD_QUICK) + math(EXPR _agent_passes "${_agent_passes} + 5") + endif() + qwk_add_unit_test(tst_windowagents agents.unit ${_agent_passes}) + if(QWINDOWKIT_BUILD_WIDGETS) + target_link_libraries(tst_windowagents PRIVATE QWKWidgets) + target_compile_definitions(tst_windowagents PRIVATE TEST_WIDGETS) + endif() + if(QWINDOWKIT_BUILD_QUICK) + target_link_libraries(tst_windowagents PRIVATE QWKQuick) + target_compile_definitions(tst_windowagents PRIVATE TEST_QUICK) + endif() +endif() diff --git a/tests/unit/tst_windowagents.cpp b/tests/unit/tst_windowagents.cpp new file mode 100644 index 00000000..e6c75c78 --- /dev/null +++ b/tests/unit/tst_windowagents.cpp @@ -0,0 +1,243 @@ +// Copyright (C) 2023-present Stdware Collections (https://www.github.com/stdware) +// SPDX-License-Identifier: Apache-2.0 + +#include +#include + +#include +#include +#include +#ifdef TEST_WIDGETS +# include +# include +#endif +#ifdef TEST_QUICK +# include +# include +#endif + +namespace { + using Button = QWK::WindowAgentBase; + + // Exercise real public agents, delegates and base context state without installing any + // native hooks, showing a window, or asking the OS to draw a border. + class UnitContext : public QWK::AbstractWindowContext { + protected: + void winIdChanged(WId, WId) override {} + }; + + class FactoryGuard { + public: + FactoryGuard() : previous(QWK::WindowAgentBasePrivate::windowContextFactoryMethod) { + QWK::WindowAgentBasePrivate::windowContextFactoryMethod = []() -> QWK::AbstractWindowContext * { + return new UnitContext; + }; + } + ~FactoryGuard() { QWK::WindowAgentBasePrivate::windowContextFactoryMethod = previous; } + private: + QWK::WindowAgentBasePrivate::WindowContextFactoryMethod previous; + }; + +#ifdef TEST_WIDGETS + auto titleSignal(const QWK::WidgetWindowAgent &) { return &QWK::WidgetWindowAgent::titleBarChanged; } +#endif +#ifdef TEST_QUICK + auto titleSignal(const QWK::QuickWindowAgent &) { return &QWK::QuickWindowAgent::titleBarWidgetChanged; } +#endif + + void agentRows() { + QTest::addColumn("quick"); +#ifdef TEST_WIDGETS + QTest::newRow("widgets") << false; +#endif +#ifdef TEST_QUICK + QTest::newRow("quick") << true; +#endif + } + + template + void withAgent(bool quick, Callback callback) { + Q_UNUSED(quick) +#ifdef TEST_WIDGETS + if (!quick) { + QWidget window; + QWK::WidgetWindowAgent agent; + callback(window, agent); + } +#endif +#ifdef TEST_QUICK + if (quick) { + QQuickWindow window; + QWK::QuickWindowAgent agent; + callback(window, agent); + } +#endif + } +} + +class WindowAgentsTest : public QObject { + Q_OBJECT +private Q_SLOTS: + void initTestCase() { + qRegisterMetaType("SystemButton"); + qRegisterMetaType("QWK::WindowAgentBase::SystemButton"); + QCOMPARE(QGuiApplication::platformName(), QStringLiteral("offscreen")); + } + + void setupIsSingleUse_data() { agentRows(); } + void setupIsSingleUse() { + QFETCH(bool, quick); + withAgent(quick, [](auto &window, auto &agent) { + using Window = std::decay_t; + using Item = std::remove_pointer_t; + Window other; + Item title; + QVERIFY(agent.setup(&window)); + QVERIFY(!agent.titleBar()); + agent.setTitleBar(&title); + QVERIFY(!agent.setup(&window)); + QVERIFY(!agent.setup(&other)); + QCOMPARE(agent.titleBar(), &title); + QVERIFY(!window.isVisible()); + QVERIFY(!other.isVisible()); + }); + } + + void titleSignalsAndReset_data() { agentRows(); } + void titleSignalsAndReset() { + QFETCH(bool, quick); + withAgent(quick, [](auto &window, auto &agent) { + using Item = std::remove_pointer_t; + QVERIFY(agent.setup(&window)); + Item first, second, button, excluded; + QSignalSpy changed(&agent, titleSignal(agent)); + QVERIFY(changed.isValid()); + Item *lastSignalItem = nullptr; + bool stateReadyAtSignal = true; + QObject::connect(&agent, titleSignal(agent), &agent, [&](Item *item) { + lastSignalItem = item; + stateReadyAtSignal = stateReadyAtSignal && agent.titleBar() == item; + }); + agent.setTitleBar(&first); + QCOMPARE(changed.count(), 1); + QCOMPARE(lastSignalItem, &first); + agent.setSystemButton(Button::Close, &button); + agent.setHitTestVisible(&excluded); + agent.setTitleBar(&first); + QCOMPARE(changed.count(), 1); + QCOMPARE(agent.systemButton(Button::Close), &button); + QVERIFY(agent.isHitTestVisible(&excluded)); + agent.setTitleBar(&second); + QCOMPARE(changed.count(), 2); + QCOMPARE(lastSignalItem, &second); + QVERIFY(stateReadyAtSignal); + QCOMPARE(agent.titleBar(), &second); + QVERIFY(!agent.systemButton(Button::Close)); + QVERIFY(!agent.isHitTestVisible(&excluded)); + }); + } + + void systemButtonSignals_data() { agentRows(); } + void systemButtonSignals() { + QFETCH(bool, quick); + withAgent(quick, [](auto &window, auto &agent) { + using Agent = std::decay_t; + using Item = std::remove_pointer_t; + QVERIFY(agent.setup(&window)); + Item first, second; + QSignalSpy changed(&agent, &Agent::systemButtonChanged); + QVERIFY(changed.isValid()); + Button::SystemButton lastRole = Button::Unknown; + Item *lastItem = nullptr; + bool stateReadyAtSignal = true; + QObject::connect(&agent, &Agent::systemButtonChanged, &agent, + [&](Button::SystemButton role, Item *item) { + lastRole = role; + lastItem = item; + stateReadyAtSignal = stateReadyAtSignal && agent.systemButton(role) == item; + }); + for (auto role : {Button::WindowIcon, Button::Help, Button::Minimize, + Button::Maximize, Button::Close}) { + const int before = changed.count(); + agent.setSystemButton(role, &first); + QCOMPARE(lastRole, role); + QCOMPARE(lastItem, &first); + agent.setSystemButton(role, &first); + QCOMPARE(changed.count(), before + 1); + agent.setSystemButton(role, &second); + QCOMPARE(agent.systemButton(role), &second); + QCOMPARE(lastItem, &second); + QCOMPARE(changed.count(), before + 2); + agent.setSystemButton(role, nullptr); + QVERIFY(!agent.systemButton(role)); + QVERIFY(!lastItem); + QCOMPARE(changed.count(), before + 3); + agent.setSystemButton(role, nullptr); + QCOMPARE(changed.count(), before + 3); + } + QVERIFY(stateReadyAtSignal); + }); + } + + void hitTestVisibilityToggles_data() { agentRows(); } + void hitTestVisibilityToggles() { + QFETCH(bool, quick); + withAgent(quick, [](auto &window, auto &agent) { + using Item = std::remove_pointer_t; + QVERIFY(agent.setup(&window)); + Item first, second; + QVERIFY(!agent.isHitTestVisible(&first)); + agent.setHitTestVisible(&first); + agent.setHitTestVisible(&first); + agent.setHitTestVisible(&second); + QVERIFY(agent.isHitTestVisible(&first)); + agent.setHitTestVisible(&first, false); + QVERIFY(!agent.isHitTestVisible(&first)); + QVERIFY(agent.isHitTestVisible(&second)); + agent.setHitTestVisible(&first, false); + agent.setHitTestVisible(&first, true); + QVERIFY(agent.isHitTestVisible(&first)); + }); + } + + void destroyedRegistrations_data() { agentRows(); } + void destroyedRegistrations() { + QFETCH(bool, quick); + withAgent(quick, [](auto &window, auto &agent) { + using Item = std::remove_pointer_t; + QVERIFY(agent.setup(&window)); + auto title = std::make_unique(); + auto button = std::make_unique(); + auto excluded = std::make_unique(); + agent.setTitleBar(title.get()); + agent.setSystemButton(Button::Close, button.get()); + agent.setHitTestVisible(excluded.get()); + button.reset(); + QVERIFY(!agent.systemButton(Button::Close)); + excluded.reset(); + title.reset(); + QVERIFY(!agent.titleBar()); + Item replacement; + agent.setTitleBar(&replacement); + QCOMPARE(agent.titleBar(), &replacement); + QVERIFY(!agent.isHitTestVisible(&replacement)); + agent.setHitTestVisible(&replacement); + QVERIFY(agent.isHitTestVisible(&replacement)); + agent.setHitTestVisible(&replacement, false); + QVERIFY(!agent.isHitTestVisible(&replacement)); + }); + } +}; + +int main(int argc, char **argv) { +#ifdef TEST_WIDGETS + QApplication app(argc, argv); +#else + QGuiApplication app(argc, argv); +#endif + FactoryGuard guard; + WindowAgentsTest test; + return QTest::qExec(&test, argc, argv); +} + +#include "tst_windowagents.moc" From 8abd403240ff2ae4fb67fb87b6251f42c71aa56c Mon Sep 17 00:00:00 2001 From: wangwenx190 <2546789017@qq.com> Date: Tue, 22 Sep 2026 16:51:19 +0800 Subject: [PATCH 17/56] ci: require fast unit suites and document offline execution Extend the existing matrix and MinGW registration checks to require the new event-dispatch, window-context, StyleAgent and agent suites, plus the existing Quick geometry suite on Windows. Anchor CTest selection patterns, use literal name checks and report missing required tests consistently. The normal build/test steps execute the suites; preserve existing workflow triggers and avoid adding a duplicate test run. Document how to configure with local dependencies, build the dedicated qwk_fast_unit_tests target and select only fast tests through CTest labels. Describe the Qt-only dependencies, offline/no-desktop execution, bounded timeouts, report locations, strict pass/skip checks, conditional suites and the distinction between compilation time and test execution time. Record the 56 newly added business cases and 68 total fast cases, explain which tests use platform substitutes, and identify native interactions that the suite does not claim to cover. Keep the local audit reports out of the implementation commits. Validation on Windows/MSVC with Qt 6.12.0: - All five fast suites passed in Release (~0.20 seconds) and Debug (~0.28 seconds), including CTest and process startup. - Twenty consecutive runs per Release suite passed: 100 process runs in approximately 3.46 seconds, with no skips. - Core-only static configuration passed both applicable suites (~0.08 seconds) with UI modules, StyleAgent and installation disabled and the Qt fallback selected. - The full Release regression passed all 15 registered CTest entries. - git diff --check passed. qmake was unavailable in this local shell; other platforms and hosted GitHub Actions were not executed. --- .github/workflows/ci.yml | 19 +++++----- tests/unit/README.md | 79 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 9 deletions(-) create mode 100644 tests/unit/README.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19f32c06..6cc0d553 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,18 +97,19 @@ jobs: # The build system tests register themselves only where the configure found the tool they # drive, which is the one way this job could go green while covering less than it says. A # missing qmake or nmake is meant to be a skip on a developer's machine and a failure here. - - name: Check that the consumer tests registered + # The fast unit suites are also required, so accidental conditional exclusions fail CI. + - name: Check that the required tests registered shell: bash run: | - expected="buildsystems.cmake buildsystems.qmake" + expected="buildsystems.cmake buildsystems.qmake core.eventdispatch.unit core.windowcontext.unit core.styleagent.unit agents.unit" if [ "$RUNNER_OS" = "Windows" ]; then - expected="$expected buildsystems.msbuild" + expected="$expected buildsystems.msbuild quickgeometry.unit" fi for name in $expected; do - if ! ctest --test-dir build -N -R "$name" | grep -q "$name"; then - echo "$name did not register, so the tool it drives was not found here" + if ! ctest --test-dir build -N -R "^${name}$" | grep -Fq "$name"; then + echo "Required test $name did not register" exit 1 fi done @@ -213,12 +214,12 @@ jobs: # The same question the matrix jobs ask. MSBuild is not part of this toolchain, so only the # qmake half is expected here. - - name: Check that the consumer tests registered + - name: Check that the required tests registered shell: msys2 {0} run: | - for name in buildsystems.cmake buildsystems.qmake; do - if ! ctest --test-dir build -N -R "$name" | grep -q "$name"; then - echo "$name did not register, so the tool it drives was not found here" + for name in buildsystems.cmake buildsystems.qmake core.eventdispatch.unit core.windowcontext.unit core.styleagent.unit agents.unit quickgeometry.unit; do + if ! ctest --test-dir build -N -R "^${name}$" | grep -Fq "$name"; then + echo "Required test $name did not register" exit 1 fi done diff --git a/tests/unit/README.md b/tests/unit/README.md new file mode 100644 index 00000000..3e486fe6 --- /dev/null +++ b/tests/unit/README.md @@ -0,0 +1,79 @@ +# Fast, offline unit tests + +These tests use only Qt Test, Qt Core/Gui (and Widgets/Quick when enabled), the +production QWindowKit code, and the existing CMake/CTest build tools. Test execution +does not download anything, access the network, install packages, show windows, +inject desktop input, or require a display server. No Python, browser, external +mocking library, commercial tool, or additional test framework is required. + +## Run + +In an existing build with `QWINDOWKIT_BUILD_TESTS=ON`: + +```sh +cmake --build build --config Release --target qwk_fast_unit_tests --parallel 4 +ctest --test-dir build -C Release -L fast --output-on-failure --no-tests=error +``` + +For a fresh build, first make Qt (including Qt Test), the compiler, CMake and the +repository submodules available locally. For example, configure with a local Qt +prefix and enable Quick to include all suites: + +```sh +cmake -S . -B build -DCMAKE_PREFIX_PATH= -DQWINDOWKIT_BUILD_TESTS=ON -DQWINDOWKIT_BUILD_QUICK=ON +``` + +The `qwk_fast_unit_tests` target builds just the fast tests and their library +dependencies. `-L fast` runs only these tests: it does not run the slower installation, +consumer-build, native-window or rendering tests. Normal unfiltered CTest runs also +include the fast tests. Use `--output-junit fast-unit.xml` for a combined report; +each process also writes Qt Test text/XML reports inside its test build directory. + +Compilation time is separate from test execution time. Each test process has an +8-second execution deadline, with a 10-second outer CTest timeout. There are no +sleeps, polling loops or asynchronous GUI waits. The runner requires the expected +pass count and rejects skips/expected failures, including accidental loss of data +rows. The expected counts include Qt Test initialization and cleanup; the business +case counts below do not. + +## Coverage + +| CTest name | Business cases | Checks | +| --- | ---: | --- | +| `core.eventdispatch.unit` | 22 | Shared/native dispatch arguments, result forwarding, ordering, short-circuit consumption, duplicate/foreign registration, transfer, removal and destruction during dispatch, appending filters, nested dispatch, dispatcher/filter destruction order, application-wide native filter cleanup/reinstallation | +| `core.windowcontext.unit` | 15 | Attribute CRUD, rejected writes/deletes, replay order, adjacent replay failures, handle loss/reuse, title replacement, destroyed objects, visibility/exclusions/button priority and fixed-size constraints | +| `core.styleagent.unit` | 9 | Theme/color state, duplicate notification suppression, invalid colors, signal-time values, reentrant notification and hook lifetime | +| `agents.unit` | 5 per enabled UI module | Widgets/Quick setup rejection, title replacement/reset, signal counts/arguments/state, all system button roles, exclusion toggles and destroyed registrations | +| `quickgeometry.unit` (existing, Windows + Quick) | 12 | Quick transforms, precise containment, fractional bounds, singular transforms and dynamic geometry | + +With Widgets, Quick and StyleAgent enabled on Windows there are 68 business cases, +including 56 new cases, across five test processes. Core suites are available even +when Widgets and Quick are disabled. The StyleAgent suite is omitted when that +component is disabled. The existing geometry suite retains its Windows registration +condition; the new platform-independent suites are registered on all platforms. + +The dispatch, context and agent tests link the production libraries. Context tests +substitute platform inputs and record callbacks; they do not reimplement attribute +storage, replay or hit-test decisions. Agent tests use the existing context factory +to bypass native hooks while testing real public methods and delegates. StyleAgent's +private notifications are not exported, so its test compiles the unchanged production +`styleagent.cpp` and moc output with deterministic platform subscription substitutes; +it does not link a second copy of StyleAgent from QWKCore. + +These suites do not verify real native handle recreation, OS theme subscription, +dragging/resizing, compositor output or physical monitor/DPI changes. Those require +separate integration tests. In particular, a fake handle transition verifies the +base context's replay contract, not a Win32/Cocoa/X11/Wayland implementation. + +## Local verification (2026-09-22) + +On Windows with Qt 6.12.0/MSVC, all five suites passed in Release (about 0.20 seconds +total, including CTest/process startup) and Debug (about 0.28 seconds). Twenty +consecutive runs per Release suite passed with no skips: 100 process runs in about +3.46 seconds. A Core-only static build with Widgets, Quick and StyleAgent disabled, +installation disabled and the Qt fallback selected also passed both applicable suites +in about 0.08 seconds. The full existing plus new Release regression passed 15/15 +CTest entries. Other platforms and hosted CI have not been executed locally. + +These timings are observations, not hardware-independent guarantees. CI requires +registration of the applicable fast suites before its normal test run. From 041a862ed97a6baa7a257eba66c9a8f0f9015745 Mon Sep 17 00:00:00 2001 From: wangwenx190 <2546789017@qq.com> Date: Tue, 22 Sep 2026 17:03:18 +0800 Subject: [PATCH 18/56] test: bound build consumers and tighten integration timeouts The install fixture and CMake, qmake and MSBuild consumer tests had no explicit CTest TIMEOUT. Their shared consumer driver also launched build tools without an execute_process deadline, so a stalled tool could hold the test run for an excessive amount of time. Give installation a 15-second CTest limit and each build consumer a 60-second total limit. Require a positive, bounded timeout argument in the shared consumer helper: use 25 seconds for configure/build/MSBuild, 15 seconds for qmake, and 25 seconds for make. Bound the configure-time qmake prefix query to five seconds and report failed or timed-out queries instead of silently continuing with incomplete discovery results. Reduce the QML installation consumer from 180 to 60 seconds overall. Use separate budgets for installation (10 seconds), configure/build (20 seconds each), runtime (8 seconds) and lint (5 seconds per invocation). Treat nonnumeric timeout/launch results as failures before interpreting lint JSON, and compare process results as strings where timeout diagnostics may be returned in place of an exit code. Reduce each Quick border test from 60 to 20 seconds at CTest level and from 45 to 15 seconds for its executable. Reduce native hit testing from 120 to 45 seconds overall and from 100 to 40 seconds for the driver. Preserve existing native fixture, message and cleanup deadlines. Keep the fast unit tests at their existing 8-second process/10-second CTest limits. No product code, external dependencies or network access is added. Validation on Windows/MSVC with Qt 6.12.0: - All 17 registered Release tests in the completed change passed in approximately 21.92 seconds, including the newly enabled local qmake consumer and the timeout-policy check added in the follow-up commit. - A deliberately sleeping consumer command with a one-second budget was terminated and reported failure after approximately 1.03 seconds. - Inspected every main-project execute_process call and CTest deadline; git diff --check passed. Other platforms and hosted CI have not been run with these tighter budgets. Independent qmsetup/stdcorelib submodule tests already have their own registered timeouts and are not modified here. --- tests/buildsystems/CMakeLists.txt | 15 ++++++++----- tests/buildsystems/cmake/RunBuild.cmake | 4 ++-- tests/buildsystems/msbuild/RunBuild.cmake | 2 +- tests/buildsystems/qmake/RunBuild.cmake | 4 ++-- .../testing/ConsumerBuildCommon.cmake | 10 ++++++--- tests/qmltypes/CMakeLists.txt | 2 +- tests/qmltypes/RunInstalled.cmake | 22 ++++++++++++------- tests/quickborder/CMakeLists.txt | 4 ++-- tests/quickborder/RunTest.cmake | 2 +- tests/windows/hittest/CMakeLists.txt | 2 +- tests/windows/hittest/RunTest.cmake | 2 +- 11 files changed, 42 insertions(+), 27 deletions(-) diff --git a/tests/buildsystems/CMakeLists.txt b/tests/buildsystems/CMakeLists.txt index b591ffe4..8e331fb2 100644 --- a/tests/buildsystems/CMakeLists.txt +++ b/tests/buildsystems/CMakeLists.txt @@ -20,7 +20,7 @@ add_test(NAME buildsystems.install COMMAND ${CMAKE_COMMAND} --install ${PROJECT_BINARY_DIR} --prefix ${_prefix} --config $ ) -set_tests_properties(buildsystems.install PROPERTIES FIXTURES_SETUP qwk_install_tree) +set_tests_properties(buildsystems.install PROPERTIES FIXTURES_SETUP qwk_install_tree TIMEOUT 15) set(_modules core) @@ -56,8 +56,13 @@ if(_qmake_executable) COMMAND ${_qmake_executable} -query QT_INSTALL_PREFIX OUTPUT_VARIABLE _qt_prefix OUTPUT_STRIP_TRAILING_WHITESPACE - ERROR_QUIET + ERROR_VARIABLE _qt_query_error + RESULT_VARIABLE _qt_query_result + TIMEOUT 5 ) + if(NOT "${_qt_query_result}" STREQUAL "0") + message(FATAL_ERROR "qmake prefix query failed (${_qt_query_result}): ${_qt_query_error}") + endif() elseif(Qt${QT_VERSION_MAJOR}_DIR) # /lib/cmake/Qt get_filename_component(_qt_prefix "${Qt${QT_VERSION_MAJOR}_DIR}/../../.." ABSOLUTE) @@ -88,7 +93,7 @@ if(_qmake_executable AND QWINDOWKIT_TEST_MAKE_PROGRAM) -D "QWK_MODULES=${_modules}" -P "${CMAKE_CURRENT_SOURCE_DIR}/qmake/RunBuild.cmake" ) - set_tests_properties(buildsystems.qmake PROPERTIES FIXTURES_REQUIRED qwk_install_tree) + set_tests_properties(buildsystems.qmake PROPERTIES FIXTURES_REQUIRED qwk_install_tree TIMEOUT 60) else() message(STATUS "qmake or a make tool was not found, skipping the qmake build system test") endif() @@ -133,7 +138,7 @@ if(QWINDOWKIT_TEST_MSBUILD_EXECUTABLE AND _platform AND _qt_prefix) -D "EXPECT_STATIC=${QWINDOWKIT_BUILD_STATIC}" -P "${CMAKE_CURRENT_SOURCE_DIR}/msbuild/RunBuild.cmake" ) - set_tests_properties(buildsystems.msbuild PROPERTIES FIXTURES_REQUIRED qwk_install_tree) + set_tests_properties(buildsystems.msbuild PROPERTIES FIXTURES_REQUIRED qwk_install_tree TIMEOUT 60) elseif(MSVC) message(STATUS "MSBuild or the Qt prefix was not found, skipping the MSBuild build system test") endif() @@ -157,7 +162,7 @@ if(_qt_prefix) -D "USE_WIDGETS=${_use_widgets}" -P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/RunBuild.cmake" ) - set_tests_properties(buildsystems.cmake PROPERTIES FIXTURES_REQUIRED qwk_install_tree) + set_tests_properties(buildsystems.cmake PROPERTIES FIXTURES_REQUIRED qwk_install_tree TIMEOUT 60) else() message(STATUS "The Qt prefix was not found, skipping the CMake build system test") endif() diff --git a/tests/buildsystems/cmake/RunBuild.cmake b/tests/buildsystems/cmake/RunBuild.cmake index a53eba6b..183a17ae 100644 --- a/tests/buildsystems/cmake/RunBuild.cmake +++ b/tests/buildsystems/cmake/RunBuild.cmake @@ -23,7 +23,7 @@ if(MAKE_PROGRAM) list(APPEND _toolchain_args "-DCMAKE_MAKE_PROGRAM=${MAKE_PROGRAM}") endif() -qwk_run_step("cmake configure" "${CMAKE_COMMAND}" +qwk_run_step("cmake configure" 25 "${CMAKE_COMMAND}" -S "${SOURCE_DIR}" -B "${WORK_DIR}/build" ${_toolchain_args} @@ -33,7 +33,7 @@ qwk_run_step("cmake configure" "${CMAKE_COMMAND}" "-DQWK_USE_WIDGETS=${USE_WIDGETS}" ) -qwk_run_step("cmake build" "${CMAKE_COMMAND}" +qwk_run_step("cmake build" 25 "${CMAKE_COMMAND}" --build "${WORK_DIR}/build" --config "${BUILD_CONFIG}" ) diff --git a/tests/buildsystems/msbuild/RunBuild.cmake b/tests/buildsystems/msbuild/RunBuild.cmake index ab41dfe7..6e9e5fa5 100644 --- a/tests/buildsystems/msbuild/RunBuild.cmake +++ b/tests/buildsystems/msbuild/RunBuild.cmake @@ -29,7 +29,7 @@ foreach(_option USE_QUICK EXPECT_STATIC) endif() endforeach() -qwk_run_step("msbuild" "${MSBUILD_EXECUTABLE}" "${PROJECT_FILE}" +qwk_run_step("msbuild" 25 "${MSBUILD_EXECUTABLE}" "${PROJECT_FILE}" /nologo /verbosity:minimal "/p:Configuration=${BUILD_CONFIG}" diff --git a/tests/buildsystems/qmake/RunBuild.cmake b/tests/buildsystems/qmake/RunBuild.cmake index bda71710..40ef0d7f 100644 --- a/tests/buildsystems/qmake/RunBuild.cmake +++ b/tests/buildsystems/qmake/RunBuild.cmake @@ -17,10 +17,10 @@ file(MAKE_DIRECTORY "${WORK_DIR}") # qmake takes a list as a whitespace separated value. string(REPLACE ";" " " _modules "${QWK_MODULES}") -qwk_run_step("qmake" "${QMAKE_EXECUTABLE}" "${PROJECT_FILE}" +qwk_run_step("qmake" 15 "${QMAKE_EXECUTABLE}" "${PROJECT_FILE}" "QWK_PREFIX=${INSTALL_PREFIX}" "QWK_MODULES=${_modules}" "CONFIG+=${BUILD_CONFIG}" ) -qwk_run_step("make" "${MAKE_PROGRAM}") +qwk_run_step("make" 25 "${MAKE_PROGRAM}") diff --git a/tests/buildsystems/testing/ConsumerBuildCommon.cmake b/tests/buildsystems/testing/ConsumerBuildCommon.cmake index 24434a71..03397691 100644 --- a/tests/buildsystems/testing/ConsumerBuildCommon.cmake +++ b/tests/buildsystems/testing/ConsumerBuildCommon.cmake @@ -20,18 +20,22 @@ endfunction() carrying the command and its output. The output is held back until then, since a passing test that prints a whole build log buries the ones that did not pass. - qwk_run_step(