From a75d7b0087b05471efbf0a2ef87df1443280ada3 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:39:47 -0600 Subject: [PATCH] fix(qt): a submenu stays open while the pointer is over its rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CardMenu's pair-hover check read the popup's HoverHandler, which lives on the background and never fires over a row: the rows' MouseAreas take the hover events, so the panel only counted as hovered on its 5px padding. Crossing from that padding into the child's first row read as "left both menus" and the 350 ms leave timer closed the pair — the Copy submenu closed the whole context menu as soon as it was entered. A menu is now hovered while its panel OR one of its rows is (one hot row at a time, held by reference so a destroyed row cannot leave it stuck), and the pair check reads that on both menus. scripts/test-card-menu-qml.sh drives the pointer through the parent row, the child's rows, back to a plain row and out, and joins the runtime gate and CI. cc --- .github/workflows/test-crates.yml | 2 + crates/qbz-qt/qml/controls/CardMenu.qml | 27 +++++++-- scripts/qml-tests/tst_card_menu.qml | 81 +++++++++++++++++++++++++ scripts/qt-runtime-gate.sh | 1 + scripts/test-card-menu-qml.sh | 18 ++++++ 5 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 scripts/qml-tests/tst_card_menu.qml create mode 100755 scripts/test-card-menu-qml.sh diff --git a/.github/workflows/test-crates.yml b/.github/workflows/test-crates.yml index ba0f420df..a14dd9e8c 100644 --- a/.github/workflows/test-crates.yml +++ b/.github/workflows/test-crates.yml @@ -87,6 +87,7 @@ on: - "scripts/test-compact-seek-qml.sh" - "scripts/test-library-folders-qml.sh" - "scripts/test-array-model-qml.sh" + - "scripts/test-card-menu-qml.sh" - "scripts/test-orbit-daemon.py" - ".github/workflows/test-crates.yml" push: @@ -116,6 +117,7 @@ on: - "scripts/test-compact-seek-qml.sh" - "scripts/test-library-folders-qml.sh" - "scripts/test-array-model-qml.sh" + - "scripts/test-card-menu-qml.sh" - "scripts/test-orbit-daemon.py" - ".github/workflows/test-crates.yml" workflow_dispatch: diff --git a/crates/qbz-qt/qml/controls/CardMenu.qml b/crates/qbz-qt/qml/controls/CardMenu.qml index a00182bba..c921750ce 100644 --- a/crates/qbz-qt/qml/controls/CardMenu.qml +++ b/crates/qbz-qt/qml/controls/CardMenu.qml @@ -93,8 +93,16 @@ QbzContextMenu { leaveTimer.stop() } readonly property bool subOpen: cmRoot._sub !== null && cmRoot._sub.opened - readonly property bool pairHovered: cmRoot.menuHovered - || (cmRoot._sub !== null && cmRoot._sub.menuHovered) + // The pointer is over THIS menu: its panel or one of its rows. The rows' + // MouseAreas take the hover events, so the panel's HoverHandler alone + // sees only the 5px padding — the pair looked "left" the moment the + // pointer crossed from that padding into a child row, and the leave + // timer closed both (2026-09-14). One row is hot at a time, so an item + // reference beats a counter that a destroyed row could leave high. + property Item _hotRow: null + readonly property bool hovering: cmRoot.menuHovered || cmRoot._hotRow !== null + readonly property bool pairHovered: cmRoot.hovering + || (cmRoot._sub !== null && cmRoot._sub.hovering) onPairHoveredChanged: { if (!cmRoot.subOpen) return @@ -113,7 +121,10 @@ QbzContextMenu { } } } - onClosed: closeSubmenu() + onClosed: { + cmRoot._hotRow = null + closeSubmenu() + } QbzTheme { id: theme } @@ -133,6 +144,10 @@ QbzContextMenu { height: isSep ? 7 : (cmRoot.kioskHost ? 44 : 33) radius: isSep ? 0 : 5 color: hot ? theme.surfaceHover : "transparent" + Component.onDestruction: { + if (cmRoot._hotRow === row) + cmRoot._hotRow = null + } // ContextMenuItem.slint: `opacity: enabled ? 1.0 : 0.4`. opacity: (isSep || rowEnabled) ? 1.0 : 0.4 @@ -202,8 +217,12 @@ QbzContextMenu { // row puts it away. Leaving the row itself does nothing — // the pointer is usually on its way into the child. onContainsMouseChanged: { - if (!containsMouse) + if (!containsMouse) { + if (cmRoot._hotRow === row) + cmRoot._hotRow = null return + } + cmRoot._hotRow = row if (row.hasSub) cmRoot.openSubmenu(row) else if (cmRoot.subOpen) diff --git a/scripts/qml-tests/tst_card_menu.qml b/scripts/qml-tests/tst_card_menu.qml new file mode 100644 index 000000000..0340542e2 --- /dev/null +++ b/scripts/qml-tests/tst_card_menu.qml @@ -0,0 +1,81 @@ +// CardMenu submenus: hovering the child must keep BOTH menus open (the +// rows' MouseAreas must count as menu hover), moving to a plain row in the +// parent closes the child only, and leaving both closes both. +import QtQuick +import QtTest +import "../../crates/qbz-qt/qml/controls" + +Item { + id: root + width: 700; height: 500 + + Item { id: anchorItem; x: 120; y: 80; width: 10; height: 10 } + + CardMenu { + id: menu + entries: [ + { "label": "One", "icon": "", "action": "one" }, + { "label": "Copy", "icon": "", "action": "copy", "submenu": [ + { "label": "Sub A", "icon": "", "action": "sub-a" }, + { "label": "Sub B", "icon": "", "action": "sub-b" } + ] }, + { "label": "Three", "icon": "", "action": "three" } + ] + } + SignalSpy { id: picks; target: menu; signalName: "picked" } + + TestCase { + name: "CardMenuSubmenu" + when: windowShown + // Row i's centre inside a menu (5px padding, 33px rows). + function rowY(m, i) { return m.y + 5 + 33 * i + 16 } + + function test_a_hover_keeps_the_pair_open() { + menu.openAtCursor(anchorItem, 0, 0) + wait(50) + verify(menu.opened) + mouseMove(root, menu.x + 40, rowY(menu, 1)) + wait(120) + verify(menu.subOpen, "hovering the Copy row opens the child") + var sub = menu._sub + verify(sub.opened) + mouseMove(root, sub.x + 40, rowY(sub, 0)) + wait(600) + verify(menu.opened, "the parent stays open while the child's first row is hovered") + verify(sub.opened, "the child stays open while its first row is hovered") + mouseMove(root, sub.x + 40, rowY(sub, 1)) + wait(600) + verify(menu.opened && sub.opened, "still open on the child's second row") + // Back onto a plain parent row: the child goes away, the parent stays. + mouseMove(root, menu.x + 40, rowY(menu, 0)) + wait(120) + verify(menu.opened) + verify(!menu.subOpen, "a plain row puts the child away") + // Leave both: both close. + mouseMove(root, menu.x + 40, rowY(menu, 1)) + wait(120) + verify(menu.subOpen) + mouseMove(root, 650, 450) + wait(700) + verify(!menu.opened, "leaving both closes the parent") + } + + function test_b_picking_in_the_child_closes_everything_and_forwards() { + picks.clear() + menu.openAtCursor(anchorItem, 0, 0) + wait(50) + mouseMove(root, menu.x + 40, rowY(menu, 1)) + wait(120) + var sub = menu._sub + verify(sub && sub.opened) + mouseMove(root, sub.x + 40, rowY(sub, 1)) + wait(100) + mouseClick(root, sub.x + 40, rowY(sub, 1)) + wait(100) + compare(picks.count, 1) + compare(picks.signalArguments[0][0], "sub-b") + verify(!menu.opened) + verify(!sub.opened) + } + } +} diff --git a/scripts/qt-runtime-gate.sh b/scripts/qt-runtime-gate.sh index 5af3d6841..0f7c14ec7 100644 --- a/scripts/qt-runtime-gate.sh +++ b/scripts/qt-runtime-gate.sh @@ -10,6 +10,7 @@ bash scripts/test-text-edit-menu-qml.sh bash scripts/test-compact-seek-qml.sh bash scripts/test-library-folders-qml.sh bash scripts/test-array-model-qml.sh +bash scripts/test-card-menu-qml.sh bash scripts/test-orbit-qml.sh node scripts/test_qt_local_views.mjs node scripts/test_qt_library.mjs diff --git a/scripts/test-card-menu-qml.sh b/scripts/test-card-menu-qml.sh new file mode 100755 index 000000000..3ea889ae9 --- /dev/null +++ b/scripts/test-card-menu-qml.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# CardMenu submenu regression: hovering the child keeps the pair open, +# leaving both closes both, a pick in the child forwards and closes all. +set -euo pipefail +cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.." +if [[ -n "${QT_ROOT_DIR:-}" ]]; then + qt_card_menu_bins="$QT_ROOT_DIR/bin" +else + qt_card_menu_qmake="${QMAKE:-}" + if [[ -z "$qt_card_menu_qmake" ]]; then + qt_card_menu_qmake="$(command -v qmake6 || command -v qmake)" + fi + qt_card_menu_bins="$("$qt_card_menu_qmake" -query QT_INSTALL_BINS)" +fi +QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \ + "$qt_card_menu_bins/qmltestrunner" \ + -input scripts/qml-tests/tst_card_menu.qml \ + -import scripts/qml-tests/imports