[vlc-commits] [Git][videolan/vlc][master] 3 commits: qt: hit-test CSD buttons via registration and the QML-written rect
Felix Paul Kühne (@fkuehne)
gitlab at videolan.org
Sun Oct 18 11:47:35 UTC 2026
Felix Paul Kühne pushed to branch master at VideoLAN / VLC
Commits:
aa97da79 by Martin Knox at 2026-10-18T13:20:01+02:00
qt: hit-test CSD buttons via registration and the QML-written rect
Register the QML button items on the C++ side so the Windows
WM_NCHITTEST path can tell whether a button set is still alive.
containsScenePos() now requires at least one registered item to be
visible and enabled (the items' own flags) and checks the rect
written by updateRect(), keeping the top 2px reserved for the
resize handle. The fullscreen early-out in buttonAtMsgPoint() is
no longer needed: nothing stays registered there, and the rect is
cleared when the last item is destroyed so a destroyed set
cannot leave a stale hitbox.
- - - - -
cd0a1f18 by Martin Knox at 2026-10-18T13:20:01+02:00
qt: add CSD button hit-testing tests
Geometry comes from setRect(), matching what updateRect() writes at
runtime. The cases cover registration idempotency, destruction,
state reset when the last item is destroyed, own
visibility/enabled, and a child created under an already hidden
parent. Wired into both meson and automake.
- - - - -
429df92c by Martin Knox at 2026-10-18T13:20:01+02:00
qt: only show MenuTopbar CSD buttons when the toolbar menu is used
LocalTopbar shows the CSD buttons when the toolbar menu is off, so the
two top bars no longer register items for the same button.
- - - - -
8 changed files:
- modules/gui/qt/Makefile.am
- modules/gui/qt/maininterface/mainctx_win32.cpp
- modules/gui/qt/maininterface/qml/MenuTopbar.qml
- modules/gui/qt/meson.build
- + modules/gui/qt/tests/test_qt_csd_button_model.cpp
- modules/gui/qt/util/csdbuttonmodel.cpp
- modules/gui/qt/util/csdbuttonmodel.hpp
- modules/gui/qt/widgets/qml/CSDWindowButton.qml
Changes:
=====================================
modules/gui/qt/Makefile.am
=====================================
@@ -1860,6 +1860,25 @@ test_vlc_dialog_model_LDFLAGS = $(QT_QTEST_COMMON_ldflags)
check_PROGRAMS += test_vlc_dialog_model
TESTS += test_vlc_dialog_model
+# test_qt_csd_button_model
+check_PROGRAMS += test_qt_csd_button_model
+TESTS += test_qt_csd_button_model
+
+test_qt_csd_button_model_SOURCES = \
+ tests/vlc_stub_modules.cpp \
+ tests/test_qt_csd_button_model.cpp \
+ util/csdbuttonmodel.hpp \
+ util/csdbuttonmodel.cpp
+nodist_test_qt_csd_button_model_SOURCES = \
+ tests/test_qt_csd_button_model.moc \
+ util/csdbuttonmodel.moc.cpp
+BUILT_SOURCES += tests/test_qt_csd_button_model.moc
+CLEANFILES += tests/test_qt_csd_button_model.moc
+test_qt_csd_button_model_CPPFLAGS = $(QT_QTEST_COMMON_cppflags)
+test_qt_csd_button_model_CXXFLAGS = $(QT_QTEST_COMMON_cxxflags)
+test_qt_csd_button_model_LDADD = $(QT_QTEST_COMMON_ldadd)
+test_qt_csd_button_model_LDFLAGS = $(QT_QTEST_COMMON_ldflags)
+
test_qt_navigation_model_SOURCES = \
tests/vlc_stub_modules.cpp \
=====================================
modules/gui/qt/maininterface/mainctx_win32.cpp
=====================================
@@ -277,11 +277,7 @@ public:
// Map the point to client coordinates.
::MapWindowPoints(nullptr, msg->hwnd, &point, 1);
- const double scaleFactor = m_window->devicePixelRatio();
-
- //divide by scale factor as buttons coordinates will be in dpr
- const QPoint qtPoint {static_cast<int>(point.x / scaleFactor), static_cast<int>(point.y / scaleFactor)};
- auto button = overlappingButton(qtPoint);
+ auto button = buttonAtMsgPoint(msg);
if (!button)
{
if (!m_platformHandlesResize)
@@ -395,29 +391,35 @@ public:
case WM_NCLBUTTONUP:
case WM_NCLBUTTONDOWN:
{
- const bool pressed = (msg->message == WM_NCLBUTTONDOWN);
-
- // manually trigger button here, UI will never get click
- // signal because we have captured the mouse in non client area
+ CSDButton::ButtonType type{};
switch ( msg->wParam )
{
- case HTCLOSE:
- handleButtonActionExclusive(CSDButton::Close, pressed);
- break;
- case HTMINBUTTON:
- handleButtonActionExclusive(CSDButton::Minimize, pressed);
- break;
- case HTMAXBUTTON:
- handleButtonActionExclusive(CSDButton::MaximizeRestore, pressed);
- break;
+ case HTCLOSE: type = CSDButton::Close; break;
+ case HTMINBUTTON: type = CSDButton::Minimize; break;
+ case HTMAXBUTTON: type = CSDButton::MaximizeRestore; break;
default:
resetPressedState();
return false;
}
+ // manually trigger here: the mouse is captured in the non-client area, so the UI never gets a click signal
+ if (msg->message == WM_NCLBUTTONDOWN)
+ {
+ handleButtonActionExclusive(type, true);
+ // required for win7 compositor, otherwise this
+ // paints default min/max/close buttons
+ return true;
+ }
+
+ // Complete the click only when released over a visible button of
+ // the same type: a button hidden or destroyed between press and
+ // release must not complete the click.
+ const CSDButton *releaseButton = buttonAtMsgPoint(msg);
+ if (releaseButton && releaseButton->type() == type)
+ handleButtonActionExclusive(type, false);
+ else
+ resetPressedState();
- // required for win7 compositor, otherwise this
- // paints default min/max/close buttons
return true;
}
@@ -485,21 +487,18 @@ private:
SWP_NOSENDCHANGING | SWP_NOSIZE | SWP_NOZORDER);
}
- CSDButton *overlappingButton(const QPoint point)
+ // hit-tests the registered CSD button sets: any registered item
+ // visible+enabled, against the QML-written rect (see
+ // CSDButton::containsScenePos); nullptr when nothing matches
+ CSDButton *buttonAtMsgPoint(const MSG *msg)
{
- const auto& buttons = m_buttonmodel->windowCSDButtons();
- const auto it = std::find_if(buttons.begin(), buttons.end(), [&point](const auto& button)
- {
- QRect rect = button->rect();
- rect.setY(rect.y() + 2); // leave a small gap for top resize which is always within the caption area
- if (rect.contains(point))
- return true;
- return false;
- });
- if (it == buttons.end())
- return nullptr;
+ POINT point = { GET_X_LPARAM(msg->lParam), GET_Y_LPARAM(msg->lParam) };
+ ::MapWindowPoints(nullptr, msg->hwnd, &point, 1);
- return *it;
+ const double scaleFactor = m_window->devicePixelRatio();
+ const QPoint qtPoint { static_cast<int>(point.x / scaleFactor),
+ static_cast<int>(point.y / scaleFactor) };
+ return m_buttonmodel->buttonAt(qtPoint);
}
void hoverExclusive(CSDButton::ButtonType type)
=====================================
modules/gui/qt/maininterface/qml/MenuTopbar.qml
=====================================
@@ -40,7 +40,9 @@ T.ToolBar {
// Not strictly necessary to set
property PlaylistPane plListView: null
- property bool _showCSD: MainCtx.clientSideDecoration && !(MainCtx.intfMainWindow.visibility === Window.FullScreen)
+ property bool _showCSD: MainCtx.clientSideDecoration
+ && !(MainCtx.intfMainWindow.visibility === Window.FullScreen)
+ && MainCtx.hasToolbarMenu
hoverEnabled: true
=====================================
modules/gui/qt/meson.build
=====================================
@@ -1361,6 +1361,25 @@ if qt6_dep.found()
'dependencies': [qt6_dep, qt_extra_deps, qtest_qt6_dep]
}
+ vlc_tests += {
+ 'name': 'test_qt_csd_button_model',
+ 'sources': files(
+ 'tests/vlc_stub_modules.cpp',
+ 'tests/test_qt_csd_button_model.cpp',
+ 'util/csdbuttonmodel.hpp',
+ 'util/csdbuttonmodel.cpp'
+ ),
+ 'moc_sources': files(
+ 'tests/test_qt_csd_button_model.cpp'
+ ),
+ 'moc_headers': files(
+ 'util/csdbuttonmodel.hpp'
+ ),
+ 'suite': ['qt'],
+ 'include_directories' : qt_include_dir,
+ 'link_with': [libvlccore, libvlc],
+ 'dependencies': [qt6_dep, qt_extra_deps, qtest_qt6_dep]
+ }
vlc_tests += {
'name': 'test_qt_renderer_manager',
=====================================
modules/gui/qt/tests/test_qt_csd_button_model.cpp
=====================================
@@ -0,0 +1,212 @@
+#include <QTest>
+#include <QGuiApplication>
+#include <QQuickItem>
+
+#include "util/csdbuttonmodel.hpp"
+#include "maininterface/mainctx.hpp"
+
+// ---- link stubs for MainCtx symbols referenced by csdbuttonmodel.cpp ----
+// (test never clicks; CSDButtonModel is constructed with a null MainCtx)
+void MainCtx::requestInterfaceMaximized() {}
+void MainCtx::requestInterfaceNormal() {}
+void MainCtx::requestInterfaceMinimized() {}
+QWindow *MainCtx::intfMainWindow() const { return nullptr; }
+
+class TestQtCSDButtonModel : public QObject
+{
+ Q_OBJECT
+
+ static CSDButton *buttonOfType(CSDButtonModel &model, CSDButton::ButtonType type)
+ {
+ for (CSDButton *b : model.windowCSDButtons())
+ if (b->type() == type)
+ return b;
+ return nullptr;
+ }
+
+private slots:
+ void init()
+ {
+ m_model = std::make_unique<CSDButtonModel>(nullptr, nullptr);
+ m_root = new QQuickItem; // scene root (no window needed)
+ m_root->setWidth(1000);
+ m_root->setHeight(1000);
+ }
+
+ void cleanup()
+ {
+ delete m_root; // destroys children first
+ m_model.reset();
+ }
+
+ void test_hitInsideVisibleItem()
+ {
+ auto *close = buttonOfType(*m_model, CSDButton::Close);
+ auto *item = new QQuickItem(m_root);
+ close->registerItem(item);
+ close->setRect({960, 0, 40, 30}); // written by QML updateRect()
+
+ QCOMPARE(m_model->buttonAt({980, 15}), close); // center of button
+ QCOMPARE(m_model->buttonAt({999, 29}), close); // bottom-right corner
+ // top 2px reserved for the resize handle
+ QCOMPARE(m_model->buttonAt({980, 1}), nullptr);
+ // outside the button
+ QCOMPARE(m_model->buttonAt({950, 15}), nullptr);
+ }
+
+ void test_itemOwnVisibility()
+ {
+ auto *close = buttonOfType(*m_model, CSDButton::Close);
+ auto *item = new QQuickItem(m_root);
+ close->registerItem(item);
+ close->setRect({960, 0, 40, 30});
+
+ item->setVisible(false); // e.g. topbar faded out
+ QCOMPARE(m_model->buttonAt({980, 15}), nullptr);
+
+ item->setVisible(true); // e.g. topbar shown again
+ QCOMPARE(m_model->buttonAt({980, 15}), close);
+
+ item->setEnabled(false); // e.g. controls made inert
+ QCOMPARE(m_model->buttonAt({980, 15}), nullptr);
+
+ item->setEnabled(true);
+ QCOMPARE(m_model->buttonAt({980, 15}), close);
+ }
+
+ void test_ancestorInvisibleOrDisabled()
+ {
+ auto *close = buttonOfType(*m_model, CSDButton::Close);
+ auto *mid = new QQuickItem(m_root); // e.g. the ToolBar topbar
+ auto *item = new QQuickItem(mid);
+ close->registerItem(item);
+ close->setRect({960, 0, 40, 30});
+
+ mid->setVisible(false); // invisible topbar (MenuTopbar)
+ QCOMPARE(m_model->buttonAt({980, 15}), nullptr);
+ mid->setVisible(true);
+
+ mid->setEnabled(false);
+ QCOMPARE(m_model->buttonAt({980, 15}), nullptr);
+ mid->setEnabled(true);
+
+ QCOMPARE(m_model->buttonAt({980, 15}), close);
+ }
+
+ void test_childUnderHiddenParent()
+ {
+ // decides whether dropping the ancestor walk is safe: Qt must
+ // report the parent's hidden state through the child's own flags
+ auto *close = buttonOfType(*m_model, CSDButton::Close);
+ auto *hiddenParent = new QQuickItem(m_root);
+ hiddenParent->setVisible(false); // already hidden ...
+ auto *child = new QQuickItem(hiddenParent); // ... when the child is created
+ close->registerItem(child);
+ close->setRect({960, 0, 40, 30});
+
+ const bool childReportsVisible = child->isVisible();
+ QVERIFY2(!childReportsVisible,
+ qPrintable(QStringLiteral(
+ "child under an already-hidden parent reports visible=%1 — "
+ "the ancestor walk would be load-bearing")
+ .arg(childReportsVisible ? 1 : 0)));
+
+ QCOMPARE(m_model->buttonAt({980, 15}), nullptr);
+
+ hiddenParent->setVisible(true); // parent shown again
+ QVERIFY(child->isVisible());
+ QCOMPARE(m_model->buttonAt({980, 15}), close);
+ }
+
+ void test_itemDestroyed()
+ {
+ auto *close = buttonOfType(*m_model, CSDButton::Close);
+ auto *item = new QQuickItem(m_root);
+ close->registerItem(item);
+ close->setRect({960, 0, 40, 30});
+ QCOMPARE(m_model->buttonAt({980, 15}), close);
+
+ delete item; // Loader deactivates -> destroy
+ QCOMPARE(m_model->buttonAt({980, 15}), nullptr); // no stale ghost rect
+ }
+
+ void test_flagsResetWhenLastItemDestroyed()
+ {
+ auto *close = buttonOfType(*m_model, CSDButton::Close);
+ auto *a = new QQuickItem(m_root);
+ auto *b = new QQuickItem(m_root);
+ close->registerItem(a);
+ close->registerItem(b);
+ close->setRect({960, 0, 40, 30});
+
+ close->externalPress(); // WM_NCLBUTTONDOWN happened
+ close->setShowHovered(true);
+
+ delete a; // one of two gone: keep state
+ QVERIFY(close->externalPressed());
+ QCOMPARE(close->rect(), QRect(960, 0, 40, 30)); // rect kept
+
+ delete b; // last one gone: state invalid,
+ QVERIFY(!close->externalPressed()); // a later NC release must NOT click
+ QVERIFY(!close->showHovered());
+ QVERIFY(close->rect().isEmpty()); // and no stale hitbox remains
+ QCOMPARE(m_model->buttonAt({980, 15}), nullptr);
+ }
+
+ void test_multipleRegisteredItems()
+ {
+ // two topbars alive at once (MenuTopbar + LocalTopbar share the model)
+ auto *min = buttonOfType(*m_model, CSDButton::Minimize);
+ auto *hidden = new QQuickItem(m_root); // invisible topbar's set
+ hidden->setVisible(false);
+ auto *visible = new QQuickItem(m_root); // visible topbar's set
+ min->registerItem(hidden);
+ min->registerItem(visible);
+ min->setRect({100, 0, 40, 30});
+
+ QCOMPARE(m_model->buttonAt({120, 15}), min); // visible one carries the hit
+
+ visible->setVisible(false); // both hidden now
+ QCOMPARE(m_model->buttonAt({120, 15}), nullptr);
+ }
+
+ void test_noRegisteredItems()
+ {
+ // e.g. fullscreen: every Loader deactivated, nothing registered —
+ // a leftover rect must not produce a hit without registered items
+ buttonOfType(*m_model, CSDButton::Close)->setRect({960, 0, 40, 30});
+ QCOMPARE(m_model->buttonAt({980, 15}), nullptr);
+ }
+
+ void test_registerItemIdempotent()
+ {
+ auto *close = buttonOfType(*m_model, CSDButton::Close);
+ auto *item = new QQuickItem(m_root);
+ close->registerItem(item);
+ close->registerItem(item); // duplicate registration: no-op
+ close->registerItem(nullptr); // null: safe no-op
+ close->setRect({960, 0, 40, 30});
+
+ QCOMPARE(m_model->buttonAt({980, 15}), close);
+
+ delete item; // one destroyed-connection, state not corrupted
+ QCOMPARE(m_model->buttonAt({980, 15}), nullptr);
+ }
+
+private:
+ std::unique_ptr<CSDButtonModel> m_model;
+ QQuickItem *m_root = nullptr;
+};
+
+int main(int argc, char **argv)
+{
+ // CI has no desktop (cf. qml_test.cpp); an externally provided platform
+ // (e.g. "windows" when running the built test on Windows) is respected
+ if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM"))
+ qputenv("QT_QPA_PLATFORM", "offscreen");
+ QGuiApplication app(argc, argv); // QQuickItem needs a GUI app
+ TestQtCSDButtonModel tc;
+ return QTest::qExec(&tc, argc, argv);
+}
+
+#include "test_qt_csd_button_model.moc"
=====================================
modules/gui/qt/util/csdbuttonmodel.cpp
=====================================
@@ -24,6 +24,9 @@
#include "maininterface/mainctx.hpp"
#include <QWindow>
+#include <QQuickItem>
+
+#include <algorithm>
CSDButton::CSDButton(ButtonType type, QObject *parent)
: QObject {parent}
@@ -106,6 +109,49 @@ void CSDButton::doubleClick()
emit doubleClicked();
}
+void CSDButton::registerItem(QQuickItem *item)
+{
+ if (!item || m_items.contains(item))
+ return;
+ m_items.append(item);
+ connect(item, &QObject::destroyed, this, [this, item] {
+ m_items.removeAll(item);
+ resetInteractionStateIfUnused();
+ });
+}
+
+// Hide contract: the hit-test sees only each registered item's own
+// visible/enabled flags plus the rect written by QML's updateRect().
+// Hides must therefore make those flags false on the items; Qt
+// propagates an ancestor's hidden or disabled state into each child's
+// own flags. Opacity-only hides are not detected; Qt does not block
+// input for transparent items either.
+bool CSDButton::containsScenePos(const QPointF &scenePos) const
+{
+ if (m_items.isEmpty())
+ return false;
+
+ const bool usable = std::any_of(m_items.cbegin(), m_items.cend(),
+ [](const QQuickItem *item) {
+ return item->isVisible() && item->isEnabled();
+ });
+ if (!usable)
+ return false;
+
+ QRect rect = m_rect;
+ rect.setY(rect.y() + 2); // leave a small gap for top resize which is always within the caption area
+ return rect.contains(scenePos.toPoint());
+}
+
+void CSDButton::resetInteractionStateIfUnused()
+{
+ if (!m_items.isEmpty())
+ return;
+ setRect({}); // a destroyed set must not leave a stale hitbox
+ unsetExternalPressed();
+ setShowHovered(false);
+}
+
CSDButtonModel::CSDButtonModel(MainCtx *mainCtx, QObject *parent)
: QObject {parent}
, m_mainCtx {mainCtx}
@@ -127,6 +173,15 @@ const QList<CSDButton *> &CSDButtonModel::windowCSDButtons() const
return m_windowCSDButtons;
}
+CSDButton *CSDButtonModel::buttonAt(const QPointF &scenePos)
+{
+ const auto it = std::find_if(m_windowCSDButtons.begin(), m_windowCSDButtons.end(),
+ [&scenePos](const CSDButton *b) {
+ return b->containsScenePos(scenePos);
+ });
+ return it == m_windowCSDButtons.end() ? nullptr : *it;
+}
+
void CSDButtonModel::minimizeButtonClicked()
{
=====================================
modules/gui/qt/util/csdbuttonmodel.hpp
=====================================
@@ -27,6 +27,8 @@
Q_MOC_INCLUDE("maininterface/mainctx.hpp")
+class QQuickItem;
+
class CSDButton : public QObject
{
Q_OBJECT
@@ -58,9 +60,10 @@ public:
bool showHovered() const;
void setShowHovered(bool newShowHovered);
- // 'rect' is location of the button in the UI
- // may be used by implementation to relay the information
- // to OS such as to show snaplay out menu on Windows 11
+ // 'rect' is location of the button in the UI, written by QML
+ // (CSDWindowButtonSet.updateRect) and read by containsScenePos;
+ // cleared when the last registered item is destroyed; may be
+ // relayed to the OS, e.g. the Windows 11 snap layout menu
const QRect &rect() const;
void setRect(const QRect &newRect);
@@ -70,6 +73,17 @@ public:
void externalPress();
void externalRelease();
+ // registers a QML item visualizing this button; the item is
+ // unregistered automatically when destroyed
+ Q_INVOKABLE void registerItem(QQuickItem *item);
+
+ // true when at least one registered item is visible and enabled
+ // (Qt propagates an ancestor's hidden or disabled state into each
+ // child's own flags) and the QML-written 'rect' contains scenePos
+ // (window scene coordinates, logical pixels; top 2px reserved for
+ // the resize handle); false when nothing is registered
+ bool containsScenePos(const QPointF &scenePos) const;
+
public slots:
// signals to perfrom action associated with button
// actions are dependent on implmentation
@@ -84,10 +98,13 @@ signals:
void externalPressedChanged();
private:
+ void resetInteractionStateIfUnused();
+
const ButtonType m_type;
bool m_showHovered = false;
QRect m_rect;
bool m_externalPressed = false;
+ QList<QQuickItem *> m_items;
};
class MainCtx;
@@ -106,6 +123,10 @@ public:
// destructed:
const QList<CSDButton *>& windowCSDButtons() const;
+ // returns the button that has a visible/enabled registered item
+ // and whose rect is under scenePos, or nullptr
+ CSDButton *buttonAt(const QPointF &scenePos);
+
private slots:
void minimizeButtonClicked();
void maximizeRestoreButtonClicked();
=====================================
modules/gui/qt/widgets/qml/CSDWindowButton.qml
=====================================
@@ -84,4 +84,9 @@ T.Button {
color: control.color
}
}
+
+ // register this button on the C++ side so WM_NCHITTEST can
+ // find it; unregistration happens automatically when the item
+ // is destroyed
+ Component.onCompleted: modelData.registerItem(control)
}
View it on GitLab: https://code.videolan.org/videolan/vlc/-/compare/f53db707da181e3f0958d3ef0a3241c940538b9e...429df92c508f98038ac992772fea1fec1322c9f9
--
View it on GitLab: https://code.videolan.org/videolan/vlc/-/compare/f53db707da181e3f0958d3ef0a3241c940538b9e...429df92c508f98038ac992772fea1fec1322c9f9
You're receiving this email because of your account on code.videolan.org. Manage all notifications: https://code.videolan.org/-/profile/notifications | Help: https://code.videolan.org/help
More information about the vlc-commits
mailing list