feat(qt): picker modal pins last playlist and shows containment strip

The dropdown pins the session's last successful target above the
results (clock glyph, de-duplicated by Rust, retained under a search
only when it matches). The body gains the collapsible Already in strip:
one card per writable playlist already holding carried tracks, with an
N-of-M badge on partial multi-track containment; clicking a card
selects that playlist so completing a partial add is one click plus the
duplicate-confirm's only-new answer. The strip renders only when the
index can back the claim — complete with matches, or updating under an
explicit caveat — and an empty answer from an incomplete index renders
nothing. Four new strings in all eight locales.

cc
This commit is contained in:
vicrodh committed 2026-08-30 17:23:58 -06:00
1 parent dade0edb26
commit 4984736370
9 files changed
+292 -3

No files matched your search

@@ -42,6 +42,21 @@ Item {
readonly property var rows: doc.playlists || []
readonly property var dup: doc.dup || ({})
readonly property int trackCount: doc.trackCount || 0
// Session-local pinned target (last successful add); null when unset,
// deleted, unwritable, or filtered out by the current query.
readonly property var lastUsed: doc.lastUsed || null
// Containment strip: writable playlists already holding carried tracks,
// honest per alreadyState ("loading"|"complete"|"updating"|"unavailable").
readonly property var alreadyIn: doc.alreadyIn || []
readonly property string alreadyState: doc.alreadyState || ""
// The strip renders only when it can back a claim: complete with matches,
// or updating (where partial matches show under an explicit caveat).
readonly property bool alreadyShowing: !root.creatingOpen
&& (root.alreadyState === "updating"
|| (root.alreadyState === "complete" && root.alreadyIn.length > 0))
// Independently collapsible (session-local: the modal mounts once per
// app run and this deliberately survives resetLocals()).
property bool alreadyCollapsed: false
// The carried payload is LocalLibrary refs (counter wording only).
readonly property bool localMode: doc.localMode === true
readonly property bool creatingOpen: doc.creatingOpen === true
@@ -368,6 +383,127 @@ Item {
}
}
}
// --- "Already in" containment strip ----------------
// Writable playlists that already hold carried tracks.
// Shown only when the index can back the claim: complete
// with matches, or updating under an explicit caveat —
// an empty answer from an incomplete index says NOTHING
// and renders nothing (ADD_TO_PLAYLIST_REDESIGN.md).
Column {
width: parent.width - 48
spacing: 8
visible: root.alreadyShowing
Item {
width: parent.width
height: 20
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 6
Text {
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Already in", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: theme.fontLegal
font.weight: theme.weightMedium
}
QbzIcon {
anchors.verticalCenter: parent.verticalCenter
name: root.alreadyCollapsed ? "chevron-down" : "chevron-up"
width: 12
height: 12
tintName: alreadyToggle.containsMouse ? "textPrimary" : "muted"
}
}
MouseArea {
id: alreadyToggle
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.alreadyCollapsed = !root.alreadyCollapsed
}
Text {
visible: root.alreadyState === "updating"
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Playlist index updating...", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.italic: true
}
}
ListView {
visible: !root.alreadyCollapsed && root.alreadyIn.length > 0
width: parent.width
height: visible ? 32 : 0
orientation: ListView.Horizontal
spacing: 8
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.alreadyIn
delegate: Rectangle {
required property var modelData
width: alreadyRow.implicitWidth + 20
height: 30
radius: 6
color: alreadyArea.containsMouse
? theme.surfaceHover : theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Row {
id: alreadyRow
anchors.centerIn: parent
spacing: 6
QbzIcon {
visible: modelData.isLocal === true
anchors.verticalCenter: parent.verticalCenter
name: "hard-drive"
width: 12
height: 12
tintName: "muted"
}
Text {
anchors.verticalCenter: parent.verticalCenter
// Cap so one long name cannot push the
// whole strip off-card; implicitWidth
// is layout-independent, so this is
// not a binding loop.
width: Math.min(implicitWidth, 180)
text: modelData.name
color: theme.textPrimary
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
// The multi-track card: "2 of 5". A full
// or single-track containment says it by
// presence alone.
Text {
visible: root.trackCount > 1
&& modelData.contained < root.trackCount
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("{} of {}", QbzSession.trRev)
.replace("{}", modelData.contained)
.replace("{}", root.trackCount)
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
}
MouseArea {
id: alreadyArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// Selecting it is the useful action: "add
// the missing 3 of 5" is one click plus
// the dup-confirm's "only new" answer.
onClicked: root.selectRow(modelData)
}
}
}
}
}
}
@@ -426,9 +562,11 @@ Item {
// 43 input + 4 gap.
y: 68 + 1 + 24 + 20 + 8 + 43 + 4
width: parent.width - 48
// create row 41 + 1 divider, 41 per shown row, 41 no-results,
// 1 + 34 overflow. Tauri max-height 320 + border.
height: Math.min(2 + 42 + root.shown * 41
// create row 41 + 1 divider, pinned last-used 41 + 1, 41 per
// shown row, 41 no-results, 1 + 34 overflow. Tauri max-height
// 320 + border.
height: Math.min(2 + 42 + (root.lastUsed !== null ? 42 : 0)
+ root.shown * 41
+ ((root.rows.length === 0 && root.filterText !== "") ? 41 : 0)
+ (root.overflow > 0 ? 35 : 0), 322)
radius: theme.radiusSm
@@ -487,6 +625,61 @@ Item {
}
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
// Session's last successful target, pinned above the
// results. Rust already validated it (still listed,
// still writable) and de-duplicated it from `rows`;
// under a search it only survives a matching query.
Rectangle {
visible: root.lastUsed !== null
width: parent.width
height: visible ? 41 : 0
color: lastUsedArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
id: lastUsedGlyph
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
name: "clock"
width: 14
height: 14
tintName: "muted"
}
Text {
anchors.left: lastUsedGlyph.right
anchors.leftMargin: 8
anchors.right: lastUsedCaption.left
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
text: root.lastUsed ? root.lastUsed.name : ""
color: theme.textPrimary
font.pixelSize: theme.fontLink
elide: Text.ElideRight
}
Text {
id: lastUsedCaption
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Last playlist", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.italic: true
}
MouseArea {
id: lastUsedArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.selectRow(root.lastUsed)
}
}
Rectangle {
visible: root.lastUsed !== null
width: parent.width
height: visible ? 1 : 0
color: theme.borderSubtle
}
Repeater {
model: root.rows.slice(0, root.windowSize)
delegate: Rectangle {