From 7f609a55e5de9175b1b96765cc1b206996c51eb9 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 13 Sep 2026 23:31:59 -0600 Subject: [PATCH] feat(qt): Copy submenus open on hover and hold their parent open CardMenu entries declare a submenu as data (`submenu: [ {label, icon, action} ]`); the row builds a child CardMenu on first use, opens it when the pointer rests on the entry (or clicks it), and closes it when the pointer moves to another entry. While either menu is hovered the pair stays open; leaving both for 350 ms, pressing Escape or clicking outside closes both. Picking in the child closes the parent too. The track row, the Popular Tracks row and the player bar's "+" menu declare their Copy entries this way instead of re-opening a second CardMenu at the row's saved click position. cc --- crates/qbz-qt/qml/controls/CardMenu.qml | 106 +++++++++++++++++- crates/qbz-qt/qml/controls/QbzContextMenu.qml | 5 + crates/qbz-qt/qml/rows/TrackRow.qml | 39 ++----- crates/qbz-qt/qml/shell/PlayerBar.qml | 39 +++---- crates/qbz-qt/qml/views/ArtistView.qml | 36 +++--- 5 files changed, 143 insertions(+), 82 deletions(-) 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) } } }