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:
vicrodh committed 2026-07-29 16:25:57 -06:00
1 parent 6b5226acf9
commit a3b89107fc
9 files changed
+877 -167

No files matched your search

+34 -32
View File
@@ -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).
+90 -19
View File
@@ -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
}
}
+19 -4
View File
@@ -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"
}
}