fix(qt): table headers line up, the mini rail is centred, tooltips have a background
Three of the owner's visual reports, one theme: geometry that was derived in two places and drifted. The track table laid its header out from the LABELLED columns only, so the heart and the overflow menu took no space there and every label sat right of the column it names. Header and rows now come from one shared set of widths, including the responsive arms -- if the header hid columns on a different condition than the row, the drift would come back only on narrow windows. The collapsed rail goes back to Slint's 64px. It had been 50 by owner request, and the measurement showed why that was the actual defect: the rail centred its row at 25 while the header's own sidebar toggle, sitting directly above it, centres at 30. Symmetric padding inside a 50px rail still reads as misaligned because the eye compares the glyphs to the header control, not to the rail; chasing it from inside bought the alignment at the price of a visibly asymmetric hover pill. At 64 both references land 2px apart and the padding is symmetric again. The row stays a literal 34 rather than being derived from the width -- deriving it is what coupled the two tokens and would have silently grown every row to 48. Tooltips: the port had none, while Slint has a shell-level overlay. This adds the overlay rather than a Popup per call site. cc
This commit is contained in:
9 files changed
+877
-167
No files matched your search
@@ -0,0 +1,240 @@
|
||||
// Shared hover-tooltip overlay — the QML port of Slint's tooltip MECHANISM,
|
||||
// not of one bubble: `shell/TooltipOverlay.slint` (bubble ABOVE the control,
|
||||
// downward caret, clamped to the window) and `shell/SidebarTooltip.slint`
|
||||
// (bubble to the RIGHT of a collapsed-rail row) are the same machine in Slint —
|
||||
// a global state channel (`TooltipState` / `SidebarTooltipState`, state.slint:
|
||||
// 4791 / 1893) that every hoverable control writes its text + anchor into, and
|
||||
// ONE overlay mounted last in AppShell that renders it on top of everything.
|
||||
//
|
||||
// WHY AN OVERLAY AND NOT A `ToolTip`/`Popup` PER CALL SITE
|
||||
//
|
||||
// Two independent reasons, both measured:
|
||||
//
|
||||
// 1. QtQuick.Controls' `ToolTip` DOES NOT SIZE TO A CUSTOM contentItem.
|
||||
// Measured on Qt 6.11.1 with a probe (four variants, same contentItem):
|
||||
// Popup { contentItem: Text {...} } -> implicitContentWidth = 157.8
|
||||
// ToolTip { contentItem: Text {...} } -> implicitContentWidth = 1
|
||||
// The Basic style's ToolTip binds
|
||||
// implicitWidth: max(implicitBackgroundWidth + insets,
|
||||
// implicitContentWidth + padding)
|
||||
// so with implicitContentWidth stuck at 1 the popup collapses to a ~13x25
|
||||
// box while the Text — which a Popup never clips — keeps painting its full
|
||||
// 178px. That is EXACTLY the reported defect: the label floats with no
|
||||
// background and a small detached background box sits beside it. Setting
|
||||
// `contentWidth`/`contentHeight` by hand papers over it; not being a
|
||||
// ToolTip at all removes it.
|
||||
// 2. The rows that need a tooltip live inside a `clip: true` sidebar and
|
||||
// inside recycled delegates. A per-row popup would be one QQuickPopup per
|
||||
// playlist (the tree runs to hundreds) and would have to be torn down with
|
||||
// its delegate. The overlay is one Item at the shell root, and it stores
|
||||
// only NUMBERS + strings — never a reference to the anchoring item — so a
|
||||
// delegate can be destroyed under an open bubble without dangling.
|
||||
//
|
||||
// The overlay never takes the pointer (no MouseArea, `enabled: false`), so it
|
||||
// cannot steal the hover that is keeping it open — the same rule
|
||||
// ArtPreviewOverlay.qml documents.
|
||||
//
|
||||
// PLACEMENTS (both flavours keep their own Slint numbers, they are not one
|
||||
// style with two anchors):
|
||||
// showRight() SidebarTooltip.slint — surface-elevated, Radius.sm, 1px
|
||||
// border-muted, 10/10/5/5 padding, 12px/w500 text-primary,
|
||||
// x = row.right + 6, vertically centred on the row.
|
||||
// showAbove() TooltipOverlay.slint — surface-elevated, radius 4, no border,
|
||||
// 9/9/5/5 padding, 11px/medium text-primary, downward caret,
|
||||
// y = anchor.top - height - 9, centre clamped 8px from the
|
||||
// window edges with the caret still pointing at the control.
|
||||
//
|
||||
// DELIBERATE DEVIATIONS FROM SLINT, both stated because they are visible:
|
||||
// - MAX WIDTH + ELISION. Neither Slint bubble caps its width; a 200-character
|
||||
// playlist name would run off the window. `maxWidth` (320 default) caps it
|
||||
// and the label elides. Set maxWidth: 0 for the uncapped Slint behaviour.
|
||||
// - SHADOW. Slint draws `drop-shadow-blur: 16px` / `10px`. Qt Quick has no
|
||||
// shadow primitive on the software path (QtQuick.Effects renders NOTHING —
|
||||
// see theme/RoundedImage.qml), so the port's usual approximation is used:
|
||||
// one offset rectangle behind the bubble, exactly like
|
||||
// SidebarNowPlayingDock.qml does for the cover.
|
||||
|
||||
import QtQuick
|
||||
import "../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// Purely decorative — never take a click, never take the hover that is
|
||||
// keeping the bubble open.
|
||||
enabled: false
|
||||
|
||||
// ---- State (the QML stand-in for Slint's TooltipState globals) --------
|
||||
// `ownerKey` is the race-safe owner id Slint carries in
|
||||
// SidebarTooltipState.id: a row that loses the pointer only clears the
|
||||
// bubble if it still owns it, so sliding straight from one row to the next
|
||||
// never blanks it (Sidebar.slint:199-203).
|
||||
property string ownerKey: ""
|
||||
property string label: ""
|
||||
// 0 = right of the anchor (sidebar rail), 1 = above it (caret bubble).
|
||||
property int placement: 0
|
||||
// Anchor rectangle, in THIS overlay's coordinates. Captured as plain
|
||||
// numbers on show — never a reference to the anchoring Item, so a recycled
|
||||
// or destroyed delegate cannot dangle here.
|
||||
property real anchorX: 0
|
||||
property real anchorY: 0
|
||||
property real anchorW: 0
|
||||
property real anchorH: 0
|
||||
// 0 disables the cap (Slint's own behaviour).
|
||||
property int maxWidth: 320
|
||||
|
||||
readonly property bool shown: ownerKey !== "" && label !== ""
|
||||
|
||||
// ---- API --------------------------------------------------------------
|
||||
// `item` is any Item; its bounds are mapped into this overlay. `key` is the
|
||||
// owner id used by hide().
|
||||
function showRight(item, key, text) {
|
||||
if (!item || !text)
|
||||
return
|
||||
root._capture(item)
|
||||
root.placement = 0
|
||||
root.label = text
|
||||
root.ownerKey = key
|
||||
}
|
||||
function showAbove(item, key, text) {
|
||||
if (!item || !text)
|
||||
return
|
||||
root._capture(item)
|
||||
root.placement = 1
|
||||
root.label = text
|
||||
root.ownerKey = key
|
||||
}
|
||||
// Race-safe close: only the owner may clear the bubble.
|
||||
function hide(key) {
|
||||
if (root.ownerKey === key)
|
||||
root.ownerKey = ""
|
||||
}
|
||||
// Unconditional close — for the events that invalidate the anchor itself
|
||||
// (the sidebar leaving the mini state, a flyout taking the same slot, a
|
||||
// view change).
|
||||
function hideAll() {
|
||||
root.ownerKey = ""
|
||||
}
|
||||
|
||||
function _capture(item) {
|
||||
const p = item.mapToItem(root, 0, 0)
|
||||
root.anchorX = p.x
|
||||
root.anchorY = p.y
|
||||
root.anchorW = item.width
|
||||
root.anchorH = item.height
|
||||
}
|
||||
|
||||
// ---- Geometry ---------------------------------------------------------
|
||||
// The bubble is sized by its LABEL, which is the whole point: `bubbleW`
|
||||
// reads the Text's natural implicitWidth (NoWrap + ElideRight leaves
|
||||
// implicitWidth at the unelided width, so assigning the Text a narrower
|
||||
// width below is not a binding loop) and the cap only ever shrinks it.
|
||||
readonly property real padH: placement === 0 ? 10 : 9
|
||||
readonly property real padV: 5
|
||||
readonly property real bubbleW: Math.min(
|
||||
tipText.implicitWidth + 2 * padH,
|
||||
maxWidth > 0 ? maxWidth : Number.MAX_VALUE)
|
||||
readonly property real bubbleH: tipText.implicitHeight + 2 * padV
|
||||
|
||||
// Right placement (SidebarTooltip.slint:17-19): +6px off the row's right
|
||||
// edge, vertically centred on the row. Flips to the row's LEFT when the
|
||||
// bubble would leave the window — Slint never clamps this one because its
|
||||
// rail is always at x=0 with the whole window to its right; the flip costs
|
||||
// nothing and keeps a narrow window honest.
|
||||
readonly property real rightX: (anchorX + anchorW + 6 + bubbleW <= root.width - 8)
|
||||
? anchorX + anchorW + 6
|
||||
: Math.max(8, anchorX - bubbleW - 6)
|
||||
readonly property real rightY: Math.max(8, Math.min(
|
||||
root.height - bubbleH - 8,
|
||||
anchorY + Math.round((anchorH - bubbleH) / 2)))
|
||||
|
||||
// Above placement (TooltipOverlay.slint:17-26): centred on the control,
|
||||
// clamped 8px from both window edges, 9px of air over the control.
|
||||
readonly property real aboveCx: anchorX + anchorW / 2
|
||||
readonly property real aboveX: Math.max(8, Math.min(
|
||||
Math.max(8, root.width - bubbleW - 8), aboveCx - bubbleW / 2))
|
||||
readonly property real aboveY: anchorY - bubbleH - 9
|
||||
|
||||
// ---- The bubble -------------------------------------------------------
|
||||
Item {
|
||||
id: bubble
|
||||
visible: root.shown
|
||||
x: Math.round(root.placement === 0 ? root.rightX : root.aboveX)
|
||||
y: Math.round(root.placement === 0 ? root.rightY : root.aboveY)
|
||||
width: Math.ceil(root.bubbleW)
|
||||
height: Math.ceil(root.bubbleH)
|
||||
|
||||
// Shadow approximation (see the header): one offset rect, same radius.
|
||||
Rectangle {
|
||||
x: 0
|
||||
y: root.placement === 0 ? 4 : 2
|
||||
width: parent.width
|
||||
height: parent.height
|
||||
radius: root.placement === 0 ? theme.radiusSm : 4
|
||||
color: "#66000000"
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: root.placement === 0 ? theme.radiusSm : 4
|
||||
color: theme.surfaceElevated
|
||||
// Only the sidebar bubble is bordered (SidebarTooltip.slint:24-25);
|
||||
// TooltipOverlay.slint has none.
|
||||
border.width: root.placement === 0 ? 1 : 0
|
||||
border.color: theme.borderMuted
|
||||
|
||||
Text {
|
||||
id: tipText
|
||||
x: root.padH
|
||||
y: root.padV
|
||||
width: Math.max(0, parent.width - 2 * root.padH)
|
||||
text: root.label
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: root.placement === 0 ? 12 : 11
|
||||
font.weight: theme.weightMedium
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
wrapMode: Text.NoWrap
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
// Downward caret, above-placement only. Slint draws a Path
|
||||
// ("M 0 0 L 5 6 L 10 0 Z", TooltipOverlay.slint:56-64) and keeps it
|
||||
// pointing at the REAL control centre even when the bubble is clamped.
|
||||
// Canvas is this port's polygon primitive (Canvas.Immediate — the
|
||||
// Cooperative/Threaded render strategies segfault against list
|
||||
// recycling; see theme/RoundedImage.qml).
|
||||
Canvas {
|
||||
id: caret
|
||||
visible: root.placement === 1
|
||||
width: 10
|
||||
height: 6
|
||||
x: Math.round(Math.max(4, Math.min(parent.width - width - 4,
|
||||
root.aboveCx - bubble.x - width / 2)))
|
||||
y: parent.height - 1
|
||||
renderTarget: Canvas.Image
|
||||
renderStrategy: Canvas.Immediate
|
||||
// The fill has to follow the theme, and a Canvas does not repaint
|
||||
// on a colour binding by itself.
|
||||
property color fill: theme.surfaceElevated
|
||||
onFillChanged: caret.requestPaint()
|
||||
onVisibleChanged: if (visible) caret.requestPaint()
|
||||
onPaint: {
|
||||
var ctx = caret.getContext("2d")
|
||||
if (!ctx)
|
||||
return
|
||||
ctx.reset()
|
||||
ctx.clearRect(0, 0, caret.width, caret.height)
|
||||
ctx.fillStyle = caret.fill
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, 0)
|
||||
ctx.lineTo(caret.width / 2, caret.height)
|
||||
ctx.lineTo(caret.width, 0)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// TrackCols — THE column geometry of the track table. ONE source of truth,
|
||||
// instantiated by BOTH rows/TrackRow.qml (which draws the cells) and
|
||||
// rows/TrackListHeader.qml (which labels them), so the header and the rows
|
||||
// under it cannot drift apart by construction.
|
||||
//
|
||||
// --- Why this file exists (the Slint reference is WRONG here) -------------
|
||||
// Slint hardcodes these widths TWICE: once in primitives/TrackRow.slint's
|
||||
// HorizontalLayout, and once again in every view that draws a column header.
|
||||
// There are four such headers, and THREE of them disagree with the row:
|
||||
//
|
||||
// playlist/PlaylistView.slint:979-1023 spacing 14, 32 · 36 · * · 220 ·
|
||||
// 70 · 92 · 28 · 28 · 32 <- correct
|
||||
// album/AlbumPageView.slint:811-880 spacing 16, 32 · * · 80 · 80 ·
|
||||
// 28 · 28 · 32 <- drifts
|
||||
// album/LocalAlbumView.slint:501-540 spacing 16, 32 · * · 80 · 80 ·
|
||||
// 28 · 32 <- drifts
|
||||
// mix/MixView.slint:246-284 spacing 16, 40 · * · 220 · 64 ·
|
||||
// 92 · 28 · 32 <- drifts
|
||||
// (and no 36px artwork reserve at
|
||||
// all, though its rows set
|
||||
// show-artwork: true)
|
||||
//
|
||||
// against primitives/TrackRow.slint:264-745 = padding 12, spacing 14,
|
||||
// number 32 · art 36 · title(stretch) · album 220 · duration 70 ·
|
||||
// quality 92 · favorite 28 · offline 28 · menu 32.
|
||||
//
|
||||
// So the reference's own arrangement is the hazard the owner reported. The
|
||||
// port takes Slint's NUMBERS (from the row, which is what actually renders)
|
||||
// and drops its arrangement: the numbers live here once, and the arithmetic
|
||||
// that turns them into a title-column width lives here once too, so a header
|
||||
// and a row asking the same question always get the same answer.
|
||||
//
|
||||
// --- Responsive arms -----------------------------------------------------
|
||||
// There are NONE. Neither primitives/TrackRow.slint nor any of the four
|
||||
// Slint headers has a width breakpoint; `compact` in AlbumPageView.slint is
|
||||
// the hero action rail, not the table. Every column is unconditional except
|
||||
// the five documented arms (artwork / album / favorite / download / menu),
|
||||
// and those are set per SURFACE, not per width. The only width-dependent
|
||||
// term is the title column, and `titleWidth()` clamps it at 0 so a very
|
||||
// narrow window collapses the title instead of dragging the trailing
|
||||
// columns off the row — identically for the header and for the row, because
|
||||
// it is the same function.
|
||||
|
||||
import QtQuick
|
||||
|
||||
QtObject {
|
||||
/// Row-body horizontal padding (TrackRow.slint:265-266).
|
||||
readonly property int padH: 12
|
||||
/// Inter-column gap (TrackRow.slint:267).
|
||||
readonly property int gap: 14
|
||||
|
||||
/// Number / play-cell (TrackRow.slint:344 `number-width: 32px`).
|
||||
readonly property int colNumber: 32
|
||||
/// Artwork thumbnail (TrackRow.slint:336, show-artwork arm).
|
||||
readonly property int colArt: 36
|
||||
/// Album link column (TrackRow.slint:540, show-album arm).
|
||||
readonly property int colAlbum: 220
|
||||
/// Duration (TrackRow.slint:569).
|
||||
readonly property int colDuration: 70
|
||||
/// Quality badge cell (TrackRow.slint:581).
|
||||
readonly property int colQuality: 92
|
||||
/// Heart (TrackRow.slint:600, show-favorite arm).
|
||||
readonly property int colFavorite: 28
|
||||
/// Offline/cloud slot (TrackRow.slint:650, show-download arm).
|
||||
readonly property int colDownload: 28
|
||||
/// ⋯ context menu (TrackRow.slint:740, show-menu arm).
|
||||
readonly property int colMenu: 32
|
||||
|
||||
/// Sum of every FIXED cell that is actually drawn for these arms.
|
||||
function fixedWidth(artwork, albumCol, favorite, download, menu) {
|
||||
return colNumber + (artwork ? colArt : 0) + (albumCol ? colAlbum : 0)
|
||||
+ colDuration + colQuality
|
||||
+ (favorite ? colFavorite : 0) + (download ? colDownload : 0)
|
||||
+ (menu ? colMenu : 0)
|
||||
}
|
||||
|
||||
/// Sum of the inter-column gaps. The unconditional cells are number,
|
||||
/// title, duration and quality = 3 gaps; each arm adds exactly one.
|
||||
/// (QML's Row skips invisible children entirely — no cell, no gap — and
|
||||
/// so does Slint's `if` in a HorizontalLayout, which is why the count is
|
||||
/// arm-dependent on both sides.)
|
||||
function gapWidth(artwork, albumCol, favorite, download, menu) {
|
||||
return (3 + (artwork ? 1 : 0) + (albumCol ? 1 : 0) + (favorite ? 1 : 0)
|
||||
+ (download ? 1 : 0) + (menu ? 1 : 0)) * gap
|
||||
}
|
||||
|
||||
/// The stretch column. `rowWidth` is the OUTER width of the row (the
|
||||
/// padding is subtracted here), so a header and a row that are the same
|
||||
/// width place every column at the same x.
|
||||
function titleWidth(rowWidth, artwork, albumCol, favorite, download, menu) {
|
||||
return Math.max(0, rowWidth - 2 * padH
|
||||
- fixedWidth(artwork, albumCol, favorite, download, menu)
|
||||
- gapWidth(artwork, albumCol, favorite, download, menu))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// TrackListHeader — the column header of the track table, the labelled twin
|
||||
// of rows/TrackRow.qml. Both take their geometry from rows/TrackCols.qml, so
|
||||
// a label sits over the column it names by construction rather than by two
|
||||
// people typing the same numbers.
|
||||
//
|
||||
// --- THE CONTRACT (two lines, both checkable) ---------------------------
|
||||
// 1. `width` MUST be the width the TrackRows below get. Not the page
|
||||
// width — the ROW width. A list inset for a scrollbar gutter
|
||||
// (PlaylistView's `anchors.rightMargin: 14`) or padded by its page
|
||||
// (MixView's `parent.width - 64`) hands its rows a narrower width, and
|
||||
// the header has to be narrowed the same way or every label after the
|
||||
// stretch column slides right by the difference.
|
||||
// 2. The five arms MUST be the arms the delegate sets. They gate BOTH the
|
||||
// cell and its gap, so a header that reserves a column the row does not
|
||||
// draw (or vice versa) shifts everything after it by width + 14.
|
||||
//
|
||||
// The owner's report was the second half of rule 2: the trailing unlabelled
|
||||
// columns (heart, offline cloud, ⋯) were missing from some headers, so they
|
||||
// took no space and every label was pushed right of its column. They are
|
||||
// reserved here as empty slots — `favoriteGlyph` / `downloadGlyph` draw the
|
||||
// 14px heart / cloud-download that AlbumPageView.slint:853-877 puts in the
|
||||
// heads of those two columns, the only labelling Slint gives them.
|
||||
//
|
||||
// `trailingReserve` covers the one surface whose row draws controls OUTSIDE
|
||||
// the shared TrackRow: views/local/LocalTrackRow.qml narrows the shared row
|
||||
// and re-draws the ⋯ plus a source glyph in the gutter it freed. The header
|
||||
// reserves the same slice so it stays the same width as the row it labels.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// --- Arms: MIRROR the TrackRow arms of the list underneath ----------
|
||||
// Defaults are TrackRow's own defaults, so a host that sets nothing gets
|
||||
// a header for a row that sets nothing.
|
||||
property bool showArtwork: false
|
||||
property bool showAlbum: false
|
||||
property bool showFavorite: true
|
||||
property bool showDownload: false
|
||||
property bool showMenu: true
|
||||
|
||||
/// Draw the 14px heart / cloud-download in the head of those two
|
||||
/// reserved columns (AlbumPageView.slint). Off = an empty reserved slot,
|
||||
/// which is what PlaylistView.slint and MixView.slint do.
|
||||
property bool favoriteGlyph: false
|
||||
property bool downloadGlyph: false
|
||||
|
||||
/// Label band height. 0 = the label's own line height, which is what the
|
||||
/// playlist / mix headers use (Slint gives those HorizontalLayouts no
|
||||
/// height, so they take the text's — 15px at Typography.legal, and
|
||||
/// hardcoding an 18 here would have added 3px to those pages' rhythm).
|
||||
/// The album headers set an explicit 40px band (AlbumPageView.slint:812).
|
||||
property int bandHeight: 0
|
||||
/// `letter-spacing: 0.5px` on the album headers; the playlist / mix
|
||||
/// headers have none.
|
||||
property real labelSpacing: 0
|
||||
/// Width the ROW draws outside the shared TrackRow (LocalTrackRow only).
|
||||
property int trailingReserve: 0
|
||||
|
||||
TrackCols { id: cols }
|
||||
QbzTheme { id: theme }
|
||||
|
||||
width: parent ? parent.width : 0
|
||||
height: bandH
|
||||
|
||||
/// Resolved band height (see `bandHeight`). The metric below is the same
|
||||
/// font the labels use, so "natural" tracks a font-size change.
|
||||
readonly property int bandH: root.bandHeight > 0
|
||||
? root.bandHeight : Math.ceil(metric.implicitHeight)
|
||||
Text {
|
||||
id: metric
|
||||
visible: false
|
||||
text: "Xg"
|
||||
font.pixelSize: theme.fontLegal
|
||||
}
|
||||
|
||||
/// The row's own outer width minus whatever it spends outside the shared
|
||||
/// TrackRow — i.e. exactly the width the shared row lays its cells in.
|
||||
readonly property int innerWidth: Math.max(0, root.width - root.trailingReserve)
|
||||
|
||||
component ColLabel: Text {
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
font.letterSpacing: root.labelSpacing
|
||||
height: root.bandH
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: cols.padH
|
||||
anchors.rightMargin: cols.padH
|
||||
spacing: cols.gap
|
||||
|
||||
// "#" — centred, because the cell it labels is TrackRow's play cell
|
||||
// and its number is centred in the same 32px. (Slint's album headers
|
||||
// left-align this one over a centred number; the playlist header
|
||||
// centres it. Centred is the one that lines up.)
|
||||
ColLabel {
|
||||
width: cols.colNumber
|
||||
text: "#"
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
// Artwork cell — unlabelled, reserved (PlaylistView.slint:990).
|
||||
Item {
|
||||
visible: root.showArtwork
|
||||
width: cols.colArt
|
||||
height: root.bandH
|
||||
}
|
||||
ColLabel {
|
||||
width: cols.titleWidth(root.innerWidth, root.showArtwork, root.showAlbum,
|
||||
root.showFavorite, root.showDownload, root.showMenu)
|
||||
text: QbzSession.tr("Title", QbzSession.trRev)
|
||||
}
|
||||
ColLabel {
|
||||
visible: root.showAlbum
|
||||
width: cols.colAlbum
|
||||
text: QbzSession.tr("Album", QbzSession.trRev)
|
||||
}
|
||||
ColLabel {
|
||||
width: cols.colDuration
|
||||
text: QbzSession.tr("Duration", QbzSession.trRev)
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
ColLabel {
|
||||
width: cols.colQuality
|
||||
text: QbzSession.tr("Quality", QbzSession.trRev)
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
// Heart column. `height: root.bandH` (not 1) so `centerIn`
|
||||
// centres the glyph on the band instead of pinning it to the top —
|
||||
// the bug AlbumView.qml carried and documented.
|
||||
Item {
|
||||
visible: root.showFavorite
|
||||
width: cols.colFavorite
|
||||
height: root.bandH
|
||||
QbzIcon {
|
||||
visible: root.favoriteGlyph
|
||||
anchors.centerIn: parent
|
||||
name: "heart"
|
||||
width: 14
|
||||
height: 14
|
||||
tintName: "muted"
|
||||
}
|
||||
}
|
||||
// Offline / cloud column.
|
||||
Item {
|
||||
visible: root.showDownload
|
||||
width: cols.colDownload
|
||||
height: root.bandH
|
||||
QbzIcon {
|
||||
visible: root.downloadGlyph
|
||||
anchors.centerIn: parent
|
||||
name: "cloud-download"
|
||||
width: 14
|
||||
height: 14
|
||||
tintName: "muted"
|
||||
}
|
||||
}
|
||||
// ⋯ column.
|
||||
Item {
|
||||
visible: root.showMenu
|
||||
width: cols.colMenu
|
||||
height: root.bandH
|
||||
}
|
||||
// Whatever the row draws outside the shared TrackRow.
|
||||
Item {
|
||||
visible: root.trailingReserve > 0
|
||||
// The gap before it is already spent by the Row, so the slot
|
||||
// itself is the reserve minus that gap.
|
||||
width: Math.max(0, root.trailingReserve - cols.gap)
|
||||
height: root.bandH
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,9 @@
|
||||
// (plus playlistTrackId for the remove-from-playlist arm). `qualityDetail`
|
||||
// is the bare exact-quality string ("16-bit / 44.1 kHz"); when a producer
|
||||
// does not carry it yet the badge degrades to the tier label alone rather
|
||||
// than to a blank cell.
|
||||
// than to a blank cell. `item.isFavorite` SEEDS the row's own `favorite`
|
||||
// property and is never written back — see the heart block below for why a
|
||||
// plain-JS-object field cannot be the live state.
|
||||
//
|
||||
// Arms: showArtwork / showAlbum / showFavorite / showDownload /
|
||||
// downloadGlyph / showMenu / zebra / artistLink / clickPlays /
|
||||
@@ -42,6 +44,13 @@
|
||||
// Deliberately NOT consolidated here (Slint-distinct): QueuePanel.QueueRow
|
||||
// (QueueItem data + queue-op menu + press-and-hold reorder) and
|
||||
// LibraryView.FeedListRow (mixed-kind feed row — inline in Slint too).
|
||||
//
|
||||
// --- COLUMN GEOMETRY LIVES IN rows/TrackCols.qml ------------------------
|
||||
// Not in this file. rows/TrackListHeader.qml reads the same object, so the
|
||||
// header of a list and the rows in it cannot disagree. If you change a
|
||||
// column width, change it THERE — a literal typed back into this file is
|
||||
// the exact defect the owner reported (the header laid out only the
|
||||
// labelled columns, so every label sat right of the column it named).
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
@@ -116,6 +125,55 @@ Rectangle {
|
||||
signal removeRequested()
|
||||
signal bodyDragStarted(int index)
|
||||
|
||||
// --- THE heart state (a real QML property, never `item.isFavorite`) ---
|
||||
// The row used to flip its glyph with `root.item.isFavorite = !…`. That is
|
||||
// a write into a plain JS object and it does nothing at all here, twice
|
||||
// over — both halves measured under qml6 6.11.1 in this exact delegate
|
||||
// shape (`Repeater { model: JSON.parse(...) }` + `required property var
|
||||
// modelData` + `item: modelData`):
|
||||
//
|
||||
// 1. QML does not re-evaluate a binding when a plain JS object's
|
||||
// property is mutated — there is no notifier to fire. The glyph
|
||||
// binding kept its old value on the same tick AND on the next.
|
||||
// 2. `item: modelData` does not even alias the row: assigning a `var`
|
||||
// property from `modelData` yields an object that compares `!==` to
|
||||
// both `modelData` and the source array element (Qt round-trips it
|
||||
// through QVariant). So the write did not reach the model either —
|
||||
// not the delegate's `modelData`, not the host's array.
|
||||
//
|
||||
// A DECLARED property has a notifier, so the optimistic flip repaints
|
||||
// immediately. The binding below keeps the document authoritative: it is
|
||||
// re-established whenever the host hands over a new row object, so a
|
||||
// republished document (or a page re-open) overrides a stale local flip.
|
||||
property bool favorite: root.item.isFavorite === true
|
||||
onItemChanged: root.favorite = Qt.binding(function () {
|
||||
return root.item.isFavorite === true
|
||||
})
|
||||
|
||||
/// The heart click and the menu's "Add to / Remove from Library" row —
|
||||
/// ONE implementation, because they used to diverge silently.
|
||||
function toggleFavorite() {
|
||||
root.favorite = !root.favorite
|
||||
QbzLibrary.libraryToggleFavorite("track", root.item.id)
|
||||
}
|
||||
|
||||
// Settle. `libraryFavoriteChanged` (key `{kind}:{id}`, emitted by
|
||||
// main.rs::emit_library_favorite) carries the value the WRITE produced:
|
||||
// the flipped one when it landed, the UNCHANGED one when it failed. This
|
||||
// is the favourite twin of the pin fan-out the cards already have
|
||||
// (cards/AlbumCard.qml) and it is what rolls a 404'd un-favorite back —
|
||||
// and what keeps the OTHER surfaces showing this same track honest
|
||||
// (the album page's row, the queue, a search result) without anybody
|
||||
// republishing a document.
|
||||
Connections {
|
||||
target: QbzLibrary
|
||||
function onLibraryFavoriteChanged(key, value) {
|
||||
var tid = (root.item && root.item.id !== undefined) ? root.item.id : ""
|
||||
if (tid !== "" && key === "track:" + tid)
|
||||
root.favorite = value
|
||||
}
|
||||
}
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// --- Polarity-baked tokens for the play cell (#638) -------------------
|
||||
@@ -155,10 +213,17 @@ Rectangle {
|
||||
readonly property bool hovered: trArea.containsMouse || favArea.containsMouse
|
||||
|| moreArea.containsMouse || playArea.containsMouse
|
||||
readonly property bool isActive: QbzPlayer.npTrackId === (item.id || "")
|
||||
readonly property int cellsRight: 70 + 92 + (showFavorite ? 28 : 0) + (showDownload ? 28 : 0) + (showMenu ? 32 : 0)
|
||||
readonly property int cellsLeft: 32 + (showArtwork ? 36 : 0) + (showAlbum ? 220 : 0)
|
||||
readonly property int gaps: (3 + (showArtwork ? 1 : 0) + (showAlbum ? 1 : 0)
|
||||
+ (showFavorite ? 1 : 0) + (showDownload ? 1 : 0) + (showMenu ? 1 : 0)) * 14
|
||||
|
||||
// --- Column geometry: rows/TrackCols.qml, NOT literals ---------------
|
||||
// Every width, the padding and the gap come from the one file that
|
||||
// rows/TrackListHeader.qml also reads, so the header above a list of
|
||||
// these rows lines up with them by construction. The title column's
|
||||
// arithmetic lives there too (`titleWidth`) — the header asks the same
|
||||
// function the same question and gets the same answer.
|
||||
TrackCols { id: cols }
|
||||
readonly property int titleColWidth: cols.titleWidth(root.width,
|
||||
root.showArtwork, root.showAlbum, root.showFavorite,
|
||||
root.showDownload, root.showMenu)
|
||||
|
||||
// Static now-playing mark: 3px accent pill on the left edge.
|
||||
Rectangle {
|
||||
@@ -173,9 +238,9 @@ Rectangle {
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 12
|
||||
spacing: 14
|
||||
anchors.leftMargin: cols.padH
|
||||
anchors.rightMargin: cols.padH
|
||||
spacing: cols.gap
|
||||
|
||||
// Number cell — the number-variant of primitives/TrackPlayCell.slint
|
||||
// (:172-232), the play affordance shared by every track row.
|
||||
@@ -207,7 +272,7 @@ Rectangle {
|
||||
// carries the separation on its own.
|
||||
Item {
|
||||
id: playCell
|
||||
width: 32
|
||||
width: cols.colNumber
|
||||
height: 40
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
// `show-overlay: row-hovered || is-active` (TrackPlayCell.slint
|
||||
@@ -266,8 +331,8 @@ Rectangle {
|
||||
// 36px artwork cell (showArtwork arm).
|
||||
Rectangle {
|
||||
visible: root.showArtwork
|
||||
width: 36
|
||||
height: 36
|
||||
width: cols.colArt
|
||||
height: cols.colArt
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
radius: 4
|
||||
color: theme.surfaceElevated
|
||||
@@ -292,7 +357,7 @@ Rectangle {
|
||||
}
|
||||
// Title (+ explicit) / artist.
|
||||
Column {
|
||||
width: parent.width - root.cellsLeft - root.cellsRight - root.gaps
|
||||
width: root.titleColWidth
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
Row {
|
||||
@@ -342,7 +407,7 @@ Rectangle {
|
||||
// Album (link) column (showAlbum arm).
|
||||
Text {
|
||||
visible: root.showAlbum
|
||||
width: 220
|
||||
width: cols.colAlbum
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.item.album || ""
|
||||
color: albumArea.containsMouse ? theme.accent : theme.textMuted
|
||||
@@ -358,7 +423,7 @@ Rectangle {
|
||||
}
|
||||
// Duration.
|
||||
Text {
|
||||
width: 70
|
||||
width: cols.colDuration
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.item.duration || ""
|
||||
color: theme.textMuted
|
||||
@@ -385,7 +450,7 @@ Rectangle {
|
||||
// QualityBadge, which carries a ToolTip popup and a hover MouseArea
|
||||
// per row; this one is two Texts.
|
||||
Item {
|
||||
width: 92
|
||||
width: cols.colQuality
|
||||
height: parent.height
|
||||
clip: true
|
||||
QualityBadgeFull {
|
||||
@@ -399,19 +464,19 @@ Rectangle {
|
||||
// Favorite (showFavorite arm).
|
||||
Rectangle {
|
||||
visible: root.showFavorite
|
||||
width: 28
|
||||
height: 28
|
||||
width: cols.colFavorite
|
||||
height: cols.colFavorite
|
||||
radius: theme.radiusSm
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: favArea.containsMouse ? theme.surfaceElevated : "transparent"
|
||||
QbzIcon {
|
||||
anchors.centerIn: parent
|
||||
name: root.item.isFavorite ? "heart-filled" : "heart"
|
||||
name: root.favorite ? "heart-filled" : "heart"
|
||||
width: 16
|
||||
height: 16
|
||||
// TrackRow.slint:619 — hover raises the glyph to
|
||||
// Theme.text-primary, NOT to white (same #638 hole).
|
||||
tintName: root.item.isFavorite
|
||||
tintName: root.favorite
|
||||
? "favorite"
|
||||
: (favArea.containsMouse ? root.playGlyphTint : "muted")
|
||||
}
|
||||
@@ -420,10 +485,7 @@ Rectangle {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
root.item.isFavorite = !root.item.isFavorite
|
||||
QbzLibrary.libraryToggleFavorite("track", root.item.id)
|
||||
}
|
||||
onClicked: root.toggleFavorite()
|
||||
}
|
||||
}
|
||||
// Download slot (showDownload arm): inert glyph stub or reserved
|
||||
@@ -431,8 +493,8 @@ Rectangle {
|
||||
// Slint reserves the slot so the grid stays aligned).
|
||||
Item {
|
||||
visible: root.showDownload
|
||||
width: 28
|
||||
height: 28
|
||||
width: cols.colDownload
|
||||
height: cols.colDownload
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
QbzIcon {
|
||||
visible: root.downloadGlyph
|
||||
@@ -446,8 +508,8 @@ Rectangle {
|
||||
// ⋯ menu (showMenu arm).
|
||||
Rectangle {
|
||||
visible: root.showMenu
|
||||
width: 32
|
||||
height: 32
|
||||
width: cols.colMenu
|
||||
height: cols.colMenu
|
||||
radius: theme.radiusSm
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: moreArea.containsMouse ? theme.surfaceElevated : "transparent"
|
||||
@@ -494,8 +556,8 @@ Rectangle {
|
||||
m.push({ "label": t("Create Qobuz radio", r), "icon": "radio", "action": "radio-qobuz" })
|
||||
}
|
||||
if (root.menuShowFavorite)
|
||||
m.push({ "label": root.item.isFavorite ? t("Remove from Library", r) : t("Add to Library", r),
|
||||
"icon": root.item.isFavorite ? "heart-filled" : "heart", "action": "favorite" })
|
||||
m.push({ "label": root.favorite ? t("Remove from Library", r) : t("Add to Library", r),
|
||||
"icon": root.favorite ? "heart-filled" : "heart", "action": "favorite" })
|
||||
if (root.hasMixtapeSeam)
|
||||
m.push({ "label": t("Add to mixtape", r), "icon": "cassette-tape", "action": "mixtape" })
|
||||
if (root.hasPlaylistAddSeam)
|
||||
@@ -525,10 +587,8 @@ Rectangle {
|
||||
else if (a === "queue") root.enqueueRequested("queue")
|
||||
else if (a === "go-artist") QbzArtist.openArtist(root.item.artistId)
|
||||
else if (a === "go-album") QbzAlbum.openAlbum(root.item.albumId)
|
||||
else if (a === "favorite") {
|
||||
root.item.isFavorite = !root.item.isFavorite
|
||||
QbzLibrary.libraryToggleFavorite("track", root.item.id)
|
||||
} else if (a === "remove") root.removeRequested()
|
||||
else if (a === "favorite") root.toggleFavorite()
|
||||
else if (a === "remove") root.removeRequested()
|
||||
else if (a === "share-qobuz") root.copyToClipboard(
|
||||
"https://open.qobuz.com/track/" + (root.item.id || ""))
|
||||
else if (a === "track-info") root.openTrackInfo()
|
||||
|
||||
@@ -4,18 +4,21 @@
|
||||
// 1 = mini (icons only), 2 = closed 0px. Width animates 160ms ease-in-out;
|
||||
// the header's panel-left button cycles (QbzShell.cycleSidebar).
|
||||
//
|
||||
// MINI RAIL: the rail is 50px here, not Slint's 64 — a deliberate,
|
||||
// one-token owner deviation (theme.sidebarMiniWidth) so the square 34px row
|
||||
// exactly fills the 8px-padded track. Everything else in the mini state is
|
||||
// 1:1 with Slint: rows centre their lone glyph, nested playlist rows
|
||||
// collapse to zero, hovering a row names it in a bubble to the right
|
||||
// (SidebarTooltip.slint) and clicking a FOLDER opens its playlists in a
|
||||
// MINI RAIL: 64px, 1:1 with Slint (state.slint:4075), holding a 34px row
|
||||
// (Sidebar.slint:575/652) on symmetric 15px padding. It was 50px for a while —
|
||||
// an owner deviation that turned out to put the rail's centre 5px left of the
|
||||
// header control sitting directly above it; see `miniPadLeft` below and
|
||||
// QbzTheme.sidebarMiniWidth for the measurement. Everything else
|
||||
// in the mini state is 1:1 with Slint: rows centre their lone glyph, nested
|
||||
// playlist rows collapse to zero, hovering a row names it in a bubble to the
|
||||
// right (SidebarTooltip.slint) and clicking a FOLDER opens its playlists in a
|
||||
// flyout to the right (SidebarFolderPopup.slint) — in mini a folder cannot
|
||||
// expand in place, so a toggle there would be a control that does nothing.
|
||||
//
|
||||
// FILE LENGTH (>500): the two mini affordances above are shared single
|
||||
// instances declared at this root (never one Popup per row), which is what
|
||||
// keeps them cheap; they are extraction candidates into
|
||||
// FILE LENGTH (>500): the mini folder flyout is a shared single instance
|
||||
// declared at this root (never one Popup per row), which is what keeps it
|
||||
// cheap; the hover bubble is not here at all any more — it is the shell's
|
||||
// shared QbzTooltip overlay. The flyout is an extraction candidate into
|
||||
// qml/shell/SidebarFolderFlyout.qml, but a new .qml also needs a build.rs
|
||||
// entry and an unregistered type fails the WHOLE file at load, so they stay
|
||||
// here until that glue lands.
|
||||
@@ -52,6 +55,34 @@ Rectangle {
|
||||
id: root
|
||||
|
||||
property bool mini: QbzShell.sidebarState === 1
|
||||
// The shell's ONE hover-tooltip overlay (controls/QbzTooltip.qml), fed in
|
||||
// by AppShell. Null when this component is mounted in isolation, in which
|
||||
// case the rail simply shows no bubble.
|
||||
property Item tooltip: null
|
||||
|
||||
// ---- Collapsed-rail optical centring ---------------------------------
|
||||
// MEASURED, not nudged (release binary, 1600x1000, xcb, pixels sampled off
|
||||
// the framebuffer). The finding that mattered: the rail's content was
|
||||
// ALREADY symmetric on the sidebar's own geometry — hover pill centre 25.0
|
||||
// against a rail centre of 25.0 — and the owner still read it as off. The
|
||||
// eye was not comparing the glyphs to the rail. It was comparing them to
|
||||
// the header's leading control, the panel-left sidebar toggle, which sits
|
||||
// at x = spacingMd(16) and is 28px wide (HeaderBar.qml:225 +
|
||||
// QbzNavButton.qml:102) -> centre 30, directly above them. Five pixels
|
||||
// apart in one vertical line is visible; symmetric padding does not help.
|
||||
//
|
||||
// Nor is there a visible rail edge to centre against: the sidebar is
|
||||
// surface-card and the content frame's 8px left gutter is surface-card too
|
||||
// (AppShell.qml:149 paints the panel; the frame around it is the shell
|
||||
// base), so it reads as one uninterrupted band, sampled (26,26,26) from
|
||||
// x=1 to x=57 and (15,15,15) from 58 — band centre 29.
|
||||
//
|
||||
// The rail is back at Slint's 64px, which dissolves the conflict instead
|
||||
// of trading one misalignment for another: row centre 32 vs header button
|
||||
// 30. See QbzTheme.sidebarMiniWidth for why 50 was the actual defect.
|
||||
// Padding is symmetric and DERIVED, so the pair can never drift apart.
|
||||
readonly property int miniPadLeft: (theme.sidebarMiniWidth - theme.sidebarMiniRow) / 2
|
||||
readonly property int miniPadRight: root.miniPadLeft
|
||||
// Which section owns the highlight. DERIVED from the live view (in
|
||||
// NavFlyout, shared with the header), not set on click: the same rows can
|
||||
// live here or in the header, and history navigation (back/forward) must
|
||||
@@ -79,6 +110,11 @@ Rectangle {
|
||||
|
||||
// Flattened entries from the bridge + the url-keyed cover map.
|
||||
readonly property var entries: parseEntries(QbzShell.sidebarJson)
|
||||
// A republish rebuilds the Repeater's delegates, so the row a bubble is
|
||||
// anchored to may be gone or moved and its MouseArea will never report the
|
||||
// leave. The overlay outlives the delegate by design (it stores numbers,
|
||||
// never the Item), so nothing dangles — but it would linger, so drop it.
|
||||
onEntriesChanged: if (root.tooltip) root.tooltip.hideAll()
|
||||
property var coverMap: ({})
|
||||
function parseEntries(json) {
|
||||
var e = JSON.parse(json)
|
||||
@@ -117,27 +153,28 @@ Rectangle {
|
||||
// nested rows are hidden in mini (SidebarFolderPopup.slint).
|
||||
// Both are ONE shared instance driven by the hovered/clicked row (exactly
|
||||
// like Slint's SidebarTooltipState / SidebarFolderPopupState), never one
|
||||
// Popup per delegate: the tree can hold hundreds of rows.
|
||||
// Both are QtQuick.Controls popups, so their content is reparented to the
|
||||
// WINDOW overlay and this root's `clip: true` cannot cut them — the same
|
||||
// reason NavFlyout and HeaderBar's playlists flyout are Popups.
|
||||
// per delegate: the tree can hold hundreds of rows.
|
||||
// Neither can live inside this root's bounds, because it clips. The FLYOUT
|
||||
// is a QtQuick.Controls Popup, so its content is reparented to the WINDOW
|
||||
// overlay — the same reason NavFlyout and HeaderBar's playlists flyout are
|
||||
// Popups. The BUBBLE is not a popup at all: it is the shell-level
|
||||
// QbzTooltip overlay (`root.tooltip`, fed in by AppShell), for the reasons
|
||||
// spelled out where the old ToolTip used to be declared, below.
|
||||
|
||||
// Race-safe show/hide (Slint SidebarRow.show-tooltip / hide-tooltip): the
|
||||
// leaving row only clears the bubble if it still owns it, so sliding the
|
||||
// pointer straight from one row to the next never blanks it.
|
||||
// pointer straight from one row to the next never blanks it. Both halves
|
||||
// now live in the shared overlay, keyed by the entry id.
|
||||
function showMiniTip(row, entry) {
|
||||
if (!root.mini) return
|
||||
var p = row.mapToItem(root, row.width + 6, 0)
|
||||
miniTip.anchorX = p.x
|
||||
miniTip.anchorY = p.y
|
||||
miniTip.rowH = row.height
|
||||
miniTip.tipText = entry.kind === "folder"
|
||||
if (!root.mini || !root.tooltip)
|
||||
return
|
||||
root.tooltip.showRight(row, entry.id, entry.kind === "folder"
|
||||
? entry.name + " (" + entry.count + ")"
|
||||
: entry.name
|
||||
miniTip.ownerId = entry.id
|
||||
: entry.name)
|
||||
}
|
||||
function hideMiniTip(id) {
|
||||
if (miniTip.ownerId === id) miniTip.ownerId = ""
|
||||
if (root.tooltip)
|
||||
root.tooltip.hide(id)
|
||||
}
|
||||
|
||||
// Mini folder click: the flyout, not a toggle (the nested rows are hidden,
|
||||
@@ -156,6 +193,10 @@ Rectangle {
|
||||
var p = row.mapToItem(root, row.width + 6, 0)
|
||||
folderFlyout.x = p.x
|
||||
folderFlyout.y = p.y
|
||||
// The flyout takes the same +6px slot the bubble occupies, and it is a
|
||||
// Popup (window overlay) so it would paint straight over it. Slint drops
|
||||
// the tooltip explicitly here too (Sidebar.slint:353).
|
||||
if (root.tooltip) root.tooltip.hideAll()
|
||||
folderFlyout.open()
|
||||
}
|
||||
|
||||
@@ -166,49 +207,32 @@ Rectangle {
|
||||
target: QbzShell
|
||||
function onSidebarStateChanged() {
|
||||
folderFlyout.close()
|
||||
miniTip.ownerId = ""
|
||||
// The bubble's anchor is a rail row that is about to move or stop
|
||||
// existing (SidebarTooltip.slint:14 gates on sidebar-mini for the
|
||||
// same reason).
|
||||
if (root.tooltip) root.tooltip.hideAll()
|
||||
}
|
||||
}
|
||||
|
||||
// Hover bubble — SidebarTooltip.slint: surface-elevated, radius sm, 1px
|
||||
// border-muted, 10/10/5/5 padding, 12px w500 text-primary, VISUAL ONLY
|
||||
// (a ToolTip never takes the pointer, so it cannot block the row it
|
||||
// describes).
|
||||
ToolTip {
|
||||
id: miniTip
|
||||
property string ownerId: ""
|
||||
property string tipText: ""
|
||||
property real anchorX: 0
|
||||
property real anchorY: 0
|
||||
property real rowH: 32
|
||||
|
||||
// Auto-hides the moment the sidebar leaves mini (SidebarTooltip
|
||||
// .slint:14) or the folder flyout takes over the same +6px slot
|
||||
// (Slint drops it explicitly on click, Sidebar.slint:353).
|
||||
visible: root.mini && ownerId !== "" && !folderFlyout.visible
|
||||
delay: 0
|
||||
timeout: -1
|
||||
padding: 0
|
||||
x: anchorX
|
||||
y: anchorY + Math.round((rowH - implicitHeight) / 2)
|
||||
contentItem: Text {
|
||||
text: miniTip.tipText
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: 12
|
||||
font.weight: theme.weightMedium
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
leftPadding: 10
|
||||
rightPadding: 10
|
||||
topPadding: 5
|
||||
bottomPadding: 5
|
||||
}
|
||||
background: Rectangle {
|
||||
color: theme.surfaceElevated
|
||||
radius: theme.radiusSm
|
||||
border.width: 1
|
||||
border.color: theme.borderMuted
|
||||
}
|
||||
}
|
||||
// The hover bubble that names a collapsed row is NOT declared here any
|
||||
// more: it is the shell's shared overlay (controls/QbzTooltip.qml, mounted
|
||||
// once by AppShell), which this file drives through showMiniTip /
|
||||
// hideMiniTip above. Two reasons, in order of severity:
|
||||
//
|
||||
// 1. THE OLD ONE WAS BROKEN. It was a QtQuick.Controls `ToolTip` with a
|
||||
// custom contentItem, and a ToolTip does not propagate a custom
|
||||
// content's implicit size: measured on Qt 6.11.1, an identical Text
|
||||
// inside a `Popup` gives implicitContentWidth 157.8 and inside a
|
||||
// `ToolTip` gives 1. The Basic style then sizes the popup to
|
||||
// `implicitContentWidth + padding`, so the bubble collapsed to ~13x25
|
||||
// while the Text — which a Popup never clips — kept painting its full
|
||||
// 178px: the label floated over the content pane with no background and
|
||||
// a small detached box sat beside it (owner report, reproduced and
|
||||
// pixel-sampled before the change).
|
||||
// 2. Slint does not put a popup per call site either. It has ONE overlay
|
||||
// fed by a global channel (SidebarTooltipState, state.slint:1893;
|
||||
// TooltipState, state.slint:4791), precisely so a clipped surface — and
|
||||
// this sidebar clips — can still show a bubble outside its own bounds.
|
||||
|
||||
// Folder flyout — SidebarFolderPopup.slint: 230px, 30px header row +
|
||||
// hairline + up to four 32px rows, 4px inner padding, surface-main /
|
||||
@@ -451,8 +475,11 @@ Rectangle {
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: root.mini ? 8 : theme.spacingMd
|
||||
anchors.rightMargin: root.mini ? 8 : theme.spacingMd
|
||||
// Mini left/right are ASYMMETRIC on purpose — see `miniPadLeft` at the
|
||||
// top of this file for the measurement. Top stays 8 (nothing above the
|
||||
// rail competes with it vertically).
|
||||
anchors.leftMargin: root.mini ? root.miniPadLeft : theme.spacingMd
|
||||
anchors.rightMargin: root.mini ? root.miniPadRight : theme.spacingMd
|
||||
anchors.topMargin: root.mini ? 8 : theme.spacingMd
|
||||
// Per-side padding so Large can zero ONLY the bottom (Sidebar.slint:719):
|
||||
// the reserved dock band then reaches the true window bottom-left and the
|
||||
@@ -1031,11 +1058,22 @@ Rectangle {
|
||||
// Placed in the sidebar's right PADDING (negative margin), like
|
||||
// Slint's `x: parent.width - width + (mini ? 8 : Spacing.md)`, so
|
||||
// the gutter never overlays the rows and cannot eat their clicks.
|
||||
//
|
||||
// MINI CAVEAT after the optical re-centring above: the right pad is
|
||||
// 4px now, so an 8px gutter pinned to the rail's edge shares its
|
||||
// left 4px with the rows. That is fine to LOOK at (an 8px-wide,
|
||||
// auto-hiding indicator whose thumb grazes the pill's rounded right
|
||||
// edge) but not to CLICK through, so the mini instance is
|
||||
// `enabled: false`: indicator only, no drag / click-to-position.
|
||||
// Nothing is lost — the wheel scrolls the rail, and a 3px-of-travel
|
||||
// drag inside a 50px rail was never a real affordance. The open
|
||||
// sidebar keeps the full interactive bar.
|
||||
QbzScrollBar {
|
||||
target: plFlick
|
||||
enabled: !root.mini
|
||||
width: root.mini ? 8 : 10
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: -(root.mini ? 8 : theme.spacingMd)
|
||||
anchors.rightMargin: -(root.mini ? root.miniPadRight : theme.spacingMd)
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
}
|
||||
|
||||
@@ -348,19 +348,29 @@ QtObject {
|
||||
readonly property int headerHeight: 42
|
||||
// Three-state sidebar rendered widths (ShellState.sidebar-rendered-width).
|
||||
readonly property int sidebarOpenWidth: 240
|
||||
// Mini rail — DELIBERATE deviation from state.slint:4075 (64px), owner
|
||||
// request: the 64px rail read as a wide empty gutter around a 32px row.
|
||||
// rail = spacingSm + row + spacingSm, so the square mini row exactly
|
||||
// fills the 8px-padded track (Sidebar.qml root padding = Sidebar.slint:
|
||||
// 716-718). REVERT is TWO tokens, not one: putting 64 back here also
|
||||
// makes sidebarMiniRow 48, so the rows would grow from 34 to 48 and stop
|
||||
// matching Slint — set sidebarMiniRow to a literal 34 at the same time.
|
||||
// (Adversarial review caught this comment claiming a one-token revert.)
|
||||
readonly property int sidebarMiniWidth: 50
|
||||
// The square row inside that rail (34px). Rows are `width: parent.width`,
|
||||
// so this is only their HEIGHT; it exists as a token so the rail and the
|
||||
// row can never drift apart.
|
||||
readonly property int sidebarMiniRow: sidebarMiniWidth - 2 * spacingSm
|
||||
// Mini rail — back to Slint's 64px (state.slint:4075) by owner decision,
|
||||
// 2026-07-29, after the 50px deviation turned out to have a cost nobody
|
||||
// had priced.
|
||||
//
|
||||
// The 50px came from an earlier owner request: the 64px rail read as a
|
||||
// wide empty gutter. But 50 made two references irreconcilable — the rail
|
||||
// centres its 34px row at 25, while the header's own sidebar toggle sits
|
||||
// at centre 30 (spacingMd + half of 28). Five pixels apart, one directly
|
||||
// above the other, which is what the owner saw and reported as "not
|
||||
// centred": symmetric padding inside the rail still looked wrong because
|
||||
// the eye lines the glyphs up against the header control, not against the
|
||||
// rail. Slint never had the problem — at 64 the row centres at 32, two
|
||||
// pixels off the header button and imperceptible. Chasing it from inside
|
||||
// the rail (12/4 padding) bought the header alignment at the price of a
|
||||
// visibly asymmetric hover pill; the root fix is the width.
|
||||
readonly property int sidebarMiniWidth: 64
|
||||
// The square row inside that rail. A LITERAL 34, not derived: at 50 the
|
||||
// `width - 2 * spacingSm` formula happened to yield 34, which is why the
|
||||
// two tokens were coupled — but Slint pins BOTH independently (rail 64px,
|
||||
// row 34px at Sidebar.slint:575/652), so the same formula at 64 would give
|
||||
// 48 and silently grow every row. Deriving it was the trap the previous
|
||||
// comment warned about; pinning it is the fix.
|
||||
readonly property int sidebarMiniRow: 34
|
||||
// Right queue/lyrics column width (AppShell).
|
||||
readonly property int queuePanelWidth: 300
|
||||
// Small now-playing bar total height (ShellState.npb-small-height,
|
||||
|
||||
@@ -143,6 +143,17 @@ Rectangle {
|
||||
m[key] = path
|
||||
root.coverMap = Object.assign({}, m)
|
||||
}
|
||||
// The SETTLED heart from Rust: the flipped value when the write
|
||||
// landed, the UNCHANGED one when it failed. The header click writes
|
||||
// its optimistic flip into localToggles and nothing used to correct
|
||||
// it, so a 404'd un-favorite stayed visibly un-favorited until the
|
||||
// user navigated away — LibraryView.qml was this signal's only
|
||||
// listener. Key shape is `library_qt::feed_key` (`{kind}:{id}`).
|
||||
function onLibraryFavoriteChanged(key, value) {
|
||||
var id = (header && header.id) ? header.id : ""
|
||||
if (id !== "" && key === "album:" + id)
|
||||
root.setToggleState("album", value)
|
||||
}
|
||||
}
|
||||
Component.onCompleted: { syncAlbumState(); dispatchCovers() }
|
||||
onAlbumChanged: { syncAlbumState(); dispatchCovers() }
|
||||
@@ -822,40 +833,31 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Column header.
|
||||
Row {
|
||||
// Column header — rows/TrackListHeader.qml, i.e. the SAME
|
||||
// rows/TrackCols.qml geometry the TrackRows below use.
|
||||
//
|
||||
// What was here disagreed with the rows on every number:
|
||||
// spacing 16 vs the row's 14, Duration 80 vs 70, Quality
|
||||
// 80 vs 92, and a title width that counted five gaps
|
||||
// where the layout draws six. Those are Slint's own
|
||||
// header numbers (AlbumPageView.slint:811-880) and they
|
||||
// do NOT match primitives/TrackRow.slint — the reference
|
||||
// has the same defect the owner reported here, so the
|
||||
// port keeps the row's numbers and drops the second
|
||||
// hardcoded copy entirely.
|
||||
//
|
||||
// The heart / cloud glyphs stay (they are the only
|
||||
// labelling those two columns get) — and they still fill
|
||||
// the band so `centerIn` centres them, which is the fix
|
||||
// this block used to document at length.
|
||||
TrackListHeader {
|
||||
visible: !QbzAlbum.albumLoading
|
||||
width: parent.width
|
||||
height: 40
|
||||
leftPadding: 12
|
||||
rightPadding: 12
|
||||
spacing: 16
|
||||
Text { text: "#"; width: 32; color: theme.textMuted; font.pixelSize: 13; font.letterSpacing: 0.5; anchors.verticalCenter: parent.verticalCenter }
|
||||
Text { text: QbzSession.tr("Title", QbzSession.trRev); width: parent.width - 32 - 80 - 80 - 28 - 28 - 32 - 5 * 16 - 24; color: theme.textMuted; font.pixelSize: 13; font.letterSpacing: 0.5; anchors.verticalCenter: parent.verticalCenter; elide: Text.ElideRight }
|
||||
Text { text: QbzSession.tr("Duration", QbzSession.trRev); width: 80; color: theme.textMuted; font.pixelSize: 13; font.letterSpacing: 0.5; horizontalAlignment: Text.AlignHCenter; anchors.verticalCenter: parent.verticalCenter }
|
||||
Text { text: QbzSession.tr("Quality", QbzSession.trRev); width: 80; color: theme.textMuted; font.pixelSize: 13; font.letterSpacing: 0.5; horizontalAlignment: Text.AlignHCenter; anchors.verticalCenter: parent.verticalCenter }
|
||||
// Heart / offline-cache column heads. AlbumPageView
|
||||
// .slint:854-877 declares these as `Rectangle { width:
|
||||
// 28px; }` with NO height — a box-layout child with no
|
||||
// fixed cross-axis size is STRETCHED to the row, and
|
||||
// the glyph is then centred inside it (`y: (parent
|
||||
// .height - self.height) / 2`). The port gave them
|
||||
// `height: 1`, so `centerIn` centred the glyph on a
|
||||
// 1px item pinned at the top of the 40px band: the two
|
||||
// icons floated above the text labels, which do centre.
|
||||
// Fill the band and centre, exactly as the .slint does.
|
||||
Item {
|
||||
width: 28
|
||||
height: parent.height
|
||||
QbzIcon { name: "heart"; width: 14; height: 14; anchors.centerIn: parent; tintName: "muted" }
|
||||
}
|
||||
Item {
|
||||
width: 28
|
||||
height: parent.height
|
||||
QbzIcon { name: "cloud-download"; width: 14; height: 14; anchors.centerIn: parent; tintName: "muted" }
|
||||
}
|
||||
// Aligns with the per-row more-button slot (.slint:878).
|
||||
Item { width: 32; height: parent.height }
|
||||
bandHeight: 40
|
||||
labelSpacing: 0.5
|
||||
showDownload: true
|
||||
favoriteGlyph: true
|
||||
downloadGlyph: true
|
||||
}
|
||||
|
||||
// Rows (with Disc / work headers).
|
||||
|
||||
@@ -32,6 +32,7 @@ import QtQuick.Window
|
||||
import com.blitzfc.qbz
|
||||
import "../cards"
|
||||
import "../controls"
|
||||
import "../rows"
|
||||
import "../theme"
|
||||
|
||||
Rectangle {
|
||||
@@ -289,6 +290,30 @@ Rectangle {
|
||||
root._coverInbox[key] = path
|
||||
if (!coverFlush.running) coverFlush.start()
|
||||
}
|
||||
// The SETTLED follow/heart state from Rust: the flipped value when the
|
||||
// write landed, the UNCHANGED one when it failed. Both the header
|
||||
// follow and the Popular Tracks / Appears On hearts write their
|
||||
// optimistic flip into localToggles and nothing used to correct it, so
|
||||
// a failed write stayed visibly wrong until the user left the page.
|
||||
// Key shape is `library_qt::feed_key` (`{kind}:{id}`) — which is
|
||||
// already the exact key the track rows use for their override.
|
||||
function onLibraryFavoriteChanged(key, value) {
|
||||
var aid = (artist && artist.id) ? artist.id : ""
|
||||
if (aid !== "" && key === "artist:" + aid)
|
||||
root.setToggleState("artist", value)
|
||||
else if (key.indexOf("track:") === 0)
|
||||
root.setToggleState(key, value)
|
||||
}
|
||||
// Header pin, same seam: the overflow-menu row flips `artistPin`
|
||||
// optimistically, and this settles it from the store (Slint does the
|
||||
// same for the open detail view: `st.set_pinned(pinned)` when its id
|
||||
// matches). The release CARDS need nothing here — each one listens to
|
||||
// this signal itself (cards/AlbumCard.qml).
|
||||
function onPinChanged(key, value) {
|
||||
var aid = (artist && artist.id) ? artist.id : ""
|
||||
if (aid !== "" && key === "artist:" + aid)
|
||||
root.setToggleState("artistPin", value)
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
@@ -431,11 +456,23 @@ Rectangle {
|
||||
|
||||
|
||||
// Popular Tracks row (TrackRow with artwork + album column).
|
||||
//
|
||||
// COLUMN GEOMETRY: rows/TrackCols.qml, the same object rows/TrackRow.qml
|
||||
// and rows/TrackListHeader.qml read. This component is a FORK of the
|
||||
// shared row (POC-NOTE, pre-existing) and it had the full column set
|
||||
// re-typed as literals; they were numerically right, but a fork with its
|
||||
// own copy of the numbers is precisely how a table's columns drift. The
|
||||
// artist page draws no column header (neither does
|
||||
// artist/ArtistPageView.slint), so nothing is misaligned today — this
|
||||
// keeps it that way if the widths ever move.
|
||||
component PopularTrackRow: Rectangle {
|
||||
id: popRow
|
||||
property var row: ({})
|
||||
property int rowIndex: 0
|
||||
property bool showAlbum: true
|
||||
|
||||
TrackCols { id: cols }
|
||||
|
||||
readonly property bool isActive: QbzPlayer.npTrackId !== "" && QbzPlayer.npTrackId === row.id
|
||||
readonly property bool hovered: trArea.containsMouse || favArea.containsMouse || moreArea.containsMouse
|
||||
|
||||
@@ -456,14 +493,14 @@ Rectangle {
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 12
|
||||
spacing: 14
|
||||
anchors.leftMargin: cols.padH
|
||||
anchors.rightMargin: cols.padH
|
||||
spacing: cols.gap
|
||||
|
||||
// Position number (artwork rows carry it separate from the cover).
|
||||
Text {
|
||||
visible: showAlbum
|
||||
width: 32
|
||||
width: cols.colNumber
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: row.number
|
||||
color: theme.textMuted
|
||||
@@ -473,8 +510,8 @@ Rectangle {
|
||||
}
|
||||
// Cover with hover play overlay.
|
||||
Rectangle {
|
||||
width: showAlbum ? 36 : 32
|
||||
height: showAlbum ? 36 : 28
|
||||
width: showAlbum ? cols.colArt : cols.colNumber
|
||||
height: showAlbum ? cols.colArt : 28
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
radius: theme.radiusSm
|
||||
color: theme.surfaceElevated
|
||||
@@ -515,8 +552,20 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
// Title + artist.
|
||||
//
|
||||
// This was `- 6 * 14` for the gaps in BOTH arms. With the album
|
||||
// column on there are nine visible cells, i.e. EIGHT gaps, so the
|
||||
// stretch column ran 28px long and dragged Album / Duration /
|
||||
// Quality / heart 28px right of where the shared TrackRow puts
|
||||
// them — the same class of defect as the header, inside a forked
|
||||
// row. `cols.titleWidth` counts the gaps from the arms.
|
||||
//
|
||||
// Arms: with the album column the leading cells are the 32px
|
||||
// number AND the 36px cover (artwork arm); without it the single
|
||||
// 32px cover IS the number cell.
|
||||
Column {
|
||||
width: parent.width - (showAlbum ? 32 + 36 : 32) - albumCell.width - 70 - 92 - 28 - 28 - 32 - 6 * 14
|
||||
width: cols.titleWidth(popRow.width, popRow.showAlbum, popRow.showAlbum,
|
||||
true, true, true)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
Row {
|
||||
@@ -558,7 +607,7 @@ Rectangle {
|
||||
Text {
|
||||
id: albumCell
|
||||
visible: showAlbum
|
||||
width: showAlbum ? 220 : 0
|
||||
width: showAlbum ? cols.colAlbum : 0
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: row.album
|
||||
color: row.albumId !== "" && albumLinkArea.containsMouse ? theme.textPrimary : theme.textMuted
|
||||
@@ -574,7 +623,7 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
Text {
|
||||
width: 70
|
||||
width: cols.colDuration
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: row.duration
|
||||
color: theme.textMuted
|
||||
@@ -582,7 +631,7 @@ Rectangle {
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
Text {
|
||||
width: 92
|
||||
width: cols.colQuality
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: row.qualityTier === "hires" ? "HI-RES" : (row.qualityTier === "cd" ? "CD" : "")
|
||||
color: theme.textMuted
|
||||
@@ -594,8 +643,8 @@ Rectangle {
|
||||
// survives a document republish (see root.localToggles).
|
||||
Rectangle {
|
||||
property bool favorite: root.toggleState("track:" + row.id, row.isFavorite)
|
||||
width: 28
|
||||
height: 28
|
||||
width: cols.colFavorite
|
||||
height: cols.colFavorite
|
||||
radius: theme.radiusSm
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: favArea.containsMouse ? theme.surfaceElevated : "transparent"
|
||||
@@ -621,16 +670,16 @@ Rectangle {
|
||||
}
|
||||
// Offline download — INERT stub.
|
||||
Rectangle {
|
||||
width: 28
|
||||
height: 28
|
||||
width: cols.colDownload
|
||||
height: cols.colDownload
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: "transparent"
|
||||
QbzIcon { anchors.centerIn: parent; name: "cloud-download"; width: 16; height: 16; tintName: "muted" }
|
||||
}
|
||||
// ⋯ (play-only menu for the POC).
|
||||
Rectangle {
|
||||
width: 32
|
||||
height: 32
|
||||
width: cols.colMenu
|
||||
height: cols.colMenu
|
||||
radius: theme.radiusSm
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: moreArea.containsMouse ? theme.surfaceElevated : "transparent"
|
||||
@@ -923,7 +972,18 @@ Rectangle {
|
||||
year: modelData.year
|
||||
qualityTier: modelData.qualityTier
|
||||
artSource: root.coverMap[modelData.artUrl] || ""
|
||||
isFavorite: false
|
||||
// artist_qt `map_release` stamps the pin state on every
|
||||
// release row; SectionRail is the only other reader and
|
||||
// this page never mounts it, so the flag was published
|
||||
// and dropped on the floor — the glyph lied on all four
|
||||
// of this page's album grids. `artUrl` is the REMOTE url
|
||||
// (coverMap is keyed BY it), which is what the pin
|
||||
// payload must store.
|
||||
isPinned: modelData.isPinned === true
|
||||
artworkUrl: modelData.artUrl || ""
|
||||
// artist_qt::map_release stamps this the same way it
|
||||
// stamps the pin; false inverted the first click.
|
||||
isFavorite: modelData.isFavorite === true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1359,7 +1419,10 @@ Rectangle {
|
||||
year: artist.lastRelease ? artist.lastRelease.year : ""
|
||||
qualityTier: artist.lastRelease ? artist.lastRelease.qualityTier : ""
|
||||
artSource: artist.lastRelease ? (root.coverMap[artist.lastRelease.artUrl] || "") : ""
|
||||
isFavorite: false
|
||||
// Same row shape as the release grids (map_release).
|
||||
isPinned: artist.lastRelease ? artist.lastRelease.isPinned === true : false
|
||||
artworkUrl: artist.lastRelease ? (artist.lastRelease.artUrl || "") : ""
|
||||
isFavorite: artist.lastRelease ? artist.lastRelease.isFavorite === true : false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1540,7 +1603,9 @@ Rectangle {
|
||||
year: modelData.year
|
||||
qualityTier: modelData.qualityTier
|
||||
artSource: root.coverMap[modelData.artUrl] || ""
|
||||
isFavorite: false
|
||||
isPinned: modelData.isPinned === true
|
||||
artworkUrl: modelData.artUrl || ""
|
||||
isFavorite: modelData.isFavorite === true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1615,6 +1680,12 @@ Rectangle {
|
||||
year: modelData.year
|
||||
qualityTier: modelData.qualityTier
|
||||
artSource: root.coverMap[modelData.imageUrl] || ""
|
||||
// These rows are LIBRARY feed items (FeedItem),
|
||||
// not release cards: the remote url is
|
||||
// `imageUrl`, and the pin state rides the same
|
||||
// row (library_qt `map_album`).
|
||||
isPinned: modelData.isPinned === true
|
||||
artworkUrl: modelData.imageUrl || ""
|
||||
isFavorite: modelData.isFavorite
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,14 +174,24 @@ Rectangle {
|
||||
ribbonKind: crCell.modelData.ribbonKind || ""
|
||||
artSource: crCell.modelData.artPath || ""
|
||||
isPinned: crCell.modelData.isPinned === true
|
||||
isFavorite: false
|
||||
// The pin payload's display snapshot — the REMOTE
|
||||
// url (artPath is the local cache path). Without
|
||||
// it a label release pinned from here landed in
|
||||
// the Home Pinned rail as a grey placeholder.
|
||||
artworkUrl: crCell.modelData.artUrl || ""
|
||||
// Stamped on the row by label_qt; false made the
|
||||
// glyph lie and inverted the first click.
|
||||
isFavorite: crCell.modelData.isFavorite === true
|
||||
}
|
||||
}
|
||||
Component {
|
||||
id: crPlaylist
|
||||
PlaylistCard {
|
||||
// artworkUrl defaults to `item.artUrl` (the card's
|
||||
// own arm), so only the pin state is handed over.
|
||||
item: crCell.modelData
|
||||
artSource: crCell.modelData.artPath || ""
|
||||
isPinned: crCell.modelData.isPinned === true
|
||||
}
|
||||
}
|
||||
Component {
|
||||
@@ -189,9 +199,14 @@ Rectangle {
|
||||
ArtistCard {
|
||||
item: crCell.modelData
|
||||
artSource: crCell.modelData.artPath || ""
|
||||
// The .slint passes card-follow-mode "none" here;
|
||||
// ArtistCard's hasFollowSeam already gates the chip
|
||||
// off, so this is belt and braces.
|
||||
isPinned: crCell.modelData.isPinned === true
|
||||
artworkUrl: crCell.modelData.artUrl || ""
|
||||
// LabelPageView.slint:524 passes card-follow-mode
|
||||
// "none" here — and now that ArtistCard's follow
|
||||
// arm is LIVE (its `hasFollowSeam: false` was a
|
||||
// factual error: library_toggle_favorite routes
|
||||
// "artist"), this line is what actually keeps the
|
||||
// chip off this carousel instead of a constant.
|
||||
followMode: "none"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user