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