Files
qbz/crates/qbz-qt/cxx/win_shell.cpp
T
vicrodh 415cba57de feat(qt): custom title bar on Windows, with a native hit test
Three requirements -- no title bar, working clicks, and window tiling -- and no
combination of public Qt flags delivers all three. Each attempt was measured on
the box rather than reasoned about, and each one taught something the plan had
wrong.

FramelessWindowHint, the previous arm, removes WS_THICKFRAME. THAT is what the
shell keys tiling on -- not WS_CAPTION, which this window never has in any
arrangement -- so Win+Arrow and Aero Snap were dead, along with the DWM shadow
and the Win11 rounded corners. "Hide title bar" was not making the app refuse
the shortcuts; it had stopped being a window the shell could tile.

Adding CustomizeWindowHint restored tiling and stopped Qt painting its own
minimise/maximise/close cluster over the QBZ header. It also made Qt answer
WM_NCHITTEST with HTNOWHERE for EVERY pixel -- centre, header, player bar and
sidebar all returned 0 -- and Windows delivers no mouse input to HTNOWHERE, so
the whole app became unclickable while still running: the tray menu still drove
playback. Dropping the hint fixed clicks and brought the drawn buttons back,
duplicated on top of the ones QBZ already draws itself.

So the hint stays and cxx/win_shell.cpp answers the hit test instead. The
filter defers to DefWindowProcW, which is what an ordinary sizable caption-less
window would return: the eight edge codes near the frame, HTCLIENT everywhere
else. It invents no geometry. Its guard is narrow on purpose -- sizable AND
caption-less, which is exactly the custom-chrome main window -- so menus,
tooltips, popups and any window showing the system title bar keep Qt's own
answer.

Measured after: HTCLIENT at the centre, in the title band, at the edges and in
the corner; Win+Left resized the window to 1720 px, exactly half of a 3440 px
display.

Dragging is unaffected and stays with QML's startSystemMove(): with no caption
there is no HTCAPTION band to inherit, which is the arrangement HeaderBar was
already written for.

NOT restored, and not restorable from flags: the Win11 Snap Layouts hover
flyout. Windows asks for it by hit-testing the maximise button as HTMAXBUTTON,
and QBZ's is a QML item inside the client area. Reporting HTMAXBUTTON over that
one rect from this filter would do it -- named as future work rather than
claimed.

cc
2026-08-27 16:52:17 -06:00

133 lines
5.1 KiB
C++

// Windows shell helpers with no Q_OBJECT of their own.
//
// Compiled on EVERY platform on purpose: the bodies are portable Qt and only
// the caller's use of the WId is Windows-specific. Keeping it unconditional
// means the file is type-checked by the Linux and macOS builds too, which is
// where a signature drift would otherwise go unnoticed until a Windows CI run.
#include <QtCore/QObject>
#include <QtGui/QGuiApplication>
#include <QtGui/QSessionManager>
#include <QtGui/QWindow>
#ifdef _WIN32
#include <QtCore/QAbstractNativeEventFilter>
#include <QtCore/QByteArray>
#include <QtCore/QCoreApplication>
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#endif
// The top-level window's native handle, or nullptr before one is shown.
//
// MUST NOT be called before exec(): QWindow::winId() CREATES the platform
// window as a side effect, so an early call would hand back a handle for a
// window Qt has not finished setting up, and the real one would then get a
// different HWND. Callers hop to the GUI thread first; winId() is not
// thread-safe.
extern "C" void *qbz_main_window_hwnd()
{
const auto windows = QGuiApplication::topLevelWindows();
for (QWindow *w : windows)
if (w->isVisible() && w->type() == Qt::Window)
return reinterpret_cast<void *>(w->winId());
return nullptr;
}
#ifdef _WIN32
namespace {
// Give the custom-chrome window a real hit-test back.
//
// MEASURED, not assumed. With `Qt::CustomizeWindowHint` -- which is the only
// way to stop Qt painting its own minimise/maximise/close cluster on top of
// QBZ's header -- Qt answers WM_NCHITTEST with HTNOWHERE for the ENTIRE
// window: centre, header, player bar and sidebar all returned 0. Windows
// delivers no mouse input to HTNOWHERE, so the app looked frozen while it was
// running perfectly and the tray menu still drove playback.
//
// Without that hint Qt hit-tests correctly but populates the default title
// hints, and its customized-title path draws the buttons over our header. No
// combination of public flags gives all three of: no drawn buttons, working
// clicks, and the WS_THICKFRAME that Windows tiling needs.
//
// So the flags keep CustomizeWindowHint and this filter answers the hit test
// the way an ordinary sizable, caption-less window would: DefWindowProcW
// returns the eight edge codes near the frame and HTCLIENT everywhere else.
// Nothing here invents geometry -- it defers to the same code every other
// borderless-resizable window uses.
//
// Dragging is unaffected and stays with QML's `startSystemMove()`: with no
// caption there is no HTCAPTION band to inherit, which is the arrangement the
// header was already written for.
class QbzNcHitTestFilter : public QAbstractNativeEventFilter
{
public:
bool nativeEventFilter(const QByteArray &type, void *message, qintptr *result) override
{
if (type != QByteArrayLiteral("windows_generic_MSG"))
return false;
MSG *msg = static_cast<MSG *>(message);
if (!msg || msg->message != WM_NCHITTEST)
return false;
// NARROW ON PURPOSE: only a window that is sizable AND caption-less,
// which is exactly the custom-chrome main window. Menus, tooltips and
// popups have no WS_THICKFRAME; a window showing the system title bar
// has WS_CAPTION and must keep Qt's own answer. Getting this wrong
// would break hit-testing everywhere instead of fixing it in one
// place.
const LONG_PTR style = GetWindowLongPtrW(msg->hwnd, GWL_STYLE);
if ((style & WS_THICKFRAME) == 0 || (style & WS_CAPTION) == WS_CAPTION)
return false;
*result = static_cast<qintptr>(
DefWindowProcW(msg->hwnd, WM_NCHITTEST, msg->wParam, msg->lParam));
return true;
}
};
QbzNcHitTestFilter *g_hit_filter = nullptr;
} // namespace
#endif // _WIN32
// Install the hit-test filter. Idempotent; a no-op off Windows.
extern "C" void qbz_install_hittest_filter()
{
#ifdef _WIN32
if (g_hit_filter)
return;
g_hit_filter = new QbzNcHitTestFilter;
QCoreApplication::instance()->installNativeEventFilter(g_hit_filter);
#endif
}
// WM_QUERYENDSESSION -> QGuiApplication::commitDataRequest, via Qt's Windows
// session manager. It fires BEFORE Windows decides whether to proceed with the
// logoff and Qt blocks until the handler returns, which makes it the one safe
// place to persist synchronously: anything deferred to a queued connection or
// another thread races the process being killed.
extern "C" void qbz_install_commit_data_handler(void (*cb)())
{
#ifndef QT_NO_SESSIONMANAGER
// Once. Qt::UniqueConnection does NOT deduplicate lambdas -- each connect
// makes an independent connection -- so a second install would run the
// session persist twice on every logoff.
static bool installed = false;
if (installed)
return;
installed = true;
QObject::connect(qApp, &QGuiApplication::commitDataRequest, qApp,
[cb](QSessionManager &) { cb(); });
#else
// A Qt built without the session manager has no commitDataRequest at all,
// and this file compiles on every platform.
(void)cb;
#endif
}