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
This commit is contained in:
vicrodh committed 2026-09-13 23:31:59 -06:00
1 parent 119fab64ee
commit 7f609a55e5
5 files changed
+143 -82

No files matched your search

+101 -5
View File
@@ -34,6 +34,87 @@ QbzContextMenu {
property var entries: [] property var entries: []
signal picked(string action) 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 } QbzTheme { id: theme }
Repeater { Repeater {
@@ -43,9 +124,10 @@ QbzContextMenu {
required property var modelData required property var modelData
readonly property bool isSep: modelData.sep === true readonly property bool isSep: modelData.sep === true
readonly property bool hasSub: cmRoot.hasSubmenu(modelData)
readonly property bool rowEnabled: modelData.enabled !== false readonly property bool rowEnabled: modelData.enabled !== false
readonly property bool isDanger: modelData.danger === true 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 width: parent ? parent.width : 0
height: isSep ? 7 : (cmRoot.kioskHost ? 44 : 33) height: isSep ? 7 : (cmRoot.kioskHost ? 44 : 33)
@@ -80,7 +162,7 @@ QbzContextMenu {
} }
Text { Text {
height: parent.height 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 || "" text: row.modelData.label || ""
color: row.isDanger ? theme.danger color: row.isDanger ? theme.danger
: (row.hot ? theme.textPrimary : theme.textSecondary) : (row.hot ? theme.textPrimary : theme.textSecondary)
@@ -88,10 +170,9 @@ QbzContextMenu {
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight elide: Text.ElideRight
} }
// Submenu marker (`submenu: true`): the host answers the pick // Submenu marker: the row opens its child menu on hover.
// by opening a second menu on the same anchor.
QbzIcon { QbzIcon {
visible: row.modelData.submenu === true visible: row.hasSub
name: "chevron-right" name: "chevron-right"
width: 14 width: 14
height: 14 height: 14
@@ -110,9 +191,24 @@ QbzContextMenu {
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: { onClicked: {
if (row.hasSub) {
cmRoot.openSubmenu(row)
return
}
cmRoot.close() cmRoot.close()
cmRoot.picked(row.modelData.action) 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()
}
} }
} }
} }
@@ -49,11 +49,16 @@ Popup {
QbzTheme { id: theme } 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 { background: Rectangle {
color: theme.surfaceMain color: theme.surfaceMain
radius: theme.radiusSm radius: theme.radiusSm
border.width: 1 border.width: 1
border.color: theme.borderMuted border.color: theme.borderMuted
HoverHandler { id: panelHover }
} }
implicitHeight: col.implicitHeight + topPadding + bottomPadding implicitHeight: col.implicitHeight + topPadding + bottomPadding
height: kioskHost && parent ? Math.min(implicitHeight, parent.height - 16) : implicitHeight height: kioskHost && parent ? Math.min(implicitHeight, parent.height - 16) : implicitHeight
+9 -30
View File
@@ -1275,43 +1275,19 @@ Rectangle {
/// Build the popup on first use, then open it. Called by `openRowMenu` /// Build the popup on first use, then open it. Called by `openRowMenu`
/// below, which owns the empty-menu guard. /// below, which owns the empty-menu guard.
function openRowMenuLazy(anchor, x, y) { function openRowMenuLazy(anchor, x, y) {
root._menuAnchor = anchor
root._menuX = x
root._menuY = y
rowMenuLoader.active = true rowMenuLoader.active = true
rowMenuLoader.item.openAtCursor(anchor, x, y) rowMenuLoader.item.openAtCursor(anchor, x, y)
} }
// --- Copy submenu + clipboard (2026-09-13) -------------------------------- // --- Copy (2026-09-13) ----------------------------------------------------
// Both lazy, like the row menu: a list row must not carry a Popup tree // The Copy entry is a CardMenu submenu (hover-opened, declared as data
// or a TextEdit per delegate. Reopened where the row menu was. // in menuModel); the clipboard carrier stays lazy, like the row menu —
property Item _menuAnchor: null // a list row must not carry a TextEdit per delegate.
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) }
}
}
Loader { Loader {
id: clipboardLoader id: clipboardLoader
active: false active: false
sourceComponent: QbzClipboard { } sourceComponent: QbzClipboard { }
} }
function openCopyMenu() {
if (!root._menuAnchor)
return
copyMenuLoader.active = true
copyMenuLoader.item.openAtCursor(root._menuAnchor, root._menuX, root._menuY)
}
function copyRow(which) { function copyRow(which) {
var parts = [root.item.title || ""] var parts = [root.item.title || ""]
if (which === "copy-full") { if (which === "copy-full") {
@@ -1426,7 +1402,10 @@ Rectangle {
m.push({ "label": t("Buy on Qobuz", r), "icon": "shopping-bag", "action": "buy" }) 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". // Copy (2026-09-13): a submenu — the track name, or "Track - Album - Artist".
if ((root.item.title || "") !== "") 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 return m
} }
@@ -1453,7 +1432,7 @@ Rectangle {
else QbzAlbum.openAlbumFrom(root.item.albumId, root.item.album || "", root.item.artist || "") else QbzAlbum.openAlbumFrom(root.item.albumId, root.item.album || "", root.item.artist || "")
} }
else if (a === "buy") QbzAlbum.buyTrack(root.item.id || "") 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 === "favorite") root.toggleFavorite()
else if (a === "mixtape") root.mixtapeRequested() else if (a === "mixtape") root.mixtapeRequested()
// Add to playlist. The internal arm is the CATALOG one and nothing // Add to playlist. The internal arm is the CATALOG one and nothing
+15 -24
View File
@@ -657,7 +657,7 @@ Rectangle {
favorite: root.npFavorite favorite: root.npFavorite
ephemeral: root.npEphemeral ephemeral: root.npEphemeral
tooltip: root.tooltip tooltip: root.tooltip
onAddRequested: function (anchorItem) { root.addAnchor = anchorItem; addMenu.openBelowRight(anchorItem) } onAddRequested: function (anchorItem) { addMenu.openBelowRight(anchorItem) }
onTrackInfoRequested: root.openTrackInfo() onTrackInfoRequested: root.openTrackInfo()
} }
} }
@@ -680,7 +680,7 @@ Rectangle {
playCircle: true playCircle: true
favorite: root.npFavorite favorite: root.npFavorite
tooltip: root.tooltip tooltip: root.tooltip
onAddRequested: function (anchorItem) { root.addAnchor = anchorItem; addMenu.openBelowRight(anchorItem) } onAddRequested: function (anchorItem) { addMenu.openBelowRight(anchorItem) }
onTrackInfoRequested: root.openTrackInfo() onTrackInfoRequested: root.openTrackInfo()
} }
} }
@@ -908,25 +908,8 @@ Rectangle {
// "Add to…" flyout behind the transport "+" (TransportControls.slint's // "Add to…" flyout behind the transport "+" (TransportControls.slint's
// add-menu), on the shared CardMenu surface. Same seven entries, same // add-menu), on the shared CardMenu surface. Same seven entries, same
// order, same icons. // order, same icons.
// The "+" button that opened the add-menu: the Copy submenu reopens on it. // The Copy submenu of the add-menu writes through this carrier.
property Item addAnchor: null
QbzClipboard { id: npClipboard } 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 { CardMenu {
id: addMenu id: addMenu
@@ -992,13 +975,16 @@ Rectangle {
"action": "buy" "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". // "Track - Album - Artist".
m.push({ m.push({
"label": QbzSession.tr("Copy", QbzSession.trRev), "label": QbzSession.tr("Copy", QbzSession.trRev),
"icon": "copy", "icon": "copy",
"action": "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()) return m.concat(root.abEntries())
} }
@@ -1017,8 +1003,13 @@ Rectangle {
QbzLibrary.libraryToggleFavorite("album", QbzPlayer.npAlbumId) QbzLibrary.libraryToggleFavorite("album", QbzPlayer.npAlbumId)
} else if (a === "buy") { } else if (a === "buy") {
if (id !== "") QbzAlbum.buyTrack(id) if (id !== "") QbzAlbum.buyTrack(id)
} else if (a === "copy") { } else if (a === "copy-title" || a === "copy-full") {
if (root.addAnchor) npCopyMenu.openBelowRight(root.addAnchor) 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") { } else if (a === "ab-mark") {
QbzPlayer.abMark() QbzPlayer.abMark()
} else if (a === "ab-clear") { } else if (a === "ab-clear") {
+13 -23
View File
@@ -1695,7 +1695,7 @@ Rectangle {
enabled: !popRow.selectMode enabled: !popRow.selectMode
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor 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" }) m.push({ "label": t("Track info", r), "icon": "info", "action": "track-info" })
if (!popRow.pulledDead) if (!popRow.pulledDead)
m.push({ "label": t("Buy on Qobuz", r), "icon": "shopping-bag", "action": "buy" }) 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 return m
} }
onPicked: function (a) { onPicked: function (a) {
@@ -1814,27 +1817,14 @@ Rectangle {
else if (a === "go-artist") QbzArtist.openArtist(popRow.row.artistId) else if (a === "go-artist") QbzArtist.openArtist(popRow.row.artistId)
else if (a === "track-info") root.openTrackInfo(id) else if (a === "track-info") root.openTrackInfo(id)
else if (a === "buy") QbzAlbum.buyTrack(id) else if (a === "buy") QbzAlbum.buyTrack(id)
else if (a === "copy") popCopyMenu.openAtCursor(popRow._menuAnchor, popRow._menuX, popRow._menuY) else if (a === "copy-title" || a === "copy-full") {
} var parts = [popRow.row.title || ""]
} if (a === "copy-full") {
// Copy submenu (2026-09-13), reopened where the row menu was. if ((popRow.row.album || "") !== "") parts.push(popRow.row.album)
property Item _menuAnchor: null if ((popRow.row.artist || "") !== "") parts.push(popRow.row.artist)
property real _menuX: 0 }
property real _menuY: 0 artistClipboard.copy(parts.join(" - "))
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)
} }
artistClipboard.copy(parts.join(" - "))
} }
} }
@@ -1860,7 +1850,7 @@ Rectangle {
id: popRcArea id: popRcArea
anchors.fill: parent anchors.fill: parent
acceptedButtons: Qt.RightButton 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) }
} }
} }