From de3753ab33e3481d23138b8980b747850588f7ec Mon Sep 17 00:00:00 2001 From: nigelfenton Date: Wed, 28 Oct 2026 18:11:33 -0400 Subject: [PATCH 1/5] 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 From 6cfac077a22382e6e04841e37ee2071901a1f1ed Mon Sep 17 00:00:00 2001 From: nigelfenton Date: Mon, 2 Nov 2026 23:39:36 -0500 Subject: [PATCH 2/5] fix(gui): the caption hit test answers for the queried point, not the cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WM_NCHITTEST carries the screen coordinate being asked about, and the shell (or WindowFromPoint) may query it while the pointer is somewhere else. The caption clause took its point from msg->lParam but gated on isSystemMoveAreaAt(QCursor::pos()), so a query over the menu button could answer HTCAPTION and a query over draggable background could answer HTCLIENT, depending only on where the pointer happened to be. Convert the message's physical screen point into Qt's logical global space through the window's own two rects — frameGeometry() against GetWindowRect — and ask about that. Going through the rects keeps whatever rounding the platform applied instead of re-deriving it from a ratio that need not divide evenly at fractional scaling. Also document drag-to-edge Snap in the Windows user guide, keeping the maximize-hover flyout (#6224) listed as the separate limitation it is, and narrow the claim that caption hit testing is what lists the window in the Snap Assist picker — that half of #6825 did not reproduce here. Reported by @rfoust in review of #6830. Co-Authored-By: Claude Opus 5 --- docs/user/docs/windows.md | 1 + src/gui/MainWindow.cpp | 7 +++++- src/gui/WindowChrome.h | 37 ++++++++++++++++++++++++++------ tests/unified_title_bar_test.cpp | 36 +++++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 8 deletions(-) diff --git a/docs/user/docs/windows.md b/docs/user/docs/windows.md index 1cb69fb6dc..b582613f3e 100644 --- a/docs/user/docs/windows.md +++ b/docs/user/docs/windows.md @@ -49,6 +49,7 @@ There is no built-in DAX audio driver on Windows. The DAX applet says so: "No bu - **Menus** are behind the **☰** button at the left of the title bar. Alt+letter mnemonics (Alt+F, Alt+S, …) still open each menu. F11 toggles full screen. See [Menu Reference](./menu-reference.md). - **Radio Setup** is **Settings → Radio Setup...**. - **Window frame:** a Qt-drawn frame with the system shadow and resize borders, with AetherSDR's own minimize, maximize and close buttons. +- **Snap:** drag the title bar to a screen edge to snap the window to that half or corner, the same as any other window; Windows then offers Snap Assist to fill the rest of the screen. **Win+Left**, **Win+Right** and **Win+Up** snap it from the keyboard. Dragging works on the title bar's empty background — over the **☰** button, the radio tabs and the caption buttons a drag does what that control does instead. Hovering over the maximize button does not show the Snap Layouts flyout ([#6224](https://github.com/aethersdr/AetherSDR/issues/6224)). - **GPU choice on hybrid laptops:** the discrete GPU is used by default. The integrated GPU is listed in **Display → SYSTEM → GPU:** but grayed out ("disabled (#1921)"), because it crashes when a panadapter is popped out or docked. See [GPU Rendering](./gpu-rendering.md). - **Noise reduction:** **BNR** (NVIDIA Maxine, on an NVIDIA RTX/GeForce GPU) is available on Windows; its runtime comes as a single self-contained download. **MNR** is macOS only and is grayed out. See [BNR GPU Noise Removal](./bnr-gpu-noise-removal.md) and [DSP Noise Mitigation](./dsp-noise-mitigation.md). - **Copy Assist** can use a **Vulkan** GPU (NVIDIA, AMD or Intel). See [Copy Assist](./copy-assist.md). diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index 4feb1dfb16..7e016ac753 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -3910,9 +3910,14 @@ bool MainWindow::nativeEvent(const QByteArray& eventType, void* message, qintptr // 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. + // + // Ask about the point in the message, not QCursor::pos(): WM_NCHITTEST + // answers for the coordinate it carries, which WindowFromPoint() and + // the shell may query while the pointer is somewhere else entirely. int captionHeight = 0; if (m_titleBar && m_titleBar->isVisible() - && m_titleBar->isSystemMoveAreaAt(QCursor::pos())) { + && m_titleBar->isSystemMoveAreaAt( + WindowChrome::logicalScreenPoint(point, rect, frameGeometry()))) { captionHeight = int(m_titleBar->height() * devicePixelRatioF()); } *result = frameHitCode( diff --git a/src/gui/WindowChrome.h b/src/gui/WindowChrome.h index 90274beac1..c8d21f912c 100644 --- a/src/gui/WindowChrome.h +++ b/src/gui/WindowChrome.h @@ -92,15 +92,38 @@ inline bool claimsWholeWindowAsClient(Qt::WindowFlags flags, const QOperatingSys // and top/bottom edges (0 while maximized). // // `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. +// needs before it will offer drag-to-edge Snap (#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 }; +// WM_NCHITTEST carries the screen point being asked about in physical pixels, +// while Qt's widget geometry — and so TitleBar::isSystemMoveAreaAt() — is in +// logical ones. Converting through the window's own two rects keeps the +// rounding the platform actually applied, rather than re-deriving it from a +// device pixel ratio that may not divide evenly (#6825). +// +// `physical` and `windowPhysical` come from WM_NCHITTEST and GetWindowRect; +// `windowLogical` is the same window as Qt sees it (QWidget::frameGeometry()). +// The answer is the matching point in Qt's global logical space, so the +// caption test asks about the point Windows asked about rather than wherever +// the pointer happens to be. +inline QPoint logicalScreenPoint(const QPoint& physical, const QRect& windowPhysical, + const QRect& windowLogical) +{ + if (windowPhysical.width() <= 0 || windowPhysical.height() <= 0) { + return physical; + } + const qreal sx = qreal(windowLogical.width()) / qreal(windowPhysical.width()); + const qreal sy = qreal(windowLogical.height()) / qreal(windowPhysical.height()); + return QPoint(windowLogical.left() + qRound((physical.x() - windowPhysical.left()) * sx), + windowLogical.top() + qRound((physical.y() - windowPhysical.top()) * sy)); +} + inline FrameHit expandedFrameHit(const QPoint& point, const QRect& window, int borderX, int borderY, int captionHeight = 0) { diff --git a/tests/unified_title_bar_test.cpp b/tests/unified_title_bar_test.cpp index e4e7bb5cfa..aa5151addb 100644 --- a/tests/unified_title_bar_test.cpp +++ b/tests/unified_title_bar_test.cpp @@ -254,6 +254,42 @@ int main(int argc, char** argv) "a point outside the window is not the caption"); } + // ── WM_NCHITTEST answers for the point it carries, not the cursor (#6825) ── + { + // The message's coordinate is physical; isSystemMoveAreaAt() needs Qt's + // logical global space. Converting through the window's own two rects + // keeps whatever rounding the platform applied. + const QRect physical(200, 100, 2000, 1200); // GetWindowRect + const QRect logical(100, 50, 1000, 600); // QWidget::frameGeometry() + + check(WindowChrome::logicalScreenPoint(physical.topLeft(), physical, logical) + == logical.topLeft(), + "the window origin maps to the window origin"); + check(WindowChrome::logicalScreenPoint(QPoint(1200, 700), physical, logical) + == QPoint(600, 350), + "a point at DPR 2 halves its offset from the window origin"); + + // A fractional ratio (150% scaling) must not drift: 2000/1333 is not 2. + const QRect physical150(0, 0, 1333, 800); + const QRect logical150(0, 0, 889, 533); + const QPoint mid = WindowChrome::logicalScreenPoint(QPoint(666, 400), + physical150, logical150); + check(mid.x() >= 443 && mid.x() <= 446 && mid.y() >= 265 && mid.y() <= 268, + "a non-integer scale maps the midpoint to the midpoint"); + + // Degenerate rects must not divide by zero. + check(WindowChrome::logicalScreenPoint(QPoint(7, 9), QRect(), logical) == QPoint(7, 9), + "an empty window rect returns the point unchanged"); + + // The whole point of the conversion: two different physical points in + // the same message stream map to two different logical points, so the + // caption test can distinguish them while the cursor sits elsewhere. + const QPoint a = WindowChrome::logicalScreenPoint(QPoint(400, 160), physical, logical); + const QPoint b = WindowChrome::logicalScreenPoint(QPoint(1800, 160), physical, logical); + check(a != b && a.y() == b.y(), + "two queried points stay distinct after conversion"); + } + // ── Native client-rect save/restore for the Windows expanded frame (#6303) ── { // Rects measured on Windows 10: the expanded frame keeps an 8 px resize From 0fe4e6865c5e1d3737b235f86cf4de23017a41ed Mon Sep 17 00:00:00 2001 From: nigelfenton Date: Tue, 3 Nov 2026 01:32:36 -0500 Subject: [PATCH 3/5] fix(gui): tooltipped title-bar drag handles stay client, so their hover text survives (#6825) Over a caption Windows sends WM_NCMOUSEMOVE, which Qt delivers as NonClientAreaMouseMove and which never starts the tooltip timer. The caption gate used isSystemMoveAreaAt(), so every drag handle answered HTCAPTION -- including the three that carry a tooltip: the brand mark ("AetherSDR"), the transmit timer, and the EXPERIMENTAL radio label. Their hover text would have stopped appearing on Windows. Measured on Windows 11 before the change: at every HTCAPTION point the tooltip was suppressed, and at every HTCLIENT point over a tooltipped widget it appeared -- so the mechanism is real, not theoretical. isCaptionMoveAreaAt() is isSystemMoveAreaAt() minus those handles. It is used only by the Windows caption gate, so the bar's own startSystemMove() is unchanged and the Linux drag is untouched; those labels still move the window, they just do not start a Snap drag. 48% of the bar still answers HTCAPTION, including one contiguous 392 px run, so drag-to-edge Snap has ample target. Found in review by rfoust. Co-Authored-By: Claude Opus 5 --- docs/user/docs/windows.md | 2 +- src/gui/MainWindow.cpp | 6 ++++- src/gui/TitleBar.cpp | 17 +++++++++++++ src/gui/TitleBar.h | 8 ++++++ tests/unified_title_bar_test.cpp | 42 ++++++++++++++++++++++++++++++++ 5 files changed, 73 insertions(+), 2 deletions(-) diff --git a/docs/user/docs/windows.md b/docs/user/docs/windows.md index b582613f3e..bf1e0c0f64 100644 --- a/docs/user/docs/windows.md +++ b/docs/user/docs/windows.md @@ -49,7 +49,7 @@ There is no built-in DAX audio driver on Windows. The DAX applet says so: "No bu - **Menus** are behind the **☰** button at the left of the title bar. Alt+letter mnemonics (Alt+F, Alt+S, …) still open each menu. F11 toggles full screen. See [Menu Reference](./menu-reference.md). - **Radio Setup** is **Settings → Radio Setup...**. - **Window frame:** a Qt-drawn frame with the system shadow and resize borders, with AetherSDR's own minimize, maximize and close buttons. -- **Snap:** drag the title bar to a screen edge to snap the window to that half or corner, the same as any other window; Windows then offers Snap Assist to fill the rest of the screen. **Win+Left**, **Win+Right** and **Win+Up** snap it from the keyboard. Dragging works on the title bar's empty background — over the **☰** button, the radio tabs and the caption buttons a drag does what that control does instead. Hovering over the maximize button does not show the Snap Layouts flyout ([#6224](https://github.com/aethersdr/AetherSDR/issues/6224)). +- **Snap:** drag the title bar to a screen edge to snap the window to that half or corner, the same as any other window; Windows then offers Snap Assist to fill the rest of the screen. **Win+Left**, **Win+Right** and **Win+Up** snap it from the keyboard. Dragging works on the title bar's empty background — over the **☰** button, the radio tabs and the caption buttons a drag does what that control does instead. The logo and the labels that have their own hover text still move the window, but do not start a snap; drag from the empty background to snap. Hovering over the maximize button does not show the Snap Layouts flyout ([#6224](https://github.com/aethersdr/AetherSDR/issues/6224)). - **GPU choice on hybrid laptops:** the discrete GPU is used by default. The integrated GPU is listed in **Display → SYSTEM → GPU:** but grayed out ("disabled (#1921)"), because it crashes when a panadapter is popped out or docked. See [GPU Rendering](./gpu-rendering.md). - **Noise reduction:** **BNR** (NVIDIA Maxine, on an NVIDIA RTX/GeForce GPU) is available on Windows; its runtime comes as a single self-contained download. **MNR** is macOS only and is grayed out. See [BNR GPU Noise Removal](./bnr-gpu-noise-removal.md) and [DSP Noise Mitigation](./dsp-noise-mitigation.md). - **Copy Assist** can use a **Vulkan** GPU (NVIDIA, AMD or Intel). See [Copy Assist](./copy-assist.md). diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index 7e016ac753..b5bff72c8f 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -3914,9 +3914,13 @@ bool MainWindow::nativeEvent(const QByteArray& eventType, void* message, qintptr // Ask about the point in the message, not QCursor::pos(): WM_NCHITTEST // answers for the coordinate it carries, which WindowFromPoint() and // the shell may query while the pointer is somewhere else entirely. + // + // isCaptionMoveAreaAt() is isSystemMoveAreaAt() minus the drag handles + // that carry a tooltip: a caption gets WM_NCMOUSEMOVE, which never + // starts Qt's tooltip timer, so those would lose their hover text. int captionHeight = 0; if (m_titleBar && m_titleBar->isVisible() - && m_titleBar->isSystemMoveAreaAt( + && m_titleBar->isCaptionMoveAreaAt( WindowChrome::logicalScreenPoint(point, rect, frameGeometry()))) { captionHeight = int(m_titleBar->height() * devicePixelRatioF()); } diff --git a/src/gui/TitleBar.cpp b/src/gui/TitleBar.cpp index 3a258e3f59..14c4b3690a 100644 --- a/src/gui/TitleBar.cpp +++ b/src/gui/TitleBar.cpp @@ -841,6 +841,23 @@ bool TitleBar::isSystemMoveAreaAt(const QPoint& globalPos) const return false; } +bool TitleBar::isCaptionMoveAreaAt(const QPoint& globalPos) const +{ + if (!isSystemMoveAreaAt(globalPos)) { + return false; + } + // A tooltip on the widget under the point (or on the drag handle holding + // it) would never appear once Windows treats this as caption, so leave + // those points client. The bare background has no child and no tooltip. + for (QWidget* widget = childAt(mapFromGlobal(globalPos)); widget && widget != this; + widget = widget->parentWidget()) { + if (!widget->toolTip().isEmpty()) { + return false; + } + } + return true; +} + bool TitleBar::startWindowMove(QMouseEvent* ev) { if (!ev || ev->button() != Qt::LeftButton) diff --git a/src/gui/TitleBar.h b/src/gui/TitleBar.h index b412c75eb0..2660d8b05b 100644 --- a/src/gui/TitleBar.h +++ b/src/gui/TitleBar.h @@ -122,6 +122,14 @@ class TitleBar : public QWidget { bool isSystemMoveAreaAt(const QPoint& globalPos) const; + // Windows only: the subset of isSystemMoveAreaAt() that may answer + // HTCAPTION. Over a caption Windows sends WM_NCMOUSEMOVE, which Qt + // delivers as NonClientAreaMouseMove and which never starts the tooltip + // timer, so a drag handle carrying a tooltip would silently lose its + // hover text (#6830 review). Those keep HTCLIENT and still drag through + // the bar's own startSystemMove(); they just do not start a Snap drag. + bool isCaptionMoveAreaAt(const QPoint& globalPos) const; + // ── Radio tabs ────────────────────────────────────────────────────────── // The strip of per-radio tabs plus the "+" discovered-radios popover. // MainWindow feeds these from discovery + the active connection. diff --git a/tests/unified_title_bar_test.cpp b/tests/unified_title_bar_test.cpp index aa5151addb..95d578493b 100644 --- a/tests/unified_title_bar_test.cpp +++ b/tests/unified_title_bar_test.cpp @@ -290,6 +290,48 @@ int main(int argc, char** argv) "two queried points stay distinct after conversion"); } + // ── A tooltipped drag handle stays client, so its tooltip survives ────── + // Over a caption Windows sends WM_NCMOUSEMOVE, which Qt delivers as + // NonClientAreaMouseMove; that never starts the tooltip timer. Measured on + // Windows 11: every HTCAPTION point suppressed its tooltip, every HTCLIENT + // point showed it. So isCaptionMoveAreaAt() excludes the drag handles that + // carry one, while isSystemMoveAreaAt() — the bar's own drag, shared with + // Linux — still accepts them. (#6830 review) + { + QWidget tipHost; + auto* tipBar = new TitleBar(&tipHost); + tipHost.resize(1400, 200); + tipHost.show(); + app.processEvents(); + + tipBar->setOperatorTransmitting(true); + app.processEvents(); + + auto* timer = tipBar->findChild(QStringLiteral("txTimerLabel")); + auto* gutter = tipBar->findChild(QStringLiteral("titleBarDragGutter")); + check(timer && gutter, "tooltip probe finds the transmit timer and the drag gutter"); + if (timer && gutter && timer->isVisible()) { + check(!timer->toolTip().isEmpty(), "the transmit timer carries a tooltip"); + check(gutter->toolTip().isEmpty(), "the drag gutter carries no tooltip"); + + const QPoint onTimer = timer->mapToGlobal(timer->rect().center()); + const QPoint onGutter = gutter->mapToGlobal(gutter->rect().center()); + + // Both are drag handles: the bar's own drag accepts either. + check(tipBar->isSystemMoveAreaAt(onTimer), + "the transmit timer is still a drag handle for the bar's own move"); + check(tipBar->isSystemMoveAreaAt(onGutter), + "the drag gutter is a drag handle"); + + // Only the tooltip-free one may answer HTCAPTION. + check(!tipBar->isCaptionMoveAreaAt(onTimer), + "a tooltipped drag handle does not become the caption, so its tooltip shows"); + check(tipBar->isCaptionMoveAreaAt(onGutter), + "the bare drag gutter still becomes the caption, so Snap works"); + } + tipBar->setOperatorTransmitting(false); + } + // ── Native client-rect save/restore for the Windows expanded frame (#6303) ── { // Rects measured on Windows 10: the expanded frame keeps an 8 px resize From a9d3f96a1901cafdef397ebb158ef167a931e408 Mon Sep 17 00:00:00 2001 From: nigelfenton Date: Tue, 3 Nov 2026 15:49:43 -0500 Subject: [PATCH 4/5] fix(gui): the caption hit test anchors on the client rect, not the window rect (#6825) 6cfac077 mapped the WM_NCHITTEST point by scaling GetWindowRect onto Qt's frameGeometry(). Those are not the same area: GetWindowRect carries the invisible DWM resize border and Qt's frame geometry does not. Measured here, a 1414 px window rect around a 1401 px client, 6 px of it on the left; while maximized the window rect also overhangs the screen. Every mapped point was therefore skewed by that inset, which pushed the HTCLIENT run left of the control it was meant to cover: the right half of the menu button answered HTCAPTION, so a press there started a window move instead of opening the menu. That is the #6272 class this change set out to avoid, and it made real-pointer hit-testing less accurate than the QCursor::pos() gate it replaced. logicalScreenPoint() now takes the client origin and the widget origin -- two descriptions of the same area, which cannot skew -- plus the device pixel ratio. captionHeightForHitTest() hoists the gate out of nativeEvent so the wiring is testable, and measures the caption from the client top rather than from window.top, which while maximized sits above it. Verified against the app's own geometry over the automation bridge: the menu button reports logical rect x=16 w=32, and at this box's 0.85 device pixel ratio the HTCLIENT run is physical client 14..40 -- an exact match, restored and maximized alike. Tests: the suite previously could not see this, because every case paired two rects of the same area. Cases now use the real Windows 11 geometry, and captionHeightForHitTest() records which point the gate asks about. Re-introducing the border skew fails 7 assertions; making the gate ignore the message point (what a revert to QCursor::pos() would do) fails 1. Also asserts the transmit timer's visibility instead of folding it into a guard, so the tooltip checks cannot silently skip. Found in review by NF0T. Co-Authored-By: Claude Opus 5 --- src/gui/MainWindow.cpp | 17 +++-- src/gui/WindowChrome.h | 62 ++++++++++++---- tests/unified_title_bar_test.cpp | 123 ++++++++++++++++++++++++------- 3 files changed, 156 insertions(+), 46 deletions(-) diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index b5bff72c8f..ae31add956 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -3905,6 +3905,11 @@ 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); + // The client area's top-left in screen pixels. GetWindowRect includes + // the invisible DWM resize border and Qt's geometry does not, so the + // caption gate anchors here instead — see logicalScreenPoint(). + POINT clientOrigin{0, 0}; + ClientToScreen(msg->hwnd, &clientOrigin); // 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), @@ -3918,12 +3923,12 @@ bool MainWindow::nativeEvent(const QByteArray& eventType, void* message, qintptr // isCaptionMoveAreaAt() is isSystemMoveAreaAt() minus the drag handles // that carry a tooltip: a caption gets WM_NCMOUSEMOVE, which never // starts Qt's tooltip timer, so those would lose their hover text. - int captionHeight = 0; - if (m_titleBar && m_titleBar->isVisible() - && m_titleBar->isCaptionMoveAreaAt( - WindowChrome::logicalScreenPoint(point, rect, frameGeometry()))) { - captionHeight = int(m_titleBar->height() * devicePixelRatioF()); - } + const bool barShown = m_titleBar && m_titleBar->isVisible(); + const int captionHeight = WindowChrome::captionHeightForHitTest( + point, window.top, QPoint(clientOrigin.x, clientOrigin.y), + mapToGlobal(QPoint(0, 0)), barShown ? m_titleBar->height() : 0, + devicePixelRatioF(), + [this](const QPoint& logical) { return m_titleBar->isCaptionMoveAreaAt(logical); }); *result = frameHitCode( WindowChrome::expandedFrameHit(point, rect, borderX, borderY, captionHeight)); return true; diff --git a/src/gui/WindowChrome.h b/src/gui/WindowChrome.h index c8d21f912c..6159632b3d 100644 --- a/src/gui/WindowChrome.h +++ b/src/gui/WindowChrome.h @@ -103,25 +103,57 @@ enum class FrameHit { Client, Caption, Left, Right, Top, Bottom, TopLeft, TopRig // WM_NCHITTEST carries the screen point being asked about in physical pixels, // while Qt's widget geometry — and so TitleBar::isSystemMoveAreaAt() — is in -// logical ones. Converting through the window's own two rects keeps the -// rounding the platform actually applied, rather than re-deriving it from a -// device pixel ratio that may not divide evenly (#6825). +// logical ones. // -// `physical` and `windowPhysical` come from WM_NCHITTEST and GetWindowRect; -// `windowLogical` is the same window as Qt sees it (QWidget::frameGeometry()). -// The answer is the matching point in Qt's global logical space, so the -// caption test asks about the point Windows asked about rather than wherever -// the pointer happens to be. -inline QPoint logicalScreenPoint(const QPoint& physical, const QRect& windowPhysical, - const QRect& windowLogical) +// The conversion must anchor on the CLIENT area, not the window rect. On +// Windows 11 GetWindowRect includes the invisible DWM resize border, which +// Qt's frame geometry does not: measured here, a 1414 px window rect around a +// 1401 px client, 6 px of it on the left (and the rect overhangs the screen +// while maximized). Scaling one onto the other skews every mapped point by +// that inset, which put the right half of the ☰ glyph into HTCAPTION — a +// press there started a window move instead of opening the menu (#6272's +// class, found in review on #6830). The client origin and Qt's own widget +// origin describe the same area, so pairing those two cannot skew. +// +// `physical` is the WM_NCHITTEST point; `clientOriginPhysical` is the client +// area's top-left in screen pixels (GetClientRect + ClientToScreen); +// `widgetOriginLogical` is that same corner as Qt places it +// (QWidget::mapToGlobal({0, 0})). `dpr` is devicePixelRatioF(). +inline QPoint logicalScreenPoint(const QPoint& physical, const QPoint& clientOriginPhysical, + const QPoint& widgetOriginLogical, qreal dpr) { - if (windowPhysical.width() <= 0 || windowPhysical.height() <= 0) { + if (!(dpr > 0)) { return physical; } - const qreal sx = qreal(windowLogical.width()) / qreal(windowPhysical.width()); - const qreal sy = qreal(windowLogical.height()) / qreal(windowPhysical.height()); - return QPoint(windowLogical.left() + qRound((physical.x() - windowPhysical.left()) * sx), - windowLogical.top() + qRound((physical.y() - windowPhysical.top()) * sy)); + return QPoint(widgetOriginLogical.x() + qRound((physical.x() - clientOriginPhysical.x()) / dpr), + widgetOriginLogical.y() + qRound((physical.y() - clientOriginPhysical.y()) / dpr)); +} + +// The caption-height half of the main window's WM_NCHITTEST gate, hoisted out +// of nativeEvent so the wiring itself is testable: `isMoveArea` is asked about +// the message's own point, converted through the client origin, and the height +// is measured from the client top rather than from the window rect — which +// while maximized starts above it. Returns 0 (no caption) when the bar is +// absent or the point is not a move area. (#6830 review) +// +// `windowTop` is GetWindowRect's top; `clientOriginPhysical` the client's +// top-left in screen pixels; `widgetOriginLogical` the same corner per Qt; +// `barHeightLogical` the title bar's height; `dpr` devicePixelRatioF(). +template +inline int captionHeightForHitTest(const QPoint& physical, int windowTop, + const QPoint& clientOriginPhysical, + const QPoint& widgetOriginLogical, + int barHeightLogical, qreal dpr, + MoveAreaTest&& isMoveArea) +{ + if (barHeightLogical <= 0) { + return 0; + } + if (!isMoveArea(logicalScreenPoint(physical, clientOriginPhysical, + widgetOriginLogical, dpr))) { + return 0; + } + return clientOriginPhysical.y() - windowTop + int(barHeightLogical * dpr); } inline FrameHit expandedFrameHit(const QPoint& point, const QRect& window, int borderX, int borderY, diff --git a/tests/unified_title_bar_test.cpp b/tests/unified_title_bar_test.cpp index 95d578493b..59d5e0699e 100644 --- a/tests/unified_title_bar_test.cpp +++ b/tests/unified_title_bar_test.cpp @@ -257,37 +257,106 @@ int main(int argc, char** argv) // ── WM_NCHITTEST answers for the point it carries, not the cursor (#6825) ── { // The message's coordinate is physical; isSystemMoveAreaAt() needs Qt's - // logical global space. Converting through the window's own two rects - // keeps whatever rounding the platform applied. - const QRect physical(200, 100, 2000, 1200); // GetWindowRect - const QRect logical(100, 50, 1000, 600); // QWidget::frameGeometry() - - check(WindowChrome::logicalScreenPoint(physical.topLeft(), physical, logical) - == logical.topLeft(), - "the window origin maps to the window origin"); - check(WindowChrome::logicalScreenPoint(QPoint(1200, 700), physical, logical) - == QPoint(600, 350), - "a point at DPR 2 halves its offset from the window origin"); - - // A fractional ratio (150% scaling) must not drift: 2000/1333 is not 2. - const QRect physical150(0, 0, 1333, 800); - const QRect logical150(0, 0, 889, 533); - const QPoint mid = WindowChrome::logicalScreenPoint(QPoint(666, 400), - physical150, logical150); - check(mid.x() >= 443 && mid.x() <= 446 && mid.y() >= 265 && mid.y() <= 268, - "a non-integer scale maps the midpoint to the midpoint"); - - // Degenerate rects must not divide by zero. - check(WindowChrome::logicalScreenPoint(QPoint(7, 9), QRect(), logical) == QPoint(7, 9), - "an empty window rect returns the point unchanged"); + // logical global space. The conversion anchors on the CLIENT origin, + // because GetWindowRect carries the invisible DWM resize border that + // Qt's frame geometry does not. + // + // Real Windows 11 geometry measured on this PR, 100% scale: a window + // rect of x=253 w=1414 around a client at x=259 w=1401 — 6 px of + // border on the left. The widget origin is the same corner as Qt sees + // it, so these two describe the same area and cannot skew. + const QPoint clientOrigin(259, 300); + const QPoint widgetOrigin(259, 300); + + check(WindowChrome::logicalScreenPoint(clientOrigin, clientOrigin, widgetOrigin, 1.0) + == widgetOrigin, + "the client origin maps to the widget origin"); + + // The regression this replaces: scaling GetWindowRect onto Qt's frame + // geometry shifted every point by the border inset, so a press on the + // right of the ☰ glyph answered HTCAPTION and started a window move. + // Anchored on the client, a point 36 px into the client stays 36 px in. + check(WindowChrome::logicalScreenPoint(QPoint(259 + 36, 326), clientOrigin, + widgetOrigin, 1.0) + == QPoint(259 + 36, 326), + "a point inside the client keeps its offset, with no border skew"); + + // The window rect's own left edge is 6 px outside the client, and must + // map to a negative offset rather than to the widget origin. + check(WindowChrome::logicalScreenPoint(QPoint(253, 300), clientOrigin, widgetOrigin, 1.0) + == QPoint(253, 300), + "a point in the resize border maps outside the widget, not onto its origin"); + + // DPR 2: offsets halve, and the origin still anchors the result. + const QPoint hiClient(400, 200); + const QPoint hiWidget(200, 100); + check(WindowChrome::logicalScreenPoint(QPoint(400, 200), hiClient, hiWidget, 2.0) + == hiWidget, + "at DPR 2 the client origin still maps to the widget origin"); + check(WindowChrome::logicalScreenPoint(QPoint(600, 400), hiClient, hiWidget, 2.0) + == QPoint(300, 200), + "a point at DPR 2 halves its offset from the client origin"); + + // A fractional ratio (150% scaling) must not drift. + const QPoint p150 = WindowChrome::logicalScreenPoint(QPoint(400 + 150, 200), + hiClient, hiWidget, 1.5); + check(p150.x() == 300 && p150.y() == 100, + "a non-integer scale divides the offset without drifting"); + + // A zero or negative ratio must not divide by zero. + check(WindowChrome::logicalScreenPoint(QPoint(7, 9), clientOrigin, widgetOrigin, 0.0) + == QPoint(7, 9), + "a zero device pixel ratio returns the point unchanged"); // The whole point of the conversion: two different physical points in // the same message stream map to two different logical points, so the // caption test can distinguish them while the cursor sits elsewhere. - const QPoint a = WindowChrome::logicalScreenPoint(QPoint(400, 160), physical, logical); - const QPoint b = WindowChrome::logicalScreenPoint(QPoint(1800, 160), physical, logical); + const QPoint a = WindowChrome::logicalScreenPoint(QPoint(400, 160), clientOrigin, + widgetOrigin, 1.0); + const QPoint b = WindowChrome::logicalScreenPoint(QPoint(1800, 160), clientOrigin, + widgetOrigin, 1.0); check(a != b && a.y() == b.y(), "two queried points stay distinct after conversion"); + + // ── The gate itself, not just the conversion ─────────────────────── + // Real Windows 11 maximized geometry: the window rect starts 6 px + // above and left of the client and overhangs the screen. + const int windowTop = 294; // GetWindowRect top + const QPoint maxClient(259, 300); // client origin, 6 px inside + const QPoint maxWidget(259, 300); + const int barHeight = 52; + + // Which point the gate asks about: record it, so a revert to the live + // cursor (or to the window rect) is visible as a different answer. + QPoint asked(-1, -1); + auto recording = [&asked](const QPoint& p) { + asked = p; + return true; + }; + const int h = WindowChrome::captionHeightForHitTest( + QPoint(259 + 36, 326), windowTop, maxClient, maxWidget, barHeight, 1.0, recording); + check(asked == QPoint(259 + 36, 326), + "the gate asks about the message's own point, client-anchored"); + checkEqual(h, (300 - 294) + 52, + "the caption spans the frame inset plus the bar, measured from the client top"); + + // A point the move-area test rejects yields no caption at all. + auto never = [](const QPoint&) { return false; }; + checkEqual(WindowChrome::captionHeightForHitTest(QPoint(300, 326), windowTop, maxClient, + maxWidget, barHeight, 1.0, never), + 0, "a point outside the move area gives no caption"); + + // No bar (or a hidden one) must never produce a caption, and must not + // consult the move-area test at all. + bool consulted = false; + auto noting = [&consulted](const QPoint&) { + consulted = true; + return true; + }; + checkEqual(WindowChrome::captionHeightForHitTest(QPoint(300, 326), windowTop, maxClient, + maxWidget, 0, 1.0, noting), + 0, "a hidden title bar gives no caption"); + check(!consulted, "a hidden title bar does not consult the move-area test"); } // ── A tooltipped drag handle stays client, so its tooltip survives ────── @@ -310,6 +379,10 @@ int main(int argc, char** argv) auto* timer = tipBar->findChild(QStringLiteral("txTimerLabel")); auto* gutter = tipBar->findChild(QStringLiteral("titleBarDragGutter")); check(timer && gutter, "tooltip probe finds the transmit timer and the drag gutter"); + // Assert visibility rather than folding it into the guard: if the timer + // ever stops showing while transmitting, these checks must fail loudly + // instead of quietly not running. + check(timer && timer->isVisible(), "the transmit timer shows while transmitting"); if (timer && gutter && timer->isVisible()) { check(!timer->toolTip().isEmpty(), "the transmit timer carries a tooltip"); check(gutter->toolTip().isEmpty(), "the drag gutter carries no tooltip"); From 62bddbfab70793642d3d206d839909a87dfdd04d Mon Sep 17 00:00:00 2001 From: Robbie Foust Date: Tue, 3 Nov 2026 23:11:59 -0500 Subject: [PATCH 5/5] fix(gui): keep audio controls out of the caption. Principle VIII. The title bar's audio container is a drag handle, so walking ancestors also accepts its sliders and buttons as system-move areas. A tooltip-only caption exclusion leaves the volume sliders classified as HTCAPTION. Keep QAbstractButton and QAbstractSlider descendants client even when their tooltips are empty. Preserve the existing system-move predicate and the tooltip exclusion for non-interactive drag handles. Cover both volume sliders and a tooltip-free PC Audio button at their left, centre and right points through the restored and maximized frame classifiers. The socket-free regression fails 27 assertions without the guard; all four TitleBar test targets pass with it on macOS ARM64. Shorten historical comments and document that the audio controls remain interactive during Windows Snap. Refs #6825, #6830. --- docs/user/docs/windows.md | 2 +- src/gui/MainWindow.cpp | 15 ++-------- src/gui/TitleBar.cpp | 11 ++++--- src/gui/TitleBar.h | 8 ++--- src/gui/WindowChrome.h | 23 +++++---------- tests/unified_title_bar_test.cpp | 50 ++++++++++++++++++++++++++++++++ 6 files changed, 70 insertions(+), 39 deletions(-) diff --git a/docs/user/docs/windows.md b/docs/user/docs/windows.md index bf1e0c0f64..362af7a84f 100644 --- a/docs/user/docs/windows.md +++ b/docs/user/docs/windows.md @@ -49,7 +49,7 @@ There is no built-in DAX audio driver on Windows. The DAX applet says so: "No bu - **Menus** are behind the **☰** button at the left of the title bar. Alt+letter mnemonics (Alt+F, Alt+S, …) still open each menu. F11 toggles full screen. See [Menu Reference](./menu-reference.md). - **Radio Setup** is **Settings → Radio Setup...**. - **Window frame:** a Qt-drawn frame with the system shadow and resize borders, with AetherSDR's own minimize, maximize and close buttons. -- **Snap:** drag the title bar to a screen edge to snap the window to that half or corner, the same as any other window; Windows then offers Snap Assist to fill the rest of the screen. **Win+Left**, **Win+Right** and **Win+Up** snap it from the keyboard. Dragging works on the title bar's empty background — over the **☰** button, the radio tabs and the caption buttons a drag does what that control does instead. The logo and the labels that have their own hover text still move the window, but do not start a snap; drag from the empty background to snap. Hovering over the maximize button does not show the Snap Layouts flyout ([#6224](https://github.com/aethersdr/AetherSDR/issues/6224)). +- **Snap:** drag the title bar to a screen edge to snap the window to that half or corner, the same as any other window; Windows then offers Snap Assist to fill the rest of the screen. **Win+Left**, **Win+Right** and **Win+Up** snap it from the keyboard. Dragging works on the title bar's empty background — over the **☰** button, the radio tabs, the audio buttons and volume sliders, and the caption buttons a drag does what that control does instead. The logo and the labels that have their own hover text still move the window, but do not start a snap; drag from the empty background to snap. Hovering over the maximize button does not show the Snap Layouts flyout ([#6224](https://github.com/aethersdr/AetherSDR/issues/6224)). - **GPU choice on hybrid laptops:** the discrete GPU is used by default. The integrated GPU is listed in **Display → SYSTEM → GPU:** but grayed out ("disabled (#1921)"), because it crashes when a panadapter is popped out or docked. See [GPU Rendering](./gpu-rendering.md). - **Noise reduction:** **BNR** (NVIDIA Maxine, on an NVIDIA RTX/GeForce GPU) is available on Windows; its runtime comes as a single self-contained download. **MNR** is macOS only and is grayed out. See [BNR GPU Noise Removal](./bnr-gpu-noise-removal.md) and [DSP Noise Mitigation](./dsp-noise-mitigation.md). - **Copy Assist** can use a **Vulkan** GPU (NVIDIA, AMD or Intel). See [Copy Assist](./copy-assist.md). diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index ae31add956..16356fd412 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -3911,18 +3911,9 @@ bool MainWindow::nativeEvent(const QByteArray& eventType, void* message, qintptr POINT clientOrigin{0, 0}; ClientToScreen(msg->hwnd, &clientOrigin); // 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. - // - // Ask about the point in the message, not QCursor::pos(): WM_NCHITTEST - // answers for the coordinate it carries, which WindowFromPoint() and - // the shell may query while the pointer is somewhere else entirely. - // - // isCaptionMoveAreaAt() is isSystemMoveAreaAt() minus the drag handles - // that carry a tooltip: a caption gets WM_NCMOUSEMOVE, which never - // starts Qt's tooltip timer, so those would lose their hover text. + // Classify the message's point even when the shell queries it away + // from the cursor. Interactive controls stay client, as do tooltipped + // handles because caption mouse moves bypass Qt's tooltip timer. const bool barShown = m_titleBar && m_titleBar->isVisible(); const int captionHeight = WindowChrome::captionHeightForHitTest( point, window.top, QPoint(clientOrigin.x, clientOrigin.y), diff --git a/src/gui/TitleBar.cpp b/src/gui/TitleBar.cpp index 14c4b3690a..4c7050e923 100644 --- a/src/gui/TitleBar.cpp +++ b/src/gui/TitleBar.cpp @@ -14,6 +14,8 @@ #include "core/NetworkDiagnostics.h" #include "core/AppSettings.h" +#include +#include #include #include #include @@ -846,12 +848,13 @@ bool TitleBar::isCaptionMoveAreaAt(const QPoint& globalPos) const if (!isSystemMoveAreaAt(globalPos)) { return false; } - // A tooltip on the widget under the point (or on the drag handle holding - // it) would never appear once Windows treats this as caption, so leave - // those points client. The bare background has no child and no tooltip. + // Interactive children keep client input inside a draggable container. + // Tooltipped handles also stay client because caption mouse moves bypass + // Qt's tooltip timer. for (QWidget* widget = childAt(mapFromGlobal(globalPos)); widget && widget != this; widget = widget->parentWidget()) { - if (!widget->toolTip().isEmpty()) { + if (qobject_cast(widget) || qobject_cast(widget) + || !widget->toolTip().isEmpty()) { return false; } } diff --git a/src/gui/TitleBar.h b/src/gui/TitleBar.h index 2660d8b05b..3b53575d81 100644 --- a/src/gui/TitleBar.h +++ b/src/gui/TitleBar.h @@ -122,12 +122,8 @@ class TitleBar : public QWidget { bool isSystemMoveAreaAt(const QPoint& globalPos) const; - // Windows only: the subset of isSystemMoveAreaAt() that may answer - // HTCAPTION. Over a caption Windows sends WM_NCMOUSEMOVE, which Qt - // delivers as NonClientAreaMouseMove and which never starts the tooltip - // timer, so a drag handle carrying a tooltip would silently lose its - // hover text (#6830 review). Those keep HTCLIENT and still drag through - // the bar's own startSystemMove(); they just do not start a Snap drag. + // Windows caption subset: interactive controls and tooltipped handles + // stay client so their input and Qt tooltip delivery remain available. bool isCaptionMoveAreaAt(const QPoint& globalPos) const; // ── Radio tabs ────────────────────────────────────────────────────────── diff --git a/src/gui/WindowChrome.h b/src/gui/WindowChrome.h index 6159632b3d..0d47921710 100644 --- a/src/gui/WindowChrome.h +++ b/src/gui/WindowChrome.h @@ -95,7 +95,7 @@ inline bool claimsWholeWindowAsClient(Qt::WindowFlags flags, const QOperatingSys // needs before it will offer drag-to-edge Snap (#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() — +// MainWindow::nativeEvent narrows it with TitleBar::isCaptionMoveAreaAt() — // 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. @@ -105,15 +105,9 @@ enum class FrameHit { Client, Caption, Left, Right, Top, Bottom, TopLeft, TopRig // while Qt's widget geometry — and so TitleBar::isSystemMoveAreaAt() — is in // logical ones. // -// The conversion must anchor on the CLIENT area, not the window rect. On -// Windows 11 GetWindowRect includes the invisible DWM resize border, which -// Qt's frame geometry does not: measured here, a 1414 px window rect around a -// 1401 px client, 6 px of it on the left (and the rect overhangs the screen -// while maximized). Scaling one onto the other skews every mapped point by -// that inset, which put the right half of the ☰ glyph into HTCAPTION — a -// press there started a window move instead of opening the menu (#6272's -// class, found in review on #6830). The client origin and Qt's own widget -// origin describe the same area, so pairing those two cannot skew. +// Anchor on the client origin: GetWindowRect includes Windows 11's +// invisible DWM resize border, while Qt's widget geometry describes the +// client area. Pairing the two client origins preserves control alignment. // // `physical` is the WM_NCHITTEST point; `clientOriginPhysical` is the client // area's top-left in screen pixels (GetClientRect + ClientToScreen); @@ -129,12 +123,9 @@ inline QPoint logicalScreenPoint(const QPoint& physical, const QPoint& clientOri widgetOriginLogical.y() + qRound((physical.y() - clientOriginPhysical.y()) / dpr)); } -// The caption-height half of the main window's WM_NCHITTEST gate, hoisted out -// of nativeEvent so the wiring itself is testable: `isMoveArea` is asked about -// the message's own point, converted through the client origin, and the height -// is measured from the client top rather than from the window rect — which -// while maximized starts above it. Returns 0 (no caption) when the bar is -// absent or the point is not a move area. (#6830 review) +// Ask the move-area gate about the message's point in logical coordinates. +// Measure from the client top, including the maximized frame inset. A missing +// bar or rejected point gives no caption. // // `windowTop` is GetWindowRect's top; `clientOriginPhysical` the client's // top-left in screen pixels; `widgetOriginLogical` the same corner per Qt; diff --git a/tests/unified_title_bar_test.cpp b/tests/unified_title_bar_test.cpp index 59d5e0699e..a1df8c3de0 100644 --- a/tests/unified_title_bar_test.cpp +++ b/tests/unified_title_bar_test.cpp @@ -402,6 +402,56 @@ int main(int argc, char** argv) check(tipBar->isCaptionMoveAreaAt(onGutter), "the bare drag gutter still becomes the caption, so Snap works"); } + const QPoint origin = tipHost.mapToGlobal(QPoint(0, 0)); + const QRect nativeWindow(origin, tipHost.size()); + const auto checkClientControl = [&](QWidget* control) { + const QPoint samples[] = { + QPoint(0, control->height() / 2), + control->rect().center(), + QPoint(control->width() - 1, control->height() / 2), + }; + for (const QPoint& local : samples) { + const QPoint point = control->mapToGlobal(local); + check(!tipBar->isCaptionMoveAreaAt(point), + "interactive audio controls stay outside the caption move area"); + const int captionHeight = WindowChrome::captionHeightForHitTest( + point, nativeWindow.top(), origin, origin, tipBar->height(), 1.0, + [tipBar](const QPoint& logical) { + return tipBar->isCaptionMoveAreaAt(logical); + }); + check(WindowChrome::expandedFrameHit(point, nativeWindow, 8, 8, captionHeight) + == WindowChrome::FrameHit::Client, + "restored-window audio controls answer client across their width"); + check(WindowChrome::expandedFrameHit(point, nativeWindow, 0, 0, captionHeight) + == WindowChrome::FrameHit::Client, + "maximized-window audio controls answer client across their width"); + } + }; + + for (const char* name : {"Master volume", "Headphone volume"}) { + QSlider* slider = nullptr; + for (QSlider* candidate : tipBar->findChildren()) { + if (candidate->accessibleName() == QLatin1String(name)) { + slider = candidate; + } + } + check(slider && slider->isVisible(), "caption probe finds each visible volume slider"); + if (slider && slider->isVisible()) { + const QString tooltip = slider->toolTip(); + slider->setToolTip(QString()); + checkClientControl(slider); + slider->setToolTip(tooltip); + } + } + + auto* pcAudio = tipBar->findChild(QStringLiteral("pcAudioBtn")); + check(pcAudio && pcAudio->isVisible(), "caption probe finds the PC Audio button"); + if (pcAudio && pcAudio->isVisible()) { + const QString tooltip = pcAudio->toolTip(); + pcAudio->setToolTip(QString()); + checkClientControl(pcAudio); + pcAudio->setToolTip(tooltip); + } tipBar->setOperatorTransmitting(false); }