diff --git a/crates/qbz-qt/qml/controls/CardMenu.qml b/crates/qbz-qt/qml/controls/CardMenu.qml index fb78981f1..a00182bba 100644 --- a/crates/qbz-qt/qml/controls/CardMenu.qml +++ b/crates/qbz-qt/qml/controls/CardMenu.qml @@ -34,6 +34,87 @@ QbzContextMenu { property var entries: [] signal picked(string action) + // --- Submenus (2026-09-13) ------------------------------------------ + // An entry with `submenu: [ { label, icon, action }, … ]` opens a + // second CardMenu beside its row ON HOVER (a click does the same and + // never closes this one). The child forwards its pick here, so hosts + // handle sub-actions in the same `onPicked`. Both menus stay open + // while the pointer is over EITHER; once it has left both for a + // moment they close together, as does a press outside or Escape. + property var _sub: null + property var _subRow: null + // The child is the same type; QML refuses a type that contains itself + // ("instantiated recursively"), so it is created from its file at run + // time instead of through a Component. + function _makeSubmenu() { + var component = Qt.createComponent("CardMenu.qml") + if (component.status !== Component.Ready) { + console.warn("[CardMenu] submenu component: " + component.errorString()) + return null + } + var sub = component.createObject(cmRoot.contentItem, { + "kioskHost": cmRoot.kioskHost, + "menuWidth": cmRoot.menuWidth + }) + if (sub) + sub.picked.connect(function (a) { + cmRoot.close() + cmRoot.picked(a) + }) + return sub + } + function hasSubmenu(entry) { + return entry && entry.submenu !== undefined && entry.submenu !== null + && entry.submenu.length !== undefined && entry.submenu.length > 0 + } + function openSubmenu(row) { + if (cmRoot._subRow === row && cmRoot._sub && cmRoot._sub.opened) + return + if (!cmRoot._sub) + cmRoot._sub = cmRoot._makeSubmenu() + if (!cmRoot._sub) + return + cmRoot._subRow = row + cmRoot._sub.entries = row.modelData.submenu + // Beside the row: to the right, or to the left when the window + // has no room there. A 4px overlap leaves no gap to cross. + var win = row.Window.window + var g = row.mapToItem(null, 0, 0) + var gx = cmRoot.x + cmRoot.width - 4 + if (win && gx + cmRoot._sub.width > win.width - 8) + gx = cmRoot.x - cmRoot._sub.width + 4 + cmRoot._sub._place(gx, g.y - cmRoot._sub.topPadding, win) + leaveTimer.stop() + } + function closeSubmenu() { + cmRoot._subRow = null + if (cmRoot._sub && cmRoot._sub.opened) + cmRoot._sub.close() + leaveTimer.stop() + } + readonly property bool subOpen: cmRoot._sub !== null && cmRoot._sub.opened + readonly property bool pairHovered: cmRoot.menuHovered + || (cmRoot._sub !== null && cmRoot._sub.menuHovered) + onPairHoveredChanged: { + if (!cmRoot.subOpen) + return + if (cmRoot.pairHovered) + leaveTimer.stop() + else + leaveTimer.restart() + } + Timer { + id: leaveTimer + interval: 350 + onTriggered: { + if (cmRoot.subOpen && !cmRoot.pairHovered) { + cmRoot.closeSubmenu() + cmRoot.close() + } + } + } + onClosed: closeSubmenu() + QbzTheme { id: theme } Repeater { @@ -43,9 +124,10 @@ QbzContextMenu { required property var modelData readonly property bool isSep: modelData.sep === true + readonly property bool hasSub: cmRoot.hasSubmenu(modelData) readonly property bool rowEnabled: modelData.enabled !== false readonly property bool isDanger: modelData.danger === true - readonly property bool hot: cmiArea.containsMouse && rowEnabled + readonly property bool hot: (cmiArea.containsMouse || cmRoot._subRow === row) && rowEnabled width: parent ? parent.width : 0 height: isSep ? 7 : (cmRoot.kioskHost ? 44 : 33) @@ -80,7 +162,7 @@ QbzContextMenu { } Text { height: parent.height - width: parent.width - 23 - (row.modelData.submenu === true ? 22 : 0) + width: parent.width - 23 - (row.hasSub ? 22 : 0) text: row.modelData.label || "" color: row.isDanger ? theme.danger : (row.hot ? theme.textPrimary : theme.textSecondary) @@ -88,10 +170,9 @@ QbzContextMenu { verticalAlignment: Text.AlignVCenter elide: Text.ElideRight } - // Submenu marker (`submenu: true`): the host answers the pick - // by opening a second menu on the same anchor. + // Submenu marker: the row opens its child menu on hover. QbzIcon { - visible: row.modelData.submenu === true + visible: row.hasSub name: "chevron-right" width: 14 height: 14 @@ -110,9 +191,24 @@ QbzContextMenu { hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { + if (row.hasSub) { + cmRoot.openSubmenu(row) + return + } cmRoot.close() cmRoot.picked(row.modelData.action) } + // Hovering a submenu row opens its child; hovering any other + // row puts it away. Leaving the row itself does nothing — + // the pointer is usually on its way into the child. + onContainsMouseChanged: { + if (!containsMouse) + return + if (row.hasSub) + cmRoot.openSubmenu(row) + else if (cmRoot.subOpen) + cmRoot.closeSubmenu() + } } } } diff --git a/crates/qbz-qt/qml/controls/QbzContextMenu.qml b/crates/qbz-qt/qml/controls/QbzContextMenu.qml index ea97ed870..14f2b2890 100644 --- a/crates/qbz-qt/qml/controls/QbzContextMenu.qml +++ b/crates/qbz-qt/qml/controls/QbzContextMenu.qml @@ -49,11 +49,16 @@ Popup { QbzTheme { id: theme } + /// The pointer is over the panel (padding included). CardMenu's + /// submenus read it on both menus to decide when leaving closes them. + readonly property bool menuHovered: panelHover.hovered + background: Rectangle { color: theme.surfaceMain radius: theme.radiusSm border.width: 1 border.color: theme.borderMuted + HoverHandler { id: panelHover } } implicitHeight: col.implicitHeight + topPadding + bottomPadding height: kioskHost && parent ? Math.min(implicitHeight, parent.height - 16) : implicitHeight diff --git a/crates/qbz-qt/qml/rows/TrackRow.qml b/crates/qbz-qt/qml/rows/TrackRow.qml index 36d82da3f..f602df425 100644 --- a/crates/qbz-qt/qml/rows/TrackRow.qml +++ b/crates/qbz-qt/qml/rows/TrackRow.qml @@ -1275,43 +1275,19 @@ Rectangle { /// Build the popup on first use, then open it. Called by `openRowMenu` /// below, which owns the empty-menu guard. function openRowMenuLazy(anchor, x, y) { - root._menuAnchor = anchor - root._menuX = x - root._menuY = y rowMenuLoader.active = true rowMenuLoader.item.openAtCursor(anchor, x, y) } - // --- Copy submenu + clipboard (2026-09-13) -------------------------------- - // Both lazy, like the row menu: a list row must not carry a Popup tree - // or a TextEdit per delegate. Reopened where the row menu was. - property Item _menuAnchor: null - property real _menuX: 0 - property real _menuY: 0 - Loader { - id: copyMenuLoader - active: false - sourceComponent: CardMenu { - kioskHost: root.kioskHost - menuWidth: 224 - entries: [ - { "label": QbzSession.tr("Track name", QbzSession.trRev), "icon": "copy", "action": "copy-title" }, - { "label": QbzSession.tr("Track - Album - Artist", QbzSession.trRev), "icon": "clipboard", "action": "copy-full" } - ] - onPicked: function (a) { root.copyRow(a) } - } - } + // --- Copy (2026-09-13) ---------------------------------------------------- + // The Copy entry is a CardMenu submenu (hover-opened, declared as data + // in menuModel); the clipboard carrier stays lazy, like the row menu — + // a list row must not carry a TextEdit per delegate. Loader { id: clipboardLoader active: false sourceComponent: QbzClipboard { } } - function openCopyMenu() { - if (!root._menuAnchor) - return - copyMenuLoader.active = true - copyMenuLoader.item.openAtCursor(root._menuAnchor, root._menuX, root._menuY) - } function copyRow(which) { var parts = [root.item.title || ""] if (which === "copy-full") { @@ -1426,7 +1402,10 @@ Rectangle { m.push({ "label": t("Buy on Qobuz", r), "icon": "shopping-bag", "action": "buy" }) // Copy (2026-09-13): a submenu — the track name, or "Track - Album - Artist". if ((root.item.title || "") !== "") - m.push({ "label": t("Copy", r), "icon": "copy", "action": "copy", "submenu": true }) + m.push({ "label": t("Copy", r), "icon": "copy", "action": "copy", "submenu": [ + { "label": t("Track name", r), "icon": "copy", "action": "copy-title" }, + { "label": t("Track - Album - Artist", r), "icon": "clipboard", "action": "copy-full" } + ] }) return m } @@ -1453,7 +1432,7 @@ Rectangle { else QbzAlbum.openAlbumFrom(root.item.albumId, root.item.album || "", root.item.artist || "") } else if (a === "buy") QbzAlbum.buyTrack(root.item.id || "") - else if (a === "copy") root.openCopyMenu() + else if (a === "copy-title" || a === "copy-full") root.copyRow(a) else if (a === "favorite") root.toggleFavorite() else if (a === "mixtape") root.mixtapeRequested() // Add to playlist. The internal arm is the CATALOG one and nothing diff --git a/crates/qbz-qt/qml/shell/PlayerBar.qml b/crates/qbz-qt/qml/shell/PlayerBar.qml index 01fc58619..4f478b35b 100644 --- a/crates/qbz-qt/qml/shell/PlayerBar.qml +++ b/crates/qbz-qt/qml/shell/PlayerBar.qml @@ -657,7 +657,7 @@ Rectangle { favorite: root.npFavorite ephemeral: root.npEphemeral tooltip: root.tooltip - onAddRequested: function (anchorItem) { root.addAnchor = anchorItem; addMenu.openBelowRight(anchorItem) } + onAddRequested: function (anchorItem) { addMenu.openBelowRight(anchorItem) } onTrackInfoRequested: root.openTrackInfo() } } @@ -680,7 +680,7 @@ Rectangle { playCircle: true favorite: root.npFavorite tooltip: root.tooltip - onAddRequested: function (anchorItem) { root.addAnchor = anchorItem; addMenu.openBelowRight(anchorItem) } + onAddRequested: function (anchorItem) { addMenu.openBelowRight(anchorItem) } onTrackInfoRequested: root.openTrackInfo() } } @@ -908,25 +908,8 @@ Rectangle { // "Add to…" flyout behind the transport "+" (TransportControls.slint's // add-menu), on the shared CardMenu surface. Same seven entries, same // order, same icons. - // The "+" button that opened the add-menu: the Copy submenu reopens on it. - property Item addAnchor: null + // The Copy submenu of the add-menu writes through this carrier. QbzClipboard { id: npClipboard } - CardMenu { - id: npCopyMenu - menuWidth: 232 - entries: [ - { "label": QbzSession.tr("Track name", QbzSession.trRev), "icon": "copy", "action": "copy-title" }, - { "label": QbzSession.tr("Track - Album - Artist", QbzSession.trRev), "icon": "clipboard", "action": "copy-full" } - ] - onPicked: function (a) { - var parts = [QbzPlayer.npTitle || ""] - if (a === "copy-full") { - if ((QbzPlayer.npAlbum || "") !== "") parts.push(QbzPlayer.npAlbum) - if ((QbzPlayer.npArtist || "") !== "") parts.push(QbzPlayer.npArtist) - } - npClipboard.copy(parts.join(" - ")) - } - } CardMenu { id: addMenu @@ -992,13 +975,16 @@ Rectangle { "action": "buy" }) } - // Copy (2026-09-13): a submenu — the track name, or + // Copy (2026-09-13): a hover-opened submenu — the track name, or // "Track - Album - Artist". m.push({ "label": QbzSession.tr("Copy", QbzSession.trRev), "icon": "copy", "action": "copy", - "submenu": true + "submenu": [ + { "label": QbzSession.tr("Track name", QbzSession.trRev), "icon": "copy", "action": "copy-title" }, + { "label": QbzSession.tr("Track - Album - Artist", QbzSession.trRev), "icon": "clipboard", "action": "copy-full" } + ] }) return m.concat(root.abEntries()) } @@ -1017,8 +1003,13 @@ Rectangle { QbzLibrary.libraryToggleFavorite("album", QbzPlayer.npAlbumId) } else if (a === "buy") { if (id !== "") QbzAlbum.buyTrack(id) - } else if (a === "copy") { - if (root.addAnchor) npCopyMenu.openBelowRight(root.addAnchor) + } else if (a === "copy-title" || a === "copy-full") { + var parts = [QbzPlayer.npTitle || ""] + if (a === "copy-full") { + if ((QbzPlayer.npAlbum || "") !== "") parts.push(QbzPlayer.npAlbum) + if ((QbzPlayer.npArtist || "") !== "") parts.push(QbzPlayer.npArtist) + } + npClipboard.copy(parts.join(" - ")) } else if (a === "ab-mark") { QbzPlayer.abMark() } else if (a === "ab-clear") { diff --git a/crates/qbz-qt/qml/views/ArtistView.qml b/crates/qbz-qt/qml/views/ArtistView.qml index c9ef633f0..19171aba8 100644 --- a/crates/qbz-qt/qml/views/ArtistView.qml +++ b/crates/qbz-qt/qml/views/ArtistView.qml @@ -1695,7 +1695,7 @@ Rectangle { enabled: !popRow.selectMode hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: function (mouse) { popRow._menuAnchor = moreArea; popRow._menuX = mouse.x; popRow._menuY = mouse.y; popMenu.openAtCursor(moreArea, mouse.x, mouse.y) } + onClicked: function (mouse) { popMenu.openAtCursor(moreArea, mouse.x, mouse.y) } } } } @@ -1779,7 +1779,10 @@ Rectangle { m.push({ "label": t("Track info", r), "icon": "info", "action": "track-info" }) if (!popRow.pulledDead) m.push({ "label": t("Buy on Qobuz", r), "icon": "shopping-bag", "action": "buy" }) - m.push({ "label": t("Copy", r), "icon": "copy", "action": "copy", "submenu": true }) + m.push({ "label": t("Copy", r), "icon": "copy", "action": "copy", "submenu": [ + { "label": t("Track name", r), "icon": "copy", "action": "copy-title" }, + { "label": t("Track - Album - Artist", r), "icon": "clipboard", "action": "copy-full" } + ] }) return m } onPicked: function (a) { @@ -1814,27 +1817,14 @@ Rectangle { else if (a === "go-artist") QbzArtist.openArtist(popRow.row.artistId) else if (a === "track-info") root.openTrackInfo(id) else if (a === "buy") QbzAlbum.buyTrack(id) - else if (a === "copy") popCopyMenu.openAtCursor(popRow._menuAnchor, popRow._menuX, popRow._menuY) - } - } - // Copy submenu (2026-09-13), reopened where the row menu was. - property Item _menuAnchor: null - property real _menuX: 0 - property real _menuY: 0 - CardMenu { - id: popCopyMenu - menuWidth: 224 - entries: [ - { "label": QbzSession.tr("Track name", QbzSession.trRev), "icon": "copy", "action": "copy-title" }, - { "label": QbzSession.tr("Track - Album - Artist", QbzSession.trRev), "icon": "clipboard", "action": "copy-full" } - ] - onPicked: function (a) { - var parts = [popRow.row.title || ""] - if (a === "copy-full") { - if ((popRow.row.album || "") !== "") parts.push(popRow.row.album) - if ((popRow.row.artist || "") !== "") parts.push(popRow.row.artist) + else if (a === "copy-title" || a === "copy-full") { + var parts = [popRow.row.title || ""] + if (a === "copy-full") { + if ((popRow.row.album || "") !== "") parts.push(popRow.row.album) + if ((popRow.row.artist || "") !== "") parts.push(popRow.row.artist) + } + artistClipboard.copy(parts.join(" - ")) } - artistClipboard.copy(parts.join(" - ")) } } @@ -1860,7 +1850,7 @@ Rectangle { id: popRcArea anchors.fill: parent acceptedButtons: Qt.RightButton - onClicked: function (mouse) { popRow._menuAnchor = popRcArea; popRow._menuX = mouse.x; popRow._menuY = mouse.y; popMenu.openAtCursor(popRcArea, mouse.x, mouse.y) } + onClicked: function (mouse) { popMenu.openAtCursor(popRcArea, mouse.x, mouse.y) } } }