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
This commit is contained in:
4 files changed
+157
-1
No files matched your search
@@ -10,6 +10,16 @@
|
||||
#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.
|
||||
//
|
||||
@@ -27,6 +37,75 @@ extern "C" void *qbz_main_window_hwnd()
|
||||
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
|
||||
|
||||
@@ -140,11 +140,66 @@ ApplicationWindow {
|
||||
// the frame, and the reference never draws its own there
|
||||
// ("Linux only — macOS keeps the native traffic lights",
|
||||
// WindowControls.slint:1-2).
|
||||
// WINDOWS takes the macOS shape, not the Linux one, and the difference is
|
||||
// the whole feature (W27).
|
||||
//
|
||||
// `Qt.FramelessWindowHint` removes WS_THICKFRAME, and THAT is what the
|
||||
// shell keys tiling on: Win+Arrow and Aero Snap by dragging to an edge both
|
||||
// stop, along with the DWM drop shadow and the Win11 rounded corners. So
|
||||
// "hide title bar" was not making the app refuse the shortcuts -- it had
|
||||
// stopped being a window the shell could tile.
|
||||
//
|
||||
// NOT restored, and it cannot be from flags alone: the Windows 11 Snap
|
||||
// Layouts flyout that appears when hovering a maximise button. Windows asks
|
||||
// for it by hit-testing that pixel as HTMAXBUTTON, and QBZ's maximise
|
||||
// button is a QML item inside the client area, so it answers HTCLIENT.
|
||||
// Wiring it would mean reporting HTMAXBUTTON over that one rect from the
|
||||
// native filter -- deliberate future work, not something this arm does.
|
||||
//
|
||||
// Qt 6.9's Windows plugin already implements WM_NCCALCSIZE -> 0,
|
||||
// DwmExtendFrameIntoClientArea, the eight resize edges and
|
||||
// safeAreaMargins() for `ExpandedClientAreaHint` -- the same flags the
|
||||
// macOS arm uses. `CustomizeWindowHint` with no button hints then
|
||||
// suppresses Qt's own painted title cluster
|
||||
// (qwindowswindow.cpp :3561/:3600/:3624/:3645) while the window KEEPS
|
||||
// WS_CAPTION, so the shell still treats it as tileable.
|
||||
//
|
||||
// It also provides no HTCAPTION drag band of its own (:3332-3338: with
|
||||
// both isDefaultTitleBar and isCustomized false the whole title block is
|
||||
// skipped), which is why HeaderBar's QML `startSystemMove()` stays: it
|
||||
// issues SC_MOVE, and SC_MOVE drags DO snap. The eight QML resize grips
|
||||
// stay enabled too and do not fight the native edges -- Qt answers
|
||||
// WM_NCHITTEST first, so a press on the border never reaches QML.
|
||||
flags: QbzShell.systemTitleBar
|
||||
? Qt.Window
|
||||
: (QbzShell.isMacos
|
||||
? (Qt.Window | Qt.ExpandedClientAreaHint | Qt.NoTitleBarBackgroundHint)
|
||||
: (Qt.Window | Qt.FramelessWindowHint))
|
||||
: (QbzShell.isWindows
|
||||
// Both halves were MEASURED on the box, and neither is
|
||||
// optional:
|
||||
//
|
||||
// WITHOUT CustomizeWindowHint, Qt populates the default title
|
||||
// hints and its customized-title path DRAWS minimise/maximise/
|
||||
// close over the QBZ header.
|
||||
//
|
||||
// WITH it, Qt answers WM_NCHITTEST with HTNOWHERE for every
|
||||
// pixel -- centre, header, player bar and sidebar all returned
|
||||
// 0 -- and Windows sends no mouse input to HTNOWHERE, so the
|
||||
// whole app becomes unclickable while still running.
|
||||
//
|
||||
// No public flag combination gives no-buttons + clicks +
|
||||
// the WS_THICKFRAME that tiling needs, so the hint stays and
|
||||
// `win_shell::install_hittest_filter()` answers the hit test
|
||||
// instead. What this arm must NOT do is drop the hint: that
|
||||
// trades the buttons back in.
|
||||
//
|
||||
// Frameless is not the answer either: it removes
|
||||
// WS_THICKFRAME, and THAT is what Win+Arrow and Aero Snap
|
||||
// actually key on -- not WS_CAPTION, which this window does
|
||||
// not have in either arrangement.
|
||||
? (Qt.Window | Qt.ExpandedClientAreaHint | Qt.NoTitleBarBackgroundHint
|
||||
| Qt.CustomizeWindowHint)
|
||||
: (Qt.Window | Qt.FramelessWindowHint)))
|
||||
|
||||
// THE OTHER HALF OF ExpandedClientAreaHint, and the piece that made the
|
||||
// flag look broken when it was not.
|
||||
|
||||
@@ -3521,6 +3521,12 @@ fn main() {
|
||||
#[cfg(target_os = "windows")]
|
||||
win_shell::install_commit_data_handler(on_session_commit_data);
|
||||
|
||||
// W27's other half. Main.qml asks for CustomizeWindowHint so Qt does not
|
||||
// paint its own button cluster over the QBZ header; this gives the window
|
||||
// back a hit test, which that hint otherwise answers as HTNOWHERE for
|
||||
// every pixel.
|
||||
#[cfg(target_os = "windows")]
|
||||
win_shell::install_hittest_filter();
|
||||
|
||||
let mut engine = QQmlApplicationEngine::new();
|
||||
|
||||
|
||||
@@ -10,6 +10,22 @@ use std::ptr::NonNull;
|
||||
unsafe extern "C" {
|
||||
fn qbz_main_window_hwnd() -> *mut c_void;
|
||||
fn qbz_install_commit_data_handler(cb: extern "C" fn());
|
||||
fn qbz_install_hittest_filter();
|
||||
}
|
||||
|
||||
/// Answer WM_NCHITTEST for the custom-chrome window the way an ordinary
|
||||
/// borderless-resizable window would.
|
||||
///
|
||||
/// Needed because `Qt::CustomizeWindowHint` -- the only way to stop Qt drawing
|
||||
/// its own window buttons over the QBZ header -- makes Qt return HTNOWHERE for
|
||||
/// the whole window, and Windows sends no mouse input to HTNOWHERE. See the
|
||||
/// long note in `cxx/win_shell.cpp`.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn install_hittest_filter() {
|
||||
// SAFETY: no arguments; installs one process-lifetime filter on the
|
||||
// QCoreApplication, and is idempotent on the C++ side. Called after
|
||||
// `QGuiApplication::new`, on the GUI thread.
|
||||
unsafe { qbz_install_hittest_filter() }
|
||||
}
|
||||
|
||||
/// The top-level window's native handle, or `None` before it is shown.
|
||||
|
||||
Reference in new issue
Block a user