From de3753ab33e3481d23138b8980b747850588f7ec Mon Sep 17 00:00:00 2001 From: nigelfenton Date: Wed, 28 Oct 2026 18:11:33 -0400 Subject: [PATCH] fix(gui): the Windows title bar answers as the caption, so Snap works again (#6825). Principle VIII. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #6825. ## What On Windows with the expanded client area, `MainWindow::nativeEvent` answered `WM_NCHITTEST` with the resize band at the edges and `HTCLIENT` everywhere else, so the 52 px title bar never reported `HTCAPTION`. Windows keys drag-to-edge Snap and the Snap Assist picker off that answer, so neither was offered: dragging the window to a screen edge did nothing. `expandedFrameHit()` gains a `Caption` hit and an opt-in `captionHeight`. `nativeEvent` passes the bar's height, scaled to the physical pixels `WM_NCHITTEST` works in, but only when `TitleBar::isSystemMoveAreaAt()` says the cursor is over the bar's empty background — the same test the bar's own drag already uses, so a point either starts a system move or stays client, never both. Answering `HTCAPTION` over the tabs, menus and caption buttons is what closed menus as they opened (#6272), and those keep reporting `HTCLIENT`. `captionHeight` defaults to 0, so every other caller is unchanged. `isSystemMoveAreaAt()` has been public and uncalled since #6198 removed the custom frame; this restores the `HTCAPTION` clause that #3069 added and #6198 dropped. ## Measured on Windows 11 (26300), AetherSDR 26.10.4, Qt 6.12 Hit test down the centre of the title bar, before and after: | offset from window top | before | after | Notepad (control) | |---|---|---|---| | +0..+7 px | HTTOP | HTTOP | HTTOP | | +8..+43 px | HTCLIENT | **HTCAPTION** | HTCAPTION | | +44 px and below | HTCLIENT | HTCLIENT | HTCLIENT | Scanning across the bar after the fix, the ☰ menu, the tab strip's controls and the caption buttons all still report `HTCLIENT`; only the empty background between them reports `HTCAPTION`. `WS_MAXIMIZEBOX` was already set (`applyWindowsCaptionStyles`), which is why Win+Left/Right always worked while the mouse drag did not — keyboard snap bypasses hit-testing. ## Tests `unified_title_bar_test` gains 10 assertions: the caption strip, its lower boundary, the resize band and corners still winning over it, the maximized case (the caption must answer or a maximized window cannot be dragged free), and that a 0 `captionHeight` leaves the old answer untouched. Verified they fail when the caption clause is disabled (3 fail, the rest pass). The suite's pre-existing "four radios fit whole in an 1800 px bar" failure on this machine is a font-metrics issue, confirmed identical on unmodified main. Co-Authored-By: Claude Opus 5 --- src/gui/MainWindow.cpp | 17 +++++++++-- src/gui/WindowChrome.h | 52 ++++++++++++++++++++++---------- tests/unified_title_bar_test.cpp | 50 ++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 18 deletions(-) diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index 41394bc923..4feb1dfb16 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -3876,6 +3876,7 @@ static LRESULT frameHitCode(WindowChrome::FrameHit hit) case WindowChrome::FrameHit::TopRight: return HTTOPRIGHT; case WindowChrome::FrameHit::BottomLeft: return HTBOTTOMLEFT; case WindowChrome::FrameHit::BottomRight: return HTBOTTOMRIGHT; + case WindowChrome::FrameHit::Caption: return HTCAPTION; case WindowChrome::FrameHit::Client: break; } return HTCLIENT; @@ -3887,7 +3888,8 @@ bool MainWindow::nativeEvent(const QByteArray& eventType, void* message, qintptr // Under the expanded client area Qt 6.12 answers WM_NCHITTEST by turning // the live mouse-button state into synthetic presses, which doubles real // clicks and closes menus as they open (#6272). Answer it here instead: - // the resize band at the edges, client area everywhere else. + // the resize band at the edges, the title bar's own background as the + // caption so Windows offers Snap (#6825), client area everywhere else. if (msg && result && msg->message == WM_NCHITTEST && windowFlags().testFlag(Qt::ExpandedClientAreaHint)) { RECT window{}; @@ -3903,7 +3905,18 @@ bool MainWindow::nativeEvent(const QByteArray& eventType, void* message, qintptr const QPoint point(GET_X_LPARAM(msg->lParam), GET_Y_LPARAM(msg->lParam)); const QRect rect(window.left, window.top, window.right - window.left, window.bottom - window.top); - *result = frameHitCode(WindowChrome::expandedFrameHit(point, rect, borderX, borderY)); + // The bar's height is in logical pixels and WM_NCHITTEST is physical. + // Only the bar's empty background may answer HTCAPTION: over its tabs, + // menus and buttons that is what closed menus as they opened (#6272), + // and isSystemMoveAreaAt() is the same test the bar's own drag uses, so + // a point either starts a system move or stays client — never both. + int captionHeight = 0; + if (m_titleBar && m_titleBar->isVisible() + && m_titleBar->isSystemMoveAreaAt(QCursor::pos())) { + captionHeight = int(m_titleBar->height() * devicePixelRatioF()); + } + *result = frameHitCode( + WindowChrome::expandedFrameHit(point, rect, borderX, borderY, captionHeight)); return true; } // The whole window is client area, inset only while maximized so nothing diff --git a/src/gui/WindowChrome.h b/src/gui/WindowChrome.h index dd5331edd6..90274beac1 100644 --- a/src/gui/WindowChrome.h +++ b/src/gui/WindowChrome.h @@ -90,27 +90,47 @@ inline bool claimsWholeWindowAsClient(Qt::WindowFlags flags, const QOperatingSys // live mouse-button state into synthetic presses, doubling real clicks (#6272). // Physical pixels; `borderX` / `borderY` are the resize bands on the left/right // and top/bottom edges (0 while maximized). -enum class FrameHit { Client, Left, Right, Top, Bottom, TopLeft, TopRight, BottomLeft, BottomRight }; - -inline FrameHit expandedFrameHit(const QPoint& point, const QRect& window, int borderX, int borderY) +// +// `Caption` is the title bar's own draggable background: the answer Windows +// needs before it will offer drag-to-edge Snap and list the window in the Snap +// Assist picker (#6825). Only the caller knows which points are the bar's empty +// background rather than its tabs, menus and buttons, so this reports Caption +// for the whole `captionHeight` strip and MainWindow::nativeEvent narrows it +// with TitleBar::isSystemMoveAreaAt() — answering HTCAPTION over an interactive +// widget is what made menus close as they opened (#6272). A `captionHeight` of +// 0 disables it, which is how every non-main window keeps the old behaviour. +enum class FrameHit { Client, Caption, Left, Right, Top, Bottom, TopLeft, TopRight, BottomLeft, BottomRight }; + +inline FrameHit expandedFrameHit(const QPoint& point, const QRect& window, int borderX, int borderY, + int captionHeight = 0) { - if (borderX <= 0 || borderY <= 0 || !window.contains(point)) { + if (!window.contains(point)) { return FrameHit::Client; } - const bool left = point.x() < window.left() + borderX; - const bool right = point.x() > window.right() - borderX; - const bool top = point.y() < window.top() + borderY; - const bool bottom = point.y() > window.bottom() - borderY; - if (left) { - return top ? FrameHit::TopLeft : bottom ? FrameHit::BottomLeft : FrameHit::Left; - } - if (right) { - return top ? FrameHit::TopRight : bottom ? FrameHit::BottomRight : FrameHit::Right; + // Maximized and fullscreen have no resize band, but the caption still has + // to answer or the window cannot be dragged back off the top of the screen. + if (borderX > 0 && borderY > 0) { + const bool left = point.x() < window.left() + borderX; + const bool right = point.x() > window.right() - borderX; + const bool top = point.y() < window.top() + borderY; + const bool bottom = point.y() > window.bottom() - borderY; + if (left) { + return top ? FrameHit::TopLeft : bottom ? FrameHit::BottomLeft : FrameHit::Left; + } + if (right) { + return top ? FrameHit::TopRight : bottom ? FrameHit::BottomRight : FrameHit::Right; + } + if (top) { + return FrameHit::Top; + } + if (bottom) { + return FrameHit::Bottom; + } } - if (top) { - return FrameHit::Top; + if (captionHeight > 0 && point.y() < window.top() + captionHeight) { + return FrameHit::Caption; } - return bottom ? FrameHit::Bottom : FrameHit::Client; + return FrameHit::Client; } // Windows: the native window rect that gives `client`, keeping the frame the diff --git a/tests/unified_title_bar_test.cpp b/tests/unified_title_bar_test.cpp index abe4ba527b..e4e7bb5cfa 100644 --- a/tests/unified_title_bar_test.cpp +++ b/tests/unified_title_bar_test.cpp @@ -204,6 +204,56 @@ int main(int argc, char** argv) "the left/right band and the top/bottom band follow their own widths"); } + // ── The title bar answers as the caption so Windows offers Snap (#6825) ── + { + using Hit = WindowChrome::FrameHit; + const QRect win(100, 50, 1000, 600); + const int band = 8; + const int caption = 52; + + // Without a caption height nothing changes: every existing caller + // (and every window that is not the main window) keeps the old answer. + check(WindowChrome::expandedFrameHit(QPoint(600, 70), win, band, band) == Hit::Client, + "the title bar is client area when no caption height is given"); + + // The bar's background drags the window, which is what Windows needs + // before it offers drag-to-edge Snap and the Snap Assist picker. + check(WindowChrome::expandedFrameHit(QPoint(600, 70), win, band, band, caption) + == Hit::Caption, + "the title bar's background is the caption"); + check(WindowChrome::expandedFrameHit(QPoint(600, 101), win, band, band, caption) + == Hit::Caption, + "the last pixel of the caption strip still drags"); + check(WindowChrome::expandedFrameHit(QPoint(600, 102), win, band, band, caption) + == Hit::Client, + "the first pixel below the caption is client area"); + + // The resize band wins over the caption, or the top edge and the top + // corners would stop resizing the moment the caption was introduced. + check(WindowChrome::expandedFrameHit(QPoint(600, 50), win, band, band, caption) == Hit::Top, + "the top resize band still wins over the caption"); + check(WindowChrome::expandedFrameHit(QPoint(101, 51), win, band, band, caption) + == Hit::TopLeft + && WindowChrome::expandedFrameHit(QPoint(1098, 51), win, band, band, caption) + == Hit::TopRight, + "the top corners still resize under the caption"); + check(WindowChrome::expandedFrameHit(QPoint(101, 70), win, band, band, caption) + == Hit::Left, + "the side band still resizes alongside the caption"); + + // Maximized: no resize band, but the caption must still answer or the + // window cannot be dragged back down off the top of the screen. + check(WindowChrome::expandedFrameHit(QPoint(600, 70), win, 0, 0, caption) == Hit::Caption, + "the caption answers while maximized, so the window can be dragged free"); + check(WindowChrome::expandedFrameHit(QPoint(600, 350), win, 0, 0, caption) == Hit::Client, + "the body is still client area while maximized"); + + // A point outside the window is never the caption. + check(WindowChrome::expandedFrameHit(QPoint(20, 20), win, band, band, caption) + == Hit::Client, + "a point outside the window is not the caption"); + } + // ── Native client-rect save/restore for the Windows expanded frame (#6303) ── { // Rects measured on Windows 10: the expanded frame keeps an 8 px resize