Skip to content

fix(gui): no light strip on the Windows 10 frame edges under the expanded chrome (#6266). Principle VIII. - #6269

Merged
ten9876 merged 5 commits into
aethersdr:mainfrom
chibondking:fix/6266-win10-frame-border
Oct 9, 2026
Merged

ten9876 merged 5 commits into
aethersdr:mainfrom
chibondking:fix/6266-win10-frame-border

Conversation

@chibondking

@chibondking chibondking commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Fixes #6266.

On Windows 10 with Frameless Window on, a light 1–2 px strip ran along the left, right and bottom edges of the main window. It started with #6198.

Cause: under Qt::ExpandedClientAreaHint, Qt 6.12's QWindowsGeometryHint::handleCalculateSize takes over the top caption, but keeps a resize-border inset on the left, right and bottom as non-client. Windows 11 draws that area invisibly. Windows 10 paints it as a flat light strip, whatever the OS colour scheme or accent settings are.

Changes:

  • MainWindow::nativeEvent (Windows 10 only): answers WM_NCCALCSIZE with the whole window as client area when WindowChrome::claimsWholeWindowAsClient() holds — ExpandedClientAreaHint set and the OS older than Windows 11 (build 22000). While maximized (not full screen), it insets the sides by SM_CXSIZEFRAME + SM_CXPADDEDBORDER and the top and bottom by SM_CYSIZEFRAME + SM_CXPADDEDBORDER, at the window's DPI, so nothing sits past the screen edge. No WM_NCHITTEST code is added: Qt's handleNonClientHitTest already returns HTLEFT/HTRIGHT/HTBOTTOM/corners from the client edges, which now are the window edges.
  • MainWindow::applyWindowsFrameColor (Windows 11 only): sets DWMWA_BORDER_COLOR to color.background.app on show and on every themeChanged, with Frameless Window on or off, so the DWM border follows the theme. dwmapi is linked on Windows only.
  • Window geometry: the native client-rect save/restore from Windows: main window grows 7 px wider and creeps left/down on every Frameless Window toggle, and the drift is saved across restarts #6303 (fix(gui): the Windows main window keeps its size and position across Frameless toggles and restarts (#6303). Principle VIII. #6310) runs after this handler has taken effect. applyWindowsCaptionStyles()'s SWP_FRAMECHANGED in showEvent routes WM_NCCALCSIZE through it before setFramelessWindow() and the startup / minimal-mode re-apply measure the frame, so on Windows 10 the measured frame is zero and a saved client rect is restored as the window rect exactly. A comment at that call names the dependency.
  • docs/architecture/window-chrome.md describes both, and that edge resize on this path rests on Qt 6.12's hit-test alone.

macOS and Linux are untouched. On Windows 10, Frameless Window off is untouched. Snap Layouts (#6224) is not addressed.

Constitution principle honored

Principle VIII: the window edge is continuous with the app's chrome on all four sides.

Test plan

  • Windows 10 22H2 (19045), FLEX-6600, dark theme, Frameless Window on: the strip is gone on the left, right and bottom (bridge luminance readback in the PR thread).
  • Windows 10: resize from all four edges and all four corners; maximize and restore; Win+Up and Aero Snap (checked by hand).
  • Windows 10: Frameless Window off is unchanged (checked by hand).
  • unified_title_bar_test (any host): the engage predicate is on for Windows 10 under the expanded chrome, off for Windows 11 build 22000 and off for Frameless off; a zero frame restores a client rect as the window rect. Both predicate checks fail when the predicate is mutated.
  • Windows 10, after the rebase onto fix(gui): the Windows main window keeps its size and position across Frameless toggles and restarts (#6303). Principle VIII. #6310: window position and size across Frameless off → on → off, three relaunch cycles, a maximized relaunch then un-maximize, and View → Minimal Mode twice.
  • Windows 11: border follows color.background.app and updates when the theme changes.
  • CI (check-windows is the compile proof for the Windows-only lines).

🤖 Generated with Claude Code

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed against #6266 and its thread, the #6224 follow-up list, and the code #6198 deleted. The implementation reads correctly — my findings are about one failure mode this code path has a documented history of, and about a ruling that was reversed by its own author.

1. Issue fit

Yes, for the reported symptom. #6266 is Windows 10 19045/22H2, Frameless Window on, light strip on left/right/bottom with a clean top edge. MainWindow::nativeEvent (src/gui/MainWindow.cpp:3820-3844) answers WM_NCCALCSIZE with the whole window as client, gated on ExpandedClientAreaHint and build < 22000, which is exactly the geometry the reporter and the thread converged on. The before/after screenshots on the issue show the strip gone. The Windows 11 DWMWA_BORDER_COLOR half is option 1 from the thread's approved decision.

Unaddressed from the ruling: option 2 is dropped (the body says why — it did not remove the strip on Win10). The ruling approved it for a second reason too: "it corrects the native menu/scrollbar/caption colouring mismatch generally." That benefit is now not covered by this PR or by any open issue. Worth a line in the body or a follow-up issue, not a blocker.

2. Scope

File What it changes Claimed? Verdict
src/gui/MainWindow.cpp nativeEvent WM_NCCALCSIZE (Win10 only), applyWindowsFrameColor, themeChanged connect, showEvent call Yes — body, and options 1/3 in the #6266 thread In scope
src/gui/MainWindow.h Two declarations under #ifdef Q_OS_WIN Yes In scope
CMakeLists.txt target_link_libraries(AetherSDR PRIVATE dwmapi) Yes In scope. MainWindow.cpp is compiled only by the AetherSDR target (CMakeLists.txt:1209, no test target lists it), so the single link line is sufficient — check-windows is green, which confirms it
docs/architecture/window-chrome.md Windows bullet + "previous full custom" qualifier Yes In scope, and the qualifier is the honest edit: the file previously said the nativeEvent/DWM frame "are gone"

Everything in the diff is explained by the issue and its thread. No new public surface, no settings key, no CHANGELOG.md entry, no removed guard, no sibling implementation left behind (grep for WM_NCCALCSIZE/DwmSetWindowAttribute across src/ returns only this PR's two sites plus a comment in WindowGeometryRestore.h). Four files, four commits' worth of work, nothing bundled.

3. Blockers

1. src/gui/MainWindow.cpp:3820-3844 — this re-creates the zero-top-margin frame on Windows 10, but not the geometry re-anchor that #6198 deleted alongside it. Expected symptom: the #4328 phantom-caption gap returns on Win10, and sticks.

The handler makes the whole window client area in the non-maximized state — the top included:

        if (IsZoomed(msg->hwnd) && !isFullScreen()) {
            ...
        }
        *result = 0;
        return true;

That is the same condition the pre-#6198 handler created, and the code #6198 removed says in its own words why it needed a partner (git show 5f09371e2 -- src/gui/MainWindow.cpp):

// Call this after every restoreGeometry() on this window, never instead of
// one. Qt's restore runs the saved rect through checkRestoredGeometry(),
// which reserves PM_TitleBarHeight above the top edge and shaves
// 2 + PM_TitleBarHeight off a window that would otherwise fill the work
// area — both correct for a native caption, both pure loss once
// WM_NCCALCSIZE has taken ours away. The result is a title-bar-sized gap
// above the window (#4328), and a matching one below it for anyone who
// sized the window to their screen.

and, on the minimal-mode path specifically:

// the #4328 gap would come straight back on one Ctrl+Shift+M round trip — then stick,
// because closeEvent() saves whatever origin is current.

MainWindow on this branch calls restoreGeometry() at four sites with no re-anchor at any of them: :2597 / :3898 (startup), :10133 (minimal mode in), :10208 (full mode back) — and saves at :4106, :4120, :10094, :10160. The mitigation written for exactly this frame is orphaned: parseSavedWindowGeometry and clampFrameToWorkArea are called only from WindowGeometryRestore.cpp and tests/window_geometry_restore_test.cpp; MainWindow.cpp:15 includes the header and calls neither.

#6224 made this a condition rather than a guess — @ten9876's list, under WindowGeometryRestore.{h,cpp}:

"These are still compiled, but nothing calls them since the custom frame went. If the #4557 check passes without the custom frame, delete them along with their test and the include at MainWindow.cpp:15. If the frame comes back, they come back into use."

The frame is coming back, on Windows 10. The same issue names the three paths to check: "a relaunch from a normal (not maximized) window / a maximized relaunch / a minimal-mode round trip (View → Minimal Mode, twice)". The thread's own pre-review asked for the relaunch cycles too. The PR test plan has none of the three — it covers resize, maximize/restore, Win+Up and Aero Snap, all within one session.

Failure scenario, concrete: Win10 19045, Frameless on, window sized to fill the work area → Ctrl+Shift+M twice → the window comes back one title-bar height lower and 2 + PM_TitleBarHeight shorter; quit; the offset is saved by closeEvent and is the new baseline next launch.

This is reasoned from the deleted code and the current tree, not reproduced — I have no build, no Windows and no way to drive the GUI, and Qt's own frame-margin bookkeeping under a hijacked WM_NCCALCSIZE is not something I can read off this checkout. What would settle it is five minutes on the hardware you already used for the screenshots:

  • Win10 19045, Frameless on: launch → note position and size → quit → relaunch. Three cycles. Nothing should creep or grow.
  • Maximized relaunch.
  • View → Minimal Mode, twice, with the window sized to fill the work area.
  • Same session: Frameless Window off → on (the setWindowFlags recreate re-applies saved geometry).

No fix suggested here on purpose: if it does drift, the remedy is the margin bookkeeping (#6224 names it — re-wire WindowGeometryRestore behind a Win10-and-expanded gate, the way the deleted reanchorCustomFrameGeometry gated on mainWindowCustomFrameEnabled()), not a change to the WM_NCCALCSIZE reply. If it does not drift, say so on the PR and #6224's WindowGeometryRestore bullet needs rewording, because the frame is back and the helpers still are not called.

4. Needs maintainer decision

2. The thread's ruling said not to do this, and was reversed by its own author. #6266 comment of 2026-10-07T23:39:54Z:

"No nativeEvent and no hit-test changes in this PR. #6224 decides that seam separately."
"If option 2 doesn't remove it on Win10, stop and report back here before reaching for nativeEvent."

The report-back (01:39:30Z) and this PR (01:37:59Z) are the same author as the ruling, and the report-back announces the finished nativeEvent rather than asking for it. GOVERNANCE.md does not require an RFC for a bug fix with a clear root cause, so this is not a canon violation — but the explicit "stop and report back first" gate was crossed by the person who set it, and the seam it reserved belongs to #6224, which a different maintainer owns. That wants a second maintainer's confirmation rather than mine.

In the PR's favour on this point: the < Windows11 gate means the handler never runs on Windows 11, so it does not become an entry point for #6224's W1 Snap Layouts fix (HTMAXBUTTON on hover, a Win11 feature). The seam exists now but is not shared, and the body says so. When W1 lands it will extend this same function — worth a cross-link on #6224 either way.

5. Nits (non-blocking)

  • src/gui/MainWindow.cpp:3833-3834 — the vertical inset uses the horizontal metric:
              const int border = GetSystemMetricsForDpi(SM_CXSIZEFRAME, dpi)
                  + GetSystemMetricsForDpi(SM_CXPADDEDBORDER, dpi);
    client.top/client.bottom should take SM_CYSIZEFRAME. They are equal under every stock Windows theme, so this is latent rather than live — but the name no longer matches the use once both axes are inset.
  • Windows 11 is unverified. The PR's own test plan leaves [ ] Windows 11: border follows color.background.app … unchecked, and the ruling listed "Win11 border colour" among the required regression checks. applyWindowsFrameColor has no coverage of any kind.
  • src/gui/MainWindow.cpp:1145-1146 — the themeChanged connect is unconditional (that { } block is not gated on frameless mode), which is right for tracking runtime theme changes. The side effect is that on Windows 11 the DWM border is now recoloured with Frameless Window off as well, i.e. the native-framed window's border follows the theme. That is probably what you want, but "Frameless Window off is unchanged" in the test plan was checked on Windows 10, where the function early-returns. Worth stating in the body.
  • Testability: the engage predicate (ExpandedClientAreaHint && build < 22000) and the maximized-inset rect math could be pure functions, host-testable the way WindowChrome::chromeFlags() already is ("a pure function of the platform so the Windows branch can be tested on any host") and the way window_geometry_restore_test tests the clamp arithmetic on any host. Not merge-gating — the WM_NCCALCSIZE reply itself has no deterministic seam, and per AGENTS.md's test-layer boundary this is native-platform behaviour that belongs in the #6224 hardware list.
  • docs/architecture/window-chrome.md:92 — "Qt's own WM_NCHITTEST still resizes from the client edges" is the one load-bearing claim with no fallback behind it: FramelessResizer::eventFilter returns early unless Qt::FramelessWindowHint is set (src/gui/FramelessResizer.cpp:296), and chromeFlags() clears that flag whenever the expanded path is taken. If Qt's hit-testing ever stops producing border codes once the frame is client area, Windows 10 loses edge resize outright with nothing to catch it. Your manual test is the only evidence either way; a sentence in the doc saying the claim rests on Qt's behaviour, not on ours, would make the next person check it after a Qt bump.

6. What I tried to break (and could not)

  • wParam == FALSE. Falls through to QMainWindow::nativeEvent — correct, lParam is a bare RECT in that case and the NCCALCSIZE_PARAMS cast at :3835 would be wrong.
  • Full screen and minimized. IsZoomed(msg->hwnd) && !isFullScreen() skips the inset in full screen; minimized leaves IsZoomed false, so the whole window is client and nothing is read from a degenerate rect. The inset is DPI-aware via GetDpiForWindow, not the fixed system metric.
  • Link scope. Only the AetherSDR target compiles MainWindow.cpp (CMakeLists.txt:1209), so dwmapi on that target alone cannot leave a test target unlinked — and check-windows, which builds AetherSDR plus four tests, is green on f092306.
  • Dropped option 2 residue. grep setColorScheme across src/ is clean; nothing of the reverted attempt is left behind to fight the new path.
  • Lifetime on the DWM call. applyWindowsFrameColor early-returns on !windowHandle(), so the winId() below it cannot force a native window into existence; called before first show it is a no-op and showEvent picks it up. Theme changes on Windows 10 reach it and early-return on the version check.
  • The other top-level window. m_appletPanelFloatWindow has its own HWND and nativeEvent is a per-widget virtual, so the handler cannot reach it — the float window keeps Qt's frame.
  • Other chrome paths. macOS, Linux, and Frameless Window off are all unreachable: the handler requires ExpandedClientAreaHint, which chromeFlags() sets only on cocoa/windows and only when enabled, and applyWindowsFrameColor is inside #ifdef Q_OS_WIN.

What I could not check at all: every runtime claim. No build, no Windows host, no GUI. The strip's disappearance, the four-edge resize, maximize/restore, Win+Up and Aero Snap all rest on your manual testing, and the screenshots support the first of those. Every finding above is reasoned from the diff, the head checkout and the code #6198 deleted.

7. Recommendation

Needs maintainer decision. The code is clean, correctly gated, honestly documented and the diff is tight — four files, nothing bundled, and the doc edit fixes the sentence this change invalidates. Two things want a human before merge: the geometry-persistence cycles in blocker 1, which #6224 already lists as a condition for exactly this situation and which the test plan does not cover, and the ruling reversal in finding 2, which should be confirmed by a maintainer other than the one who wrote both sides of it.

Concrete next step: run the three #6224 persistence paths on the Win10 box, post the result here, and cross-link the new nativeEvent on #6224 so its WindowGeometryRestore and W1 bullets stay accurate either way. Thanks for chasing this down to the actual Qt mechanism after the first attempt failed, and for saying plainly in the body that option 2 did not work — that saved the next person the same round trip.


🤖 aethersdr-agent · cost: $9.0615 · model: claude-opus-5

@chibondking

chibondking commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator Author

Fix verified through the automation bridge

Builds compared (same machine, same session, same window position):

  • Before: the installed v26.10.1 build, which doesn't have this fix.
  • After: this branch's portable build from CI run 37709423690 (7a522dd7). The PR head, f0923068, adds only a docs commit on top.

Run on Windows 10 22H2 (19045) with Intel HD 520, connected to a FLEX-6600. The dark theme was on, and Frameless Window was switched with the bridge (invoke "Frameless Window" trigger).

Method: a solid black window was placed directly behind AetherSDR, and the screen was captured around the window. Each 1-px line along the left, right and bottom edges was scored by luminance (0–255), over the middle 50–60% of each edge. Against the black backdrop, any light pixel at the edge belongs to the window frame.

Frameless Window Build Left edge Right edge Bottom edge
On Before 255 (white, 1 px) 255 (white, 1 px) 255 (white, 1 px)
On After 26: the theme background runs to the edge 17: the theme background 16: the theme background
Off After 38: the normal native 1 px Windows 10 border 38 38

In the "before" build, every sampled pixel on the boundary line was exactly 255, a solid white line on all three edges. The top edge was clean, as described in #6266. In the "after" build, the window's dark background meets the backdrop directly, and the maximum luminance at the boundary line is no higher than the window's own content. With Frameless Window off, the native border is unchanged.

Before/after crops at 8× zoom:

6269-proof-before-after

Not covered here: Windows 11, and resize, maximize and snap through the bridge. The resize, maximize and snap checks were done by hand, as listed in the test plan.

chibondking added a commit to chibondking/AetherSDR that referenced this pull request Oct 9, 2026
…ss Window on (aethersdr#6303). Principle VIII.

On Windows with Qt's expanded client area (Frameless Window on), Qt 6.12
doesn't take its frame margins from the real non-client area. It
subtracts a fixed border from the system margins
(qwindowswindow.cpp:3069), and stops recomputing them once the window
rect and the client rect are the same size (:3061). restoreGeometry()
rebuilds the window from the blob's frame rect using those margins, so
every launch came back a different size. Measured on Windows 10 at 85%
UI scale: the saved blob had a normal rect of 1407x400 but a frame rect
of 1389x391, and the relaunch came back 1424 wide (17 px more).

reapplyStartupGeometryAfterShow() now puts back the blob's normal
geometry, which is the client rect saveGeometry() recorded, right after
restoreGeometry(). It only does this on Windows, in the expanded mode,
in the normal window state. The off-screen clamp after it is unchanged.
WindowChrome::savedNormalGeometry() reads that rect from the blob, and
unified_title_bar_test covers it with a blob whose frame and normal
rects differ.

The issue also reports drift on each Frameless toggle. That comes from
MainWindow::setFramelessWindow() reapplying Qt's geometry() across the
flag change; geometry() is wrong by the same margin error. aethersdr#6269 fixes
it on Windows 10, where the window rect then equals the client rect;
toggles measured exact on that build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chibondking chibondking self-assigned this Oct 9, 2026
chibondking added a commit to chibondking/AetherSDR that referenced this pull request Oct 9, 2026
… not Qt's frame margins (aethersdr#6303). Principle VIII.

The first attempt put back the saveGeometry() blob's normal rect after
startup. On the CI build (Windows 10, 85% UI scale, Frameless Window
on), that held the position, but the client still grew by 6 physical
px each launch: 1210, 1216, 1222, 1228. Under the expanded client area,
Qt's geometry() itself reads back 7 logical px wider than the
setGeometry() that produced it, so the rect being saved was already
wrong.

The main window now saves and restores its client rect in physical
pixels, read and set through Windows:

- closeEvent() stores GetClientRect/ClientToScreen with the window's
  device pixel ratio (MainWindowNativeClientRect), in the expanded mode
  and the normal window state only.
- reapplyStartupGeometryAfterShow() puts it back with SetWindowPos,
  sizing the window from the frame it has now, when the pixel ratio
  matches. A UI or display scale change falls back to Qt's restore.
- setFramelessWindow() keeps the same client rect across the flag
  change, so the toggle no longer depends on aethersdr#6269's Windows 10 frame
  change and works the same on Windows 11.

WindowChrome::windowRectForClient() and the format/parse pair are pure
and covered in unified_title_bar_test. Mutating the caption term out of
the margin math, or the pixel-ratio check out of the parser, fails it.
The blob parser from the first attempt is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at f0923068 against #6266 and its thread, the earlier aethersdr-agent review on this PR, and Qt 6.12.0's own Windows platform source (qtbase/src/plugins/platforms/windows/qwindowswindow.cpp, qwindowscontext.cpp, src/widgets/kernel/qwidget.cpp at tag v6.12.0). Every changed line is Windows-only, so everything below is reasoned from code plus the Qt source, not reproduced. Nothing here can run on a Linux host, and the demo-mode bridge has no Windows backend to drive.

Issue fit

Yes, for the reported symptom. #6266 describes a light strip on the left, right and bottom edges on Win10 19045 with Frameless on. Qt 6.12's expanded handleCalculateSize (qwindowswindow.cpp:1185-1197) insets left, right and bottom by getResizeBorderThickness(96) and leaves top alone when the window isn't maximized. That matches the reported geometry exactly. The PR answers WM_NCCALCSIZE first, from MainWindow::nativeEvent (src/gui/MainWindow.cpp:3820-3844), which runs before Qt's handler (qwindowscontext.cpp:858 vs :1013). Your bridge luminance table on the PR and the before/after screenshots on #6266 back up the visual result. The Win11 DWMWA_BORDER_COLOR half is option 1 from the approved ruling.

Scope

File / commit What it changes Claimed? Verdict
src/gui/MainWindow.cpp (7a522dd7) Win10-only nativeEvent WM_NCCALCSIZE reply Yes In scope
src/gui/MainWindow.cpp (21ee1b82, authored 10-07) applyWindowsFrameColor (Win11 DWMWA_BORDER_COLOR), themeChanged connect, showEvent call Yes, option 1 of the ruling In scope. One undisclosed effect, see nit 2
src/gui/MainWindow.h Two declarations inside #ifdef Q_OS_WIN Yes In scope
CMakeLists.txt dwmapi on the AetherSDR target, if(WIN32) Yes In scope. MainWindow.cpp is compiled only into AetherSDR
docs/architecture/window-chrome.md (f0923068) Describes both halves; "previous full custom" qualifier Yes In scope

Everything in the diff is explained by the issue and its thread. There's no new public surface, no settings key, no CHANGELOG.md entry and no removed guard. The dropped option 2 left nothing behind: grep -rn setColorScheme src/ at the head is empty. The test-boundary preflight is vacuous because the PR touches no test sources.

Verify before merge — Frameless off → on probably grows the window on Win10 (src/gui/MainWindow.cpp:9940-9946 + :3820-3844)

This is the one place where I think the change has a real side effect. I can't reproduce it here. If it reproduces, I'd treat it as a blocker. The fix is small either way.

setFramelessWindow() saves geometry(), then calls WindowChrome::configure(), which re-creates the native window. It then calls setGeometry(geom) before show(). That means the new HWND is created while Qt still owns WM_NCCALCSIZE. During CreateWindowEx there is no QWindowsWindow yet, so filterNativeEvent(platformWindow->window(), …) at qwindowscontext.cpp:858 can't reach MainWindow::nativeEvent. The creation-context branch (:944-945) runs Qt's handler instead, so the new window is laid out with Qt's L/R/B inset around geom.

Next comes show() → showEvent() → applyWindowsCaptionStyles() → SetWindowPos(… SWP_NOMOVE | SWP_NOSIZE | SWP_FRAMECHANGED). That is the first WM_NCCALCSIZE your handler sees. The window rect stays where it is and the client area becomes all of it. Qt records honest zero margins from that reply (qwindowscontext.cpp:1366-1380, more on this below). The result is that the client is now about 2×border wider and border taller than geom, and shifted left by border. Nothing re-applies geom afterwards. closeEvent then persists the grown rect.

On a 100% display, SM_CXSIZEFRAME + SM_CXPADDEDBORDER is 8 px, so each off→on toggle should grow the window by roughly 16×8 px.

Startup isn't affected the same way. showEvent schedules reapplyStartupGeometryAfterShow() (:3886), and that runs after the margins are honest, so it undoes the same growth on launch.

Two-minute check with the bridge on your Win10 box: read MainWindow geometry, invoke "Frameless Window" trigger twice, then read the geometry again and repeat. If it grows, this drop-in fixes it. Once show() returns, showEvent has already forced the frame change synchronously, so Qt's margins are zero and setGeometry is honoured as-is:

    WindowChrome::configure(this, on);
    setGeometry(geom);
    if (wasVisible) {
        show();
#ifdef Q_OS_WIN
        // show() ran applyWindowsCaptionStyles(), whose frame change turned the
        // Windows 10 resize border into client area (nativeEvent); put the
        // client rect back where it was.
        setGeometry(geom);
#endif
    }

The same mechanism probably applies to the maximized-relaunch path. Windows' rcNormalPosition comes from the creation-time frame, so the first un-maximize after a maximized launch would land about one border wider and taller. It's worth reading the geometry there too while you're at it.

The earlier review's blocker 1 (restart drift / #4328 gap): mostly refuted by Qt's source

That review predicted creep across relaunches because "Qt derives fullFrameMargins from the style, not from what we answer to WM_NCCALCSIZE". In Qt 6.12.0 that isn't the case:

  • qWindowsWndProc takes rgrc[0] before and after windowsProc, which includes the nativeEvent filter. It then calls setFullFrameMargins(marginsFromRects(…)) (qwindowscontext.cpp:1353-1380). Qt's comment there says this exists to "correctly capture client code overriding the message". Non-maximized under this PR, the captured margins are (0,0,0,0).
  • calculateFullFrameMargins() returns early when the window rect and client rect are the same size, under QTBUG-117704 (qwindowswindow.cpp:3056-3061). So nothing later overwrites the zero margins.
  • QWidget::restoreGeometry (format v3) restores the client geometry() with setGeometry(restoredGeometry) (qwidget.cpp, the non-maximized branch). With honest zero margins, a relaunch round-trips exactly.
  • The #4328 phantom-caption reservation (checkRestoredGeometry, which reserves PM_TitleBarHeight) depends only on the client rect's top. Qt's own expanded handler already leaves top as client when not maximized, so that behaviour is identical on current main and is not introduced here. If it bites, it belongs to #6224's WindowGeometryRestore bullet, not this PR.
  • The PR's only delta on restore is L/R/B going from 8 px to 0. That can cause a one-time +1 moveLeft / −2 width nudge for a window flush with the work-area edge, and it is not compounding.
  • One loose end I checked: QWindowsWindow::frameMargins() (:3084-3089) subtracts invisibleMargins, so it goes slightly negative under this PR. Its consumers in MainWindow's paths are the frameGeometry().center() on-screen test (MainWindow.cpp:3906, harmless) and the off-screen fallback move() (:3920, a few pixels at most). Neither is load-bearing.

So I'd still run three relaunch cycles on Win10, but I expect them to pass. The real risk is the in-session toggle above.

Maintainer note (not a canon violation)

The #6266 ruling said "No nativeEvent … If option 2 doesn't remove it on Win10, stop and report back here before reaching for nativeEvent." The PR went ahead and reported back with the technical reason, written by the same maintainer who made the ruling. GOVERNANCE.md exempts bug fixes with a clear root cause from the RFC path, so this is a sign-off question for a second maintainer rather than a blocker.

Nits (non-blocking)

  1. src/gui/MainWindow.cpp:3832-3838: the vertical inset uses SM_CX*. That is cosmetic: Qt's own getResizeBorderThickness (qwindowswindow.cpp:527-534) uses SM_CXSIZEFRAME + SM_CXPADDEDBORDER for all four sides too. Your version is actually more correct than Qt's, because it uses GetDpiForWindow where Qt's calculate-size hard-codes 96.
  2. src/gui/MainWindow.cpp:1145-1148 + :3857: the body says "Windows 11, macOS, Linux and Frameless Window off are untouched". On Windows 11 that isn't quite true. The connect sits in an unconditional { } scope (:1127), and applyWindowsFrameColor() doesn't check ExpandedClientAreaHint. So with Frameless off, the native-framed window's DWM border is recoloured to color.background.app, next to a native caption that keeps the OS light/dark colour. Either gate it like applyWindowsCaptionStyles() does, or state it in the body:
        if (QOperatingSystemVersion::current() < QOperatingSystemVersion::Windows11
            || !windowHandle() || !windowFlags().testFlag(Qt::ExpandedClientAreaHint)) {
            return;
        }
    The off-toggle re-creates the HWND, so the border goes back to the default by itself.
  3. The Windows 11 half is unverified: its test-plan box is unchecked. Nothing in the tree exercises applyWindowsFrameColor.
  4. docs/architecture/window-chrome.md:92, "Qt's own WM_NCHITTEST still resizes from the client edges": I checked this for Qt 6.12.0. Our flags (CustomizeWindowHint, no title or button hints) take the else if (CustomizeWindowHint) branch of handleNonClientHitTest, and the border != 0 block (qwindowswindow.cpp:3430-3443) returns HTLEFT/HTRIGHT/HTBOTTOM/corners from geometry()'s edges. geometry() is now the whole window. Nothing in AetherSDR backs this up: FramelessResizer is inert without FramelessWindowHint. Consider saying "(Qt 6.12)" in the doc so the next Qt bump re-checks it.
  5. Testability (not merge-gating): the engage predicate and the maximized-inset arithmetic could be pure functions, host-testable like WindowChrome::chromeFlags(). The WM_NCCALCSIZE reply itself has no deterministic seam.
  6. A cross-link on #6224 would keep its W1 (Snap Layouts) and WindowGeometryRestore bullets accurate. The < Windows11 gate means this handler is not yet the seam W1 needs.

What I tried to break (held up)

  • Platform gating. Every hunk is inside #ifdef Q_OS_WIN or if(WIN32): MainWindow.cpp:276-291, :1145-1148, :3779-3845, :3856-3858; MainWindow.h:350-358. QOperatingSystemVersion::Windows11 is 10.0.22000, so Win11 never enters the handler. macOS and Linux can't compile it, and Frameless off fails the ExpandedClientAreaHint test.
  • wParam == FALSE. It falls through to Qt, which is correct because lParam is then a bare RECT. Minimized and full screen: IsZoomed && !isFullScreen() matches Qt's own maximized predicate (:1186).
  • Maximized inset. All four sides by border at the window's DPI. That matches what Qt captures as fullFrameMargins while zoomed, so the maximized bookkeeping stays honest too.
  • The floating applet window and pop-outs. Only MainWindow uses chromeFlags() (grep ExpandedClientAreaHint src/), and nativeEvent is per-widget, so no sibling surface is left with the strip.
  • Stale CI. The green runs were created 2026-10-08T01:38Z, and main has moved 13 commits since (tip 2026-10-09T04:34Z). git merge-tree origin/main f0923068 is clean. Main's MainWindow.cpp changes (#6267 status-bar a11y, #6289 Network Diagnostics) and the aethercore ole32/oleaut32/shell32 link line don't overlap these hunks. So the green no longer describes the current merge, but I see no semantic interaction.

Not done: I didn't build or drive the app. A Linux build compiles none of these lines, CI build already covers the Linux compile and check-windows compiled the Windows side, and the demo bridge can't exercise a Win32 message handler. The automated /code-review pass wasn't run in this session. I did the equivalent pass by hand.

Verdict: the fix is correct for the symptom and tightly scoped. Please run the Frameless off→on geometry check above (and the maximized-relaunch un-maximize) before merge, and add the follow-up setGeometry(geom) if it grows.

ten9876 added a commit that referenced this pull request Oct 9, 2026
…Frameless toggles and restarts (#6303). Principle VIII. (#6310)

Fixes #6303.

## What

On Windows with **Frameless Window on**, the main window's size and
position drift on every Frameless toggle and on every restart. Each
change is saved, so the drift builds up. Measured on main (Windows 10,
85% UI scale): every restart makes the client area 6 physical px wider
and moves it 3–6 px left and 3 px down.

## Root cause

With `Qt::ExpandedClientAreaHint`, Qt 6.12's Windows plugin doesn't take
its frame margins from the real non-client area.
`qwindowswindow.cpp:3069-3081` subtracts a fixed `qRound(scaleFactor) *
2` border from the system margins and guesses the top margin from what's
left over. At `:3057-3062` it stops recomputing them once the window
rect and client rect are the same size. The real expanded frame is L8 T0
R8 B8, so Qt's bookkeeping is off:

- `geometry()` reads back **7 logical px wider** than the client Windows
reports, even right after a `setGeometry()` produced that client.
- `restoreGeometry()` rebuilds the size from the saved blob's frame
rect. One saved blob had a normal rect of 1407×400 but a frame rect of
1389×391, and it relaunched 1424 wide.

`MainWindow::setFramelessWindow()` (save `geometry()`, re-create,
`setGeometry()`) and the startup restore both round-trip through those
numbers.

## How

On Windows only, every behaviour change is inside `#ifdef Q_OS_WIN`. The
main window keeps its **client rect in physical pixels, read and set
through Windows**, instead of through Qt's frame margins. The rects live
in one versioned feature document (Principle V),
`MainWindowNativeGeometry`, with one entry per window role:

```json
{"schemaVersion":1,
 "main":        {"x":131,"y":45,"width":1234,"height":411,"dpr":0.85},
 "fullMode":    {...},
 "minimalMode": {...}}
```

- **`closeEvent()`** stores `GetClientRect` / `ClientToScreen` and the
window's device pixel ratio as `main`, and as `minimalMode` when
quitting in Minimal Mode. Only in the expanded mode and a normal (not
minimized/maximized/full-screen) window; a maximized close keeps the
last normal rect.
- **`reapplyStartupGeometryAfterShow()`**, after the existing
`restoreGeometry()`, puts `main` back with `SetWindowPos`. The window is
sized from the frame it has *now*, and only when the pixel ratio
matches. A UI or display scale change falls back to Qt's restore, as
today. The off-screen clamp after it is unchanged.
- **Minimal Mode round trip.** `FullModeGeometry` and
`MinimalModeGeometry` go through `restoreGeometry()` and drift by the
same margin error, so each is paired with its native rect (`fullMode`,
`minimalMode`), saved and re-applied beside it in `toggleMinimalMode()`.
- **`setFramelessWindow()`** keeps the same client rect across the flag
change. The toggle no longer depends on #6269's Windows 10 frame change,
and it works the same on Windows 11, which #6269 doesn't touch.

A saved entry is ignored — and Qt's restore runs — when it is missing,
from another schema version, at another pixel ratio, fractional,
non-numeric, or outside the 16-bit Win32 coordinate range. The ratio is
a JSON double, so it round-trips at full precision.

Only the main window takes `ExpandedClientAreaHint`. Pop-out pans,
floating containers and dialogs switch `Qt::FramelessWindowHint`, whose
frame margins are real, so they don't have this drift and are unchanged.

`WindowChrome::windowRectForClient()`, `withNativeClientRect()` and
`savedNativeClientRect()` are pure helpers; the HWND calls and the
document read/write are small `MainWindow` members.

## Testing

### Proof with the agent automation bridge (Windows 10 22H2, Intel HD
520, 85% UI scale, FLEX-6600 connected)

Builds came from the fork's Windows Installer workflow (Qt 6.12).
Frameless was switched with the bridge (`invoke "Frameless Window"
trigger`). Restarts were real closes: `WM_CLOSE` to the main window, so
`closeEvent()` saves as it does for a user. Every rect is read from
Windows (`GetWindowRect`, `GetClientRect` + `ClientToScreen`, DWM
extended frame bounds), never from Qt.

**Restart with Frameless on** (client rect, physical px):

| Build | Launch 0 | Launch 1 | Launch 2 | Launch 3 |
|---|---|---|---|---|
| main `65b72a65` (installed 26.10.1) | 151,21 **1210**×340 | 148,24
**1216**×340 | 143,27 **1222**×340 | 137,30 **1228**×340 |
| first commit `cb8712da` | 246,42 **1210**×411 | 246,42 **1216**×411 |
246,42 **1222**×411 | 246,42 **1228**×411 |
| **this PR `c12b1ec6`** | **131,45 1234×411** | **131,45 1234×411** |
**131,45 1234×411** | **131,45 1234×411** |

On this PR's build the saved value was `131,45,1234,411@0.85`, which
matches what came back.

**Frameless toggle** (client rect, physical px):

| Build | Before | → On | → Off |
|---|---|---|---|
| main | 165,44 1099×340 | 165,44 1099×340 (Qt's `geometry()` now 7 px
wider) | **162,47 1105×340** |
| **this PR** | 131,45 1234×411 | 131,45 1234×411 | **131,45 1234×411**
|

### Unit tests (local, Qt 6.12.0 pinned, MSVC 2022, offscreen)

- The `unified_title_bar_test` checks for `windowRectForClient`
(expanded frame, uneven borders, caption, asymmetric left/right) and the
document helpers (round trip through compact JSON, other role, scale
mismatch, full-precision scale, nothing saved, out of range, malformed,
fractional, newer schema, empty) **all pass** on Linux (Qt 6.12,
offscreen).
- **Mutation-checked:** swapping the left/right border, dropping the
range bound, storing the ratio as a float, dropping the schema check and
dropping the integer check each turn the test red; dropping the caption
term or the pixel-ratio comparison did the same in the earlier Windows
run.
- One existing check in the same binary, *"four radios fit whole in an
1800 px bar"*, fails in my local Windows offscreen run **with and
without this change** (it also fails on a tree that's main plus
theme-only edits). CI on `main` (`888ac58b`, run 37865594009) is green,
so this looks like a local font or metrics difference, not this PR.

### Not verified

- **Windows 11.** The fix measures the frame live from Windows, so it
doesn't depend on the border sizes, but only Windows 10 was available.
- **Maximized and full-screen** windows, which the new code deliberately
skips; they weren't drifting.
- **Multi-monitor and mixed-DPI** setups.
- **The Minimal Mode round trip and the feature-document storage on
Windows.** The document helpers are unit-tested on Linux; the Win32
paths compile only on `check-windows` and were not re-driven on Windows
after the document change.
- **UI-scale or display-scale changes between sessions.** The saved rect
is ignored, and Qt's normal restore runs, by design.

### Other platforms

macOS and Linux compile the same code as before. The pure helpers in
`WindowChrome.h` compile everywhere but are only called from Windows
code and the test.

## Related

- #6266 / #6269 change the Windows 10 expanded frame (window rect ==
client rect). They fix the toggle half of this issue on Windows 10, but
not the restart drift: on the #6269 build, a restart with Frameless on
came back 17 logical px wider. This PR works with or without #6269.
- #5526 (window shifted at launch, closed) is related but separate.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: CJ Johnson <chibondking@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
chibondking and others added 5 commits October 8, 2026 22:21
…t/dark setting (aethersdr#6266). Principle VIII.

Since aethersdr#6198 handed the Windows frame to Qt's expanded client area, the
left, right and bottom edges are drawn by DWM, coloured from the OS
light/dark preference. A dark AetherSDR theme on a light-mode Windows 10
install showed a light strip on those three edges.

- ThemeManager sets Qt's colour scheme from color.background.app on every
  theme switch and on live edits of that token. Qt's Windows plugin turns
  that into DWMWA_USE_IMMERSIVE_DARK_MODE (Windows 10 1809+).
- On Windows 11, MainWindow also sets DWMWA_BORDER_COLOR to
  color.background.app on show and on every theme change, so the border
  matches the app exactly. dwmapi is linked on Windows only.

No nativeEvent or hit-test changes; aethersdr#6224 decides that seam.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…anded chrome (aethersdr#6266). Principle VIII.

Setting Qt's colour scheme from the theme did not remove the strip on
Windows 10 (tested on 19045 with Frameless Window on). Qt 6.12's
QWindowsGeometryHint::handleCalculateSize keeps a resize-border inset on
the left, right and bottom as non-client under ExpandedClientAreaHint, and
Windows 10 paints that area light whatever the colour scheme says.

- MainWindow::nativeEvent handles WM_NCCALCSIZE on Windows 10 only (below
  build 22000) when ExpandedClientAreaHint is set: the client area is the
  whole window, inset on all four sides only while maximized. Qt's own
  WM_NCHITTEST already resizes from the client edges in this mode, so no
  hit-test code is added.
- The ThemeManager colour-scheme change is reverted: it did not fix the
  strip and switched Qt's default palette app-wide.
- The Windows 11 DWMWA_BORDER_COLOR change stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… border colour (aethersdr#6266). Principle VIII.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…epends on (aethersdr#6266). Principle VIII.

applyWindowsCaptionStyles()'s SWP_FRAMECHANGED is what routes
WM_NCCALCSIZE through MainWindow::nativeEvent() on Windows 10 before
setFramelessWindow() and reapplyStartupGeometryAfterShow() measure the
client rect with nativeClientRect(). setNativeClientRect() sizes the
window from the frame it measures at that moment, so with the frame
already zero it restores the client rect exactly; no further post-show
geometry correction is needed, and a setGeometry() after it would undo it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…te, and the maximized inset uses each axis's metric (aethersdr#6266). Principle VIII.

WindowChrome::claimsWholeWindowAsClient() is the engage condition
MainWindow::nativeEvent() answers WM_NCCALCSIZE under, as a pure function
so the Windows 10 / Windows 11 / Frameless-off split is checked on any
host. The maximized inset takes SM_CYSIZEFRAME for the top and bottom
instead of the horizontal metric. unified_title_bar_test also pins that a
zero frame restores a saved client rect as the window rect, the case
aethersdr#6303's setNativeClientRect() meets on Windows 10 under this handler.
window-chrome.md says edge resize rests on Qt 6.12's hit-test alone and
that the Windows 11 border follows the theme with Frameless off too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ten9876
ten9876 force-pushed the fix/6266-win10-frame-border branch from f092306 to 4c1e533 Compare October 9, 2026 05:26
@ten9876 ten9876 self-assigned this Oct 9, 2026
@ten9876

ten9876 commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Answering @aethersdr-agent's review (5450894866) and my own (5465834976) on the rebased head 4c1e533c3. The branch is now on main after #6310 (abdd4f2e); all five commits are signed and verified.

Blocker 1 — #4328 gap / geometry drift on Windows 10. The re-anchor #6198 deleted is now covered by #6310's native client-rect restore, which follows every restoreGeometry() on the main window:

  • startup: reapplyStartupGeometryAfterShow() → restoreNativeClientRect("main") (src/gui/MainWindow.cpp:3954-3968)
  • minimal mode in: restoreNativeClientRect("minimalMode") after restoreGeometry (:10266-10268)
  • full mode back: restoreNativeClientRect("fullMode") after showNormal() (:10348-10356)
  • Frameless toggle: setFramelessWindow() captures the client rect before the re-create and setNativeClientRect()s it after show().

setNativeClientRect() sizes the window from the frame it measures at that moment (GetWindowRect vs GetClientRect), not from Qt's margins, so Qt's checkRestoredGeometry() shave is overwritten. Under this PR the measured frame on Windows 10 is zero, because applyWindowsCaptionStyles()'s SWP_FRAMECHANGED runs synchronously inside showEvent and routes WM_NCCALCSIZE through nativeEvent() first; with a zero frame the saved client rect becomes the window rect exactly. unified_title_bar_test now pins that zero-frame case, and e9d7f67a0 adds a comment at applyWindowsCaptionStyles() naming the dependency. This also retires the Frameless off→on growth I predicted in my review: that was against pre-#6310 main, and a post-show setGeometry(geom) would now undo #6310's correction, so none was added.

Reasoned from code, not run: none of these lines compile on Linux. The one path the native restore deliberately skips is a maximized relaunch followed by un-maximize (Qt's normal geometry is used there). @chibondking, before this merges please post the bridge geometry readback on the Win10 box for: Frameless off → on → off; three relaunch cycles from a normal window; a maximized relaunch then un-maximize; View → Minimal Mode twice with the window filling the work area.

Finding 2 — the ruling reversal. Confirmed by @ten9876 as the second maintainer: the nativeEvent / WM_NCCALCSIZE approach is signed off.

Nit — vertical metric. Fixed in 4c1e533c3: the maximized inset uses SM_CYSIZEFRAME for top and bottom.

Nit — testability. Fixed in 4c1e533c3: the engage condition is WindowChrome::claimsWholeWindowAsClient(), a pure function checked in unified_title_bar_test (Windows 10 under the expanded chrome → on; Windows 11 build 22000 → off; Frameless off → off). Mutating < to <=, or dropping the ExpandedClientAreaHint term, each fails its check; restored, unified_title_bar_test: all checks passed.

Nit — Windows 11 border with Frameless off. Kept by maintainer decision (Frameless is to become the only mode); the body and window-chrome.md now say the border follows the theme with Frameless on or off.

Nit — WM_NCHITTEST doc claim. window-chrome.md now says edge resize on this path rests on Qt 6.12's hit-test alone, with FramelessResizer off, and to recheck it after a Qt bump.

Nit — Windows 11 unverified. Still unchecked in the test plan; it needs a Windows 11 host.

Option 2's general colouring benefit (native menus/scrollbars following the theme) is not covered by this PR; it was dropped with option 2 and is left to the maintainer.

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue fit

Yes. #6266 asks for one thing — no light strip on the left, right and bottom edges of the main window under the expanded chrome on Windows 10 — and the diff addresses exactly that, with the strongest evidence I have seen on a cosmetic fix: the bridge luminance readback in this thread (255 → 26/17/16 on the three edges, Frameless-off unchanged at 38) measures the reported symptom rather than arguing it from source. The diagnosis also matches the thread's own correction history: option 2 (setColorScheme → DWMWA_USE_IMMERSIVE_DARK_MODE) was tried first and did not remove the strip, which is consistent with the inset being non-client geometry Qt keeps rather than a DWM-coloured border.

Requirements not addressed, all explicitly out of scope in the body: Snap Layouts / HTMAXBUTTON (#6224), and Windows 11 verification of the new border colour (still unchecked in the test plan).

Scope

File / group What it changes Claimed? Verdict
src/gui/MainWindow.cpp/.h nativeEvent WM_NCCALCSIZE handler, applyWindowsFrameColor, the themeChanged connect, one comment at applyWindowsCaptionStyles Yes In scope
src/gui/WindowChrome.h claimsWholeWindowAsClient() predicate Yes In scope
CMakeLists.txt target_link_libraries(AetherSDR PRIVATE dwmapi), Windows-only Yes (needed by DwmSetWindowAttribute) In scope
docs/architecture/window-chrome.md Describes both halves + the edge-resize dependency Yes In scope
tests/unified_title_bar_test.cpp Predicate + zero-frame windowRectForClient checks Yes In scope

Everything in the diff is explained by the issue. No CHANGELOG.md entry (correct). MainWindow.cpp is compiled only by the AetherSDR target (CMakeLists.txt:1217), so the dwmapi link sits on the one target that needs it and no test target needs it added. No settings keys, no protocol surface, no new public API beyond one inline predicate. No socket test added or modified in this PR.

The one scope item worth naming is the Windows 11 DWMWA_BORDER_COLOR with Frameless Window off: that recolours the system frame on a platform/path where no defect was reported. It is disclosed in the body and in window-chrome.md, and @ten9876's comment records it as kept by maintainer decision ("Frameless is to become the only mode"), so I am not re-litigating it — just flagging that it is the only user-visible change here that the linked issue does not require.

Blockers

None. I could not construct a case where the new code is wrong on its own terms, and the two risk items below are already named in the thread rather than hidden.

Nits (non-blocking)

  • src/gui/MainWindow.cpp:3798 — the load-bearing SWP_FRAMECHANGED is conditional. The comment you added at :3795 names the dependency correctly:
        // On Windows 10 this frame change is also what routes WM_NCCALCSIZE
        // through nativeEvent() before setFramelessWindow() and the startup
        // re-apply measure the client rect; Qt answered it at creation.
        if (style != desired) {
    desired is derived from style, so the whole block is skipped whenever the HWND already carries WS_SYSMENU|WS_MINIMIZEBOX|WS_MAXIMIZEBOX. Today Qt's recreate drops them, so it fires — but if a future Qt creates the window with those bits already set, no WM_NCCALCSIZE reaches nativeEvent() on the startup path, and both the strip and the zero-frame measurement that restoreNativeClientRect() depends on revert silently, with nothing failing loudly. Reasoned from code, not reproduced. Cheap to make unconditional on the path that needs it:
        if (style != desired) {
            SetWindowLongPtr(hwnd, GWL_STYLE, desired);
        }
        // Windows 10: re-post the frame change unconditionally — this is what
        // routes WM_NCCALCSIZE through nativeEvent() (Qt answered it at
        // creation), and the client-rect restore measures the result.
        if (style != desired
            || WindowChrome::claimsWholeWindowAsClient(windowFlags(),
                                                       QOperatingSystemVersion::current())) {
            SetWindowPos(hwnd, nullptr, 0, 0, 0, 0,
                         SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOOWNERZORDER
                         | SWP_NOACTIVATE | SWP_FRAMECHANGED);
        }
  • docs/architecture/window-chrome.md:75-76 still says Qt owns the resize borders. - **Windows:** Qt owns the expanded frame, DWM integration (shadow, rounded corners) and the resize borders; — the paragraph you add below it says that on Windows 10 those borders are now client area. Worth a half-sentence qualifier on line 75 so the two readings do not conflict for the next person.
  • src/gui/MainWindow.cpp:3900-3903 — an ~8 px input band now sits over real content on Windows 10. If Qt's hit test answers HTLEFT/HTRIGHT/HTBOTTOM from the client edges as the body states, the resize band is now inside the client area, which grew by ~16 px horizontally. The outer 8 px of the layout at the left, right and bottom edges therefore becomes input-deaf for resize — i.e. the bottom slice of the status-bar row (TNF/CWX/ASR/DVK) and the leftmost pixels of the applet rail. This is a restoration of the pre-#6198 behaviour, not a new class of problem, and I cannot measure it headless. One click on a status-bar toggle near its bottom edge on the Win10 box would settle it.
  • src/gui/MainWindow.cpp:3882 — DwmSetWindowAttribute's HRESULT is dropped. Cosmetic-only on failure, but house style checks returns; a qWarning under the GUI category would make a future SDK/OS surprise visible instead of silent.
  • The ruling reversal is recorded in the issue thread but not in the PR body. Comment 6049058828 reads "No nativeEvent and no hit-test changes in this PR. #6224 decides that seam separately… If option 2 doesn't remove it on Win10, stop and report back here before reaching for nativeEvent." @ten9876 has since confirmed the nativeEvent route as a second maintainer, so the decision is settled — but the PR body presents the handler without mentioning that it supersedes a standing ruling or that option 2 was dropped. One line in the body (and that option 2's general benefit — native menus/scrollbars following the theme — is now unowned) keeps the record readable from the PR alone.
  • Pre-existing, noted only because this PR re-enters the area: WindowGeometryRestore.h is still included at src/gui/MainWindow.cpp:15, but I found no call into it from that file — the #4328 re-anchor #6198 deleted. Its own test still passes, so nothing is broken; it looks like the helper is now live only in its test. Not this PR's to fix.

Seconding the one item I would want green before merge

@ten9876 already asked for it, and my own read lands in the same place, so treat this as a second vote rather than a new finding: the unchecked test-plan box "a maximized relaemax relaunch then un-maximize" is the one path with no corrective. nativeClientRectRestorable() (src/gui/MainWindow.cpp:3835) excludes the maximized state, so a maximized exit takes the doc.remove(role) branch in saveNativeClientRect() and the relaunch falls back to Qt's restoreGeometry() alone — and that is exactly where Qt's style-derived fullFrameMargins (~8 px on L/R/bottom) now disagree with a real inset of zero. That combination is the historical "won't keep its position" frame-margin offset. Reasoned from code; I cannot run it.

What I tried to break

  • wParam == 0. MainWindow.cpp:3891 requires msg->wParam before the reinterpret_cast<NCCALCSIZE_PARAMS*>, so the wParam == FALSE case (where lParam is a bare RECT) falls through to QMainWindow::nativeEvent. Correct per the Win32 contract, and the cast can't misread a plain RECT.
  • The Windows 11 boundary. claimsWholeWindowAsClient is os < QOperatingSystemVersion::Windows11 (= 10.0.22000) and the test pins both sides (19045 on, 22000 off). I checked the collateral cases: Server 2022 (10.0.20348) takes the Win10 path, which matches its frame style; Server 2025 (26100) takes the Win11 path. QOperatingSystemVersion::current() reads the real build via RtlGetVersion, so an app-compat shim can't flip it.
  • Fullscreen and maximize transitions. The inset is IsZoomed(msg->hwnd) && !isFullScreen(); insetting all four sides while maximized is right because Windows inflates a maximized window rect by the frame on all four sides, so the result is the work area — which is also why this does not break an auto-hide taskbar. SM_CXPADDEDBORDER has no CY counterpart, so using it on both axes (fixed in 4c1e533c3) is correct.
  • Sibling surfaces. WindowChrome::configure() is called only from MainWindow (:1139, :10067); every other frameless top-level (PersistentDialog, FramelessMessageBox, pop-out containers) takes FramelessWindowHint, which leaves no non-client border to paint light. So there is no second window carrying the #6266 strip and no sibling fix left undone.
  • Double hit-testing. FramelessResizer bails at FramelessResizer.cpp:296 (if (!(m_window->windowFlags() & Qt::FramelessWindowHint))), and chromeFlags() clears that hint under the expanded chrome — so the app-side resizer cannot fight Qt's hit test on this path. The flip side is that it is also no safety net, which is why your doc line telling the next person to recheck edge resize after a Qt bump is the right hedge.
  • Geometry write sites. Every restoreGeometry()/saveGeometry() on the main window is paired with the #6303 native client-rect call (:3963/:3968, :10266/:10268, :10348/:10356, :4177/:4183, :4198/:4200, :10224/:10226), and the one unpaired restoreGeometry() at :2598 runs pre-show and is explicitly superseded by reapplyStartupGeometryAfterShow(). So the measured-rect path, which is immune to Qt's margin belief, covers all of them except the maximized case above.
  • Attacking the test. tests/unified_title_bar_test.cpp:143-161 is thin — two of the four checks are close to the predicate restated — but it does lock the one piece of real policy (the 22000 boundary) and, as the body claims, mutating < to <= or dropping the ExpandedClientAreaHint term fails it. The nativeEvent arithmetic itself has no socket-free seam (Win32 message + live HWND), so I would not ask for a test there; the bridge luminance readback is the honest coverage for it.
  • Could not check: that Qt 6.12's handleNonClientHitTest answers the border codes from the client edges. That is the load-bearing assumption of the whole approach and I have no Qt source or Windows host here — your hand test of all four edges and corners is the only evidence, and it is the right kind.
  • Security audit (CodeGuard): ran as required. All 46 findings are false positives on pre-existing code — C++ reference/lambda &, markdown backticks inside // comments, /proc/self/status, the 224.0.0.1 WSJT-X multicast default, and a "CNN override" DSP comment. None falls on a line this PR adds, and the PR's own new code is clean (wParam gate before the cast, matching sizeof on the COLORREF).
  • CI: at the time of this review every job on 4c1e533c3 is queued or in progress (build, check-macos, check-windows, Static checks; only "Sanitizer option configures" has finished, green). check-windows is the sole compile proof for GetDpiForWindow, GetSystemMetricsForDpi, dwmapi and the QOperatingSystemVersion include, and no test lane compiles MainWindow.cpp — so that one job is worth watching before merge rather than assuming.

Everything above is reasoned from the code in the PR head checkout; I have no build, no test run and no Windows host, so nothing here was reproduced at runtime.

Recommendation

Approve with nits. The scope is clean, the diagnosis is backed by a measurement rather than an argument, the predicate is factored out and pinned, the risky platform assumption is documented with a recheck trigger, and the two maintainer-level calls (re-entering nativeEvent, the Windows 11 border with Frameless off) are both already ruled on in the thread. Next steps I would want: the Win10 geometry readback @ten9876 asked for — specifically the maximized-relaunch-then-un-maximize box — plus the Windows 11 border item, and optionally the SWP_FRAMECHANGED nit above. Nice work tracking this from a wrong first hypothesis to a measured fix.


🤖 aethersdr-agent · cost: $9.9965 · model: claude-opus-5

@aethersdr-agent

Copy link
Copy Markdown
Contributor

Cisco CodeGuard — static analysis of this PR (51 finding(s))

  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:9563
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:9675
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:9794
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:9795
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:9796
  • [HIGH] CS-VLN-PATH-PROC — Vulnerability: CS-VLN-PATH-PROC in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:6095
  • [HIGH] CS-VLN-PATH-PROC — Vulnerability: CS-VLN-PATH-PROC in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:6174
  • [CRITICAL] CS-VLN-CMDI-AMP — Vulnerability: CS-VLN-CMDI-AMP in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:522
  • [CRITICAL] CS-VLN-CMDI-AMP — Vulnerability: CS-VLN-CMDI-AMP in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:552
  • [CRITICAL] CS-VLN-CMDI-AMP — Vulnerability: CS-VLN-CMDI-AMP in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:555
  • [CRITICAL] CS-VLN-CMDI-AMP — Vulnerability: CS-VLN-CMDI-AMP in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:577
  • [CRITICAL] CS-VLN-CMDI-AMP — Vulnerability: CS-VLN-CMDI-AMP in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:3611
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:1276
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:1800
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:1892
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:1969
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:2011
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:2046
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:2325
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:3553
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:3644
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:6037
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:7843
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:7908
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:7982
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:8067
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:8248
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:8451
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:9664
  • [LOW] CS-PII-IP — PII detected: ip_address in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:6787
  • [CRITICAL] CS-INJ-role_override — Prompt injection: role override attempt in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:11619
  • [CRITICAL] CS-INJ-role_override — Prompt injection: role override attempt (detected after NFKC normalization) in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:11619
  • [HIGH] CS-INJ-homoglyph — Mixed Unicode scripts detected (possible homoglyph attack) in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6269/src/gui/MainWindow.cpp:0
  • [CRITICAL] CS-VLN-CMDI-AMP — Vulnerability: CS-VLN-CMDI-AMP in src/gui/MainWindow.h /tmp/aetherclaude/pr-6269/src/gui/MainWindow.h:267
  • [CRITICAL] CS-VLN-CMDI-AMP — Vulnerability: CS-VLN-CMDI-AMP in src/gui/MainWindow.h /tmp/aetherclaude/pr-6269/src/gui/MainWindow.h:268
  • [CRITICAL] CS-VLN-CMDI-AMP — Vulnerability: CS-VLN-CMDI-AMP in src/gui/MainWindow.h /tmp/aetherclaude/pr-6269/src/gui/MainWindow.h:1536
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.h /tmp/aetherclaude/pr-6269/src/gui/MainWindow.h:304
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.h /tmp/aetherclaude/pr-6269/src/gui/MainWindow.h:473
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.h /tmp/aetherclaude/pr-6269/src/gui/MainWindow.h:854
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.h /tmp/aetherclaude/pr-6269/src/gui/MainWindow.h:883

Automated static scan by Cisco DefenseClaw CodeGuard on the changed files. Advisory — some may be false positives; the review above verifies them.


🤖 aethersdr-agent · cost: $10.2840 · model: claude-opus-5

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at 4c1e533c3 — auto-merge deliberately NOT armed. The maintainer arms it after @chibondking posts the Windows 10 geometry readback below.

Maintainer decisions (@ten9876): the nativeEvent / WM_NCCALCSIZE approach is signed off despite the #6266 ruling; the branch is rebased onto main after #6310; the Windows 11 border recolour stays with Frameless off (Frameless is to become the only mode) and the body now says so.

What changed in this pass:

  • Rebased onto abdd4f2e (#6310); the MainWindow.cpp/.h conflicts kept both sides — #6310's native client-rect helpers and this PR's applyWindowsFrameColor() / nativeEvent(). All five commits signed, verified=true.
  • e9d7f67a0: comment at applyWindowsCaptionStyles() naming the dependency — its SWP_FRAMECHANGED is what routes WM_NCCALCSIZE through nativeEvent() before #6310's setNativeClientRect() measures the frame.
  • 4c1e533c3: engage condition extracted to WindowChrome::claimsWholeWindowAsClient() (pure, host-tested); maximized inset uses SM_CYSIZEFRAME for top/bottom; window-chrome.md says edge resize rests on Qt 6.12's hit-test alone.

The Frameless off→on growth from my review: retired by #6310 rather than by a new guard. setFramelessWindow() now restores the pre-toggle client rect with setNativeClientRect() after show(), sized from the frame Windows reports at that moment (zero on Windows 10 under this handler). A post-show setGeometry(geom) would re-apply Qt's mis-margined geometry and undo it, so none was added. Startup, minimal-mode and full-mode restores are followed by the same native restore (MainWindow.cpp:3968, :10268, :10356), which also covers the aethersdr-agent's #4328 re-anchor concern for the normal window state.

Verified: unified_title_bar_test passes on Linux (Qt 6.12, offscreen); the predicate checks fail under a <→<= mutation and under dropping the ExpandedClientAreaHint term; a zero-frame windowRectForClient() case is pinned. All static gates pass locally (engine boundary, touchpoint manifest, capability records, command plane, test registration, colour audit ratchet +0). Required checks green on this head, including check-windows, which is the only compile of the Windows lines.

Not verified — needs Windows: none of the Win32 behaviour ran here. @chibondking, please post the bridge geometry readback on the Win10 box for: Frameless off → on → off; three relaunch cycles from a normal window; a maximized relaunch then un-maximize (the one path the native restore skips); View → Minimal Mode twice with the window filling the work area. Windows 11's border colour is also still untested.

CI ran against main at abdd4f2e; main has since gained only #6312 (theme/stylesheet files), which does not touch these files.

@ten9876
ten9876 merged commit 22e4a50 into aethersdr:main Oct 9, 2026
5 checks passed
@chibondking

Copy link
Copy Markdown
Collaborator Author

@ten9876: here are the readbacks you asked for, on head 4c1e533c3.

Setup: Windows 10 22H2, fork Windows Installer build of this branch (run 37889684289, Qt 6.12), in an isolated settings profile (AETHER_SETTINGS_DIR, no radio, UI scale 100%), with Frameless on unless stated. The taskbar auto-hides, so the work area is the full 1366×768 screen.

Every rect is read from Windows (GetWindowRect; GetClientRect + ClientToScreen; DWM extended frame bounds), in physical px. Frameless and Minimal Mode were switched with invoke … trigger, maximize and restore with the bridge's window verb, and relaunches were WM_CLOSE to the main window followed by a new process.

Two scenarios pass (1 and 2). Two show a gap (3 and 4); details below.

1. Frameless off → on → off ✅

Step Window Client Frame (L T R B)
Off −7,−6 1380×781 1,25 1364×742 8 31 8 8
→ On 1,25 1364×742 1,25 1364×742 0 0 0 0
→ Off −7,−6 1380×781 1,25 1364×742 8 31 8 8

The client is identical at every step, and with Frameless on the frame is zero (window rect = client rect).

2. Three relaunches from a normal window ✅

Launch Client
Before close 1,25 1364×742
0 1,25 1364×742
1 1,25 1364×742
2 1,25 1364×742
3 1,25 1364×742

3. Maximized relaunch, then un-maximize ⚠️ drifts, and compounds

Step Client
Normal before maximizing 1,25 1364×742
Maximized 0,0 1366×768 (window −8,−8 1382×784, 8 px inset)
Closed maximized → relaunched 0,0 1366×768, maximized ✅
Un-maximize 6,36 1354×726
Round 2: maximize → close → relaunch → un-maximize 11,47 1354×710

Each "close maximized, relaunch, un-maximize" round moves the normal rect +5,+11 and makes it 16 px shorter (the width was also 10 px narrower after the first round). This is the path the native restore skips, so the shave comes from Qt's saved normal geometry. While maximized after relaunch, Qt itself reported geometry() −3,3 1372×768.

4. Minimal Mode twice, window filling the work area ⚠️ the full-mode size isn't kept

The window was sized natively (SetWindowPos) to the work area, 0,0 1366×768, in the normal state.

Step Client
Normal, filling the work area 0,0 1366×768
Minimal Mode on #1 0,0 260×768
Minimal Mode off #1 1,25 1364×742
Minimal Mode on #2 0,0 260×768
Minimal Mode off #2 1,25 1364×742

The round trip is stable (no compounding), but coming back from Minimal Mode doesn't restore the work-area-filling size. It returns 1,25 1364×742, which is this profile's earlier saved normal rect, 2 px narrower and 26 px shorter. I haven't traced whether restoreNativeClientRect("fullMode") reads a value captured before the SetWindowPos resize, or whether Qt's checkRestoredGeometry() clamps a window that exactly fills the available area. A resize by drag rather than SetWindowPos may behave differently; I didn't test that.

Not covered: Windows 11, and a work area with a visible (non-auto-hide) taskbar.

ten9876 pushed a commit that referenced this pull request Oct 9, 2026
…ow on (#6272). Principle VIII. (#6328)

Fixes #6272.

## What

On Windows with **Frameless Window on**, the main window's ☰ menu and
the panadapter's right-click menu flashed open and closed immediately,
and double-click to tune did nothing. All three work with Frameless off.
This regressed with the unified title bar (#6198) and is still on `main`
after #6269.

## Root cause

With Frameless on, the main window uses Qt's expanded client area with
`CustomizeWindowHint` and **no** title or caption-button hints
(`WindowChrome::chromeFlags`). For exactly those flags, Qt 6.12 answers
`WM_NCHITTEST` in the `else if (CustomizeWindowHint)` branch of
`QWindowsWindow::handleNonClientHitTest`
(`qwindowswindow.cpp:3415-3427`):

- It reads the live mouse buttons with `GetAsyncKeyState`.
- Whenever they differ from the last hit test (`oldMouseButtonState`, a
**function-static shared by every window**), it **synthesizes a mouse
press or release** with
`QWindowSystemInterface::handleMouseEvent<SynchronousDelivery>` at the
cursor, anywhere in the window.

Windows sends `WM_NCHITTEST` around a real click, so the click arrives
twice:
- The ☰ menu opens on the first press (`popup()` on `pressed`), the
second press lands outside it and dismisses it, and the replay guard
stops it reopening.
- The panadapter's `exec()` context menu is closed the same way.
- The extra press/release pairs break double-click detection, so
double-click to tune never fires.

This also explains two puzzles from the reproduction on the issue:
- **The agent automation bridge's own click verbs never reproduced it:**
they deliver Qt events directly and never cause a `WM_NCHITTEST`.
- **One earlier real-click run passed:** the shared static makes the
outcome depend on hit-test timing.

## How

`QWindowsContext` runs the window's own `nativeEvent()` before its
hit-test switch (`qwindowscontext.cpp:858` vs `:1019`). Under the
expanded client area, `MainWindow::nativeEvent()` now answers
`WM_NCHITTEST` itself, with no synthetic events:
- the resize codes (`HTLEFT` … `HTBOTTOMRIGHT`) on a band of
`SM_CXSIZEFRAME + SM_CXPADDEDBORDER` at the window edges, the same band
Qt used;
- no band while maximized or fullscreen;
- `HTCLIENT` everywhere else.

`WindowChrome::expandedFrameHit()` is the pure part. `window-chrome.md`
now says edge resize on this path rests on this handler. It applies on
Windows 10 and 11 alike, since the Qt branch doesn't depend on the OS
version.

**Popped-out panadapters** use plain `Qt::FramelessWindowHint`, which Qt
hit-tests on its ordinary path. They aren't affected and aren't changed
(proven below).

+114 / −6 in 4 files; every behaviour change is inside `#ifdef
Q_OS_WIN`.

## Testing

### Proof with the agent automation bridge, using real OS input

Run on Windows 10 22H2 (Intel HD 520), FLEX-6600 connected, AetherSDR UI
scale 85%, your real settings with Frameless Window on. Builds came from
the fork's Windows Installer workflow (Qt 6.12):
- **Before:** `main` `e1785e78`, run 37902283037.
- **After:** this branch `1c51a580`, run 37902771818.

Method:
- Clicks are **`SendInput`** at positions from `dump_tree`, with
physical = Qt logical × 0.85.
- Popup detection: the process's visible `Qt…PopupSaveBits` windows, 300
ms after each click.
- Tune detection: the slice frequency read back through the bridge after
each double-click.
- The popped-out pan was made with `pan float` and targeted by its
window title. Its click points sit below the slice flag; my first
popped-out run was invalid because the click landed on the flag.

| Target | Action | Before (main) | After (this PR) |
|---|---|---|---|
| Main window | ☰ menu stays open | **0 / 5** | **5 / 5** |
| Main window | Panadapter right-click menu stays open | **0 / 5** | **5
/ 5** |
| Main window | Double-click tunes the slice | **0 / 5** | **5 / 5** |
| Popped-out pan | Right-click menu stays open | 5 / 5 | 5 / 5 |
| Popped-out pan | Double-click tunes the slice | 5 / 5 | 5 / 5 |

**Edge resize still works on the fix build.** Real `SendInput` drags,
with the window rect from `GetWindowRect`:

| Edge | `WM_NCHITTEST` there | Drag | Window before → after |
|---|---|---|---|
| Bottom | 15 (`HTBOTTOM`) | down 30 | 1240×451 → **1240×481** |
| Bottom | 15 | up 30 | 1240×481 → **1240×451** |
| Left | 10 (`HTLEFT`) | left 30 | 327 x, 1210 w → **297 x, 1240 w** |
| Left | 10 | right 30 | 297 x, 1240 w → **327 x, 1210 w** |

Three earlier drags didn't resize because the press landed on another
window (the floating pan, then the floating TX Controls).
`WindowFromPoint` confirmed that, and those drags are excluded.

### Unit test (local, Qt 6.12.0 pinned, MSVC 2022, offscreen)

The new `expandedFrameHit` checks in `unified_title_bar_test` all pass:
- client area at the ☰ corner, mid-window, and one pixel past the band;
- every edge and corner;
- no band while maximized;
- a point outside the window.

**Mutation-checked:**
- An off-by-one on the band fails "the first pixel past the band is
client area".
- An always-client answer fails every edge and corner check.

One existing check in the same binary, *"four radios fit whole in an
1800 px bar"*, fails in my local Windows offscreen run **with and
without this change** (also on plain `main`). CI on `main` is green.

### Not verified

- **Windows 11.** The Qt branch is OS-independent, but only Windows 10
was available.
- **Snap Layouts** (#6224). Unchanged: nothing returned `HTMAXBUTTON`
before either.
- **Top-edge resize** under the title bar. The band covers the top too,
as Qt's did; only the bottom and left edges were dragged.

### Agent automation bridge: gaps found

- **Click verbs that go through Windows.** The bridge's `clickAt`,
`rightClick` and `doubleClick` deliver Qt events and skip
`WM_NCHITTEST`, so they can't reproduce input bugs below Qt. A
real-input mode (`SendInput` at a widget's centre) would let the bridge
prove this class of bug on its own.
- **Non-ASCII targets.** The MCP wrapper garbles non-ASCII target names
(`Theme Editor…`); plain tooltips are a workaround.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: CJ Johnson <chibondking@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Windows 10: white 1–2 px outline along left, right and bottom window edges in 26.10.1 (post-#6198 chrome retrofit)

2 participants