fix(qt): an album leaving the Library slides the grid instead of rebuilding it

Removing an album (or a track, artist or playlist) from the Library made
Rust republish the feed, the derive produced a new array, and every body
bound to it took a model swap: the grid jumped to the top, every card was
rebuilt and every cover faded in again. All four bodies (grid, list,
albums list, playlists list) now share one QbzKeyedModel over the same
derive: the leaving card shrinks and fades while its neighbours slide
into the gap, the page stays where it was, and surviving cards keep their
delegates and decoded covers. The Artists sidepanel rail gets its own
keyed model with the same page identity.

A different page is still a different page: tab, sort, search, source
filters, genres and grouping form the model's scope and replace the rows
at once, from the top, as before. A heart removed under "Favorites only"
is not a scope change, so that card animates out.

Supporting changes: the artwork window reports read the model's published
rows and count from originY (a removal above the window moves the
origin), scroll memory is origin-relative, the playlists and albums lists
are height-gated like the grid so hidden bodies build nothing, pooled
cells come back opaque, and a background reload with a page already on
screen (a bulk removal, a language switch) keeps that page instead of
blinking through the loading skeleton.

tst_library_keyed mounts the real grid cell over the keyed model and
checks that an album leaving through a republished feed keeps the other
cards, their covers and the page offset; bound to the plain array it
fails.

cc
This commit is contained in:
vicrodh committed 2026-09-14 05:50:35 -06:00
1 parent b3aa992954
commit ce625e7e28
6 files changed
+375 -64

No files matched your search

+135 -40
View File
@@ -323,7 +323,10 @@ Rectangle {
root.artistsGroup = p.artistsGroup
root.artistsView = p.artistsView
}
Component.onCompleted: root.applyPrefs()
Component.onCompleted: {
root.applyPrefs()
feedRows.views = [grid, list, playlistsList, albumsList.listView]
}
Connections {
target: QbzLibrary
function onLibraryPrefsJsonChanged() { root.applyPrefs() }
@@ -562,6 +565,59 @@ Rectangle {
onVisibleRowsChanged: Qt.callLater(root.reportSurfaceState)
onVisibleChanged: Qt.callLater(root.reportSurfaceState)
// THE MODEL every body binds to (2026-09-14). `visibleRows` stays the
// logical truth for everything that reasons about rows (selection, play
// context, the A-Z strip); the views see it through QbzKeyedModel, which
// turns a new derive into removes / inserts / moves instead of a model
// swap. That is the whole fix for "an album leaving the Library rebuilds
// the grid at the top": the heart settles, Rust republishes the feed
// (`publish_library_document`), the derive runs, and only the leaving
// card fades out while its neighbours slide into the gap — scroll offset,
// delegates and covers untouched.
//
// A new tab, sort, search or filter set is a different page, not a change
// to this one: `scope` replaces the rows at once and starts at the top,
// exactly as the swap did. `favoriteFilterRev` is deliberately NOT in the
// scope — a heart removed under "Favorites only" is precisely the row
// that should animate out.
//
// Index-coupled readers (the artwork window reports) read
// `feedRows.published`, the array the model reflects index for index; the
// reconcile is deferred one turn, and `onReconciled` re-reports because a
// removal above the window shifts every index.
/// The page identity `feedRows` resets on — for bodies that derive their
/// own keyed rows from the feed (the Artists sidepanel rail).
readonly property string rowsScope: feedRows.scope
QbzKeyedModel {
id: feedRows
rows: root.visibleRows
// The All feed shows a purchased favourite twice (a favourites row and
// a purchases row), so there the group is part of the identity; the
// tabs keep one representative per entity, whose group may change.
keyOf: function (row) {
if (row.kind === "group-header")
return row.id
return (row._membershipKey || (row.kind + ":" + row.id))
+ (root.activeTab === "all" ? "|" + (row.group || "") : "")
}
scope: JSON.stringify([root.activeTab, root.activeSort, root.search,
root.tabSearch, root.showPurchases, root.showFavorites,
root.showFollowing, root.showLocal, root.hiresOnly, root.genreNames,
root.tracksGroup, root.albumsGroup, root.artistsGroup,
root.artistsView, root.playlistsSubTab])
// Positional cache from parseFeed: one row leaving renumbers it for
// every row after it, which is not a change to those rows.
ignoredKeys: ["_feedOrder"]
// `views` is handed over in Component.onCompleted below: all four
// bodies are declared after this model.
onReconciled: {
root.gridWindowReport()
root.listWindowReport()
albumsList.report()
playlistsList.report()
}
}
/// A-Z bucket for a title: its first letter uppercased, "#" for anything
/// that is not a letter (a leading digit, "*", punctuation). Matches the
/// jump strip's own buckets, so a letter in the strip always has a
@@ -1093,13 +1149,14 @@ Rectangle {
id: windowDebounce
interval: 180
onTriggered: root.reportWindow(
pendingRows ? pendingRows : root.visibleRows, pendingFirst, pendingLast, pendingVisibleFirst)
pendingRows ? pendingRows : feedRows.published, pendingFirst, pendingLast, pendingVisibleFirst)
property int pendingFirst: 0
property int pendingLast: 0
property int pendingVisibleFirst: 0
property var pendingRows: null
}
/// `rows` is optional — omit it and the band is read off `visibleRows`.
/// `rows` is optional — omit it and the band is read off the array the
/// views show (`feedRows.published`).
function queueWindowReport(first, last, rows, visibleFirst) {
windowDebounce.pendingFirst = first
windowDebounce.pendingLast = last
@@ -1250,6 +1307,11 @@ Rectangle {
readonly property int barInset:
(root.activeTab === "tracks" && root.tracksMultiSelect) ? 52 : 0
// A reload with a page already on screen (a bulk removal, a language
// switch) keeps that page until the new document lands, so the rows
// reconcile instead of the page blinking through the skeleton.
readonly property bool hasPage: root.feed.length > 0
// Loading skeleton — a deliberate ADDITION: the Slint mounts a
// bare centred 36px LoadingSpinner here (FavoritesView.slint:
// 956/1012), which says "busy" but not "this is the shape of
@@ -1269,6 +1331,7 @@ Rectangle {
anchors.rightMargin: 32
anchors.topMargin: 16
visible: QbzLibrary.libraryLoading && QbzLibrary.libraryError === ""
&& !content.hasPage
// Which shape the tab is about to render.
readonly property bool listShape:
(root.activeTab === "all" && root.viewMode === "list")
@@ -1360,7 +1423,8 @@ Rectangle {
}
readonly property bool ready:
!QbzLibrary.libraryLoading && QbzLibrary.libraryError === ""
(!QbzLibrary.libraryLoading || content.hasPage)
&& QbzLibrary.libraryError === ""
onReadyChanged: Qt.callLater(root.reportSurfaceState)
// Which surface owns the tab right now — one predicate per body,
// so no two can mount at once.
@@ -1391,12 +1455,10 @@ Rectangle {
// being spent on the body that was not showing.
//
// Collapsing the height to 0 is what actually stops the refill.
// The obvious alternative — `model: visible ? root.visibleRows :
// []` — is the one thing this file spends a paragraph warning
// against (see onLibraryFavoriteChanged): handing `model` a new
// value goes through QQuickItemView::setModel(), which resets the
// scroll offset to 0 and rebuilds every delegate. The height gate
// leaves `model` untouched.
// The obvious alternative — `model: visible ? feedRows : null` —
// is a model swap: QQuickItemView::setModel() resets the scroll
// offset to 0 and rebuilds every delegate. The height gate leaves
// `model` untouched.
GridView {
id: grid
anchors.left: parent.left
@@ -1416,7 +1478,12 @@ Rectangle {
reuseItems: true
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.visibleRows
model: feedRows
// A card leaving shrinks and fades while the rest slide over.
add: QbzRowAdd { enabled: feedRows.animate; grow: 0.94 }
remove: QbzRowRemove { enabled: feedRows.animate; shrink: 0.92 }
move: QbzRowDisplaced { enabled: feedRows.animate }
displaced: QbzRowDisplaced { enabled: feedRows.animate }
onContentYChanged: root.gridWindowReport(true)
onModelChanged: root.gridWindowReport()
@@ -1447,8 +1514,10 @@ Rectangle {
Component.onCompleted: root.gridWindowReport()
delegate: FeedGridCell {
required property var modelData
required property string rowKey
required property int rowRev
required property int index
readonly property var modelData: feedRows.row(rowKey, rowRev)
view: root
item: modelData
// Viewport-relative so the skeleton's 48-instance cap
@@ -1474,7 +1543,11 @@ Rectangle {
reuseItems: true
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.visibleRows
model: feedRows
add: QbzRowAdd { enabled: feedRows.animate }
remove: QbzRowRemove { enabled: feedRows.animate }
move: QbzRowDisplaced { enabled: feedRows.animate }
displaced: QbzRowDisplaced { enabled: feedRows.animate }
onContentYChanged: root.listWindowReport()
onModelChanged: root.listWindowReport()
onVisibleChanged: root.listWindowReport()
@@ -1484,8 +1557,10 @@ Rectangle {
Component.onCompleted: root.listWindowReport()
delegate: Loader {
required property var modelData
required property string rowKey
required property int rowRev
required property int index
readonly property var modelData: feedRows.row(rowKey, rowRev)
width: list.width
height: modelData && modelData.kind === "group-header"
? 34
@@ -1583,7 +1658,12 @@ Rectangle {
}
onLoaded: activateLoadedRow()
ListView.onPooled: releaseLoadedRow()
ListView.onReused: activateLoadedRow()
ListView.onReused: {
// A transition cut short can pool the row half faded.
opacity = 1
scale = 1
activateLoadedRow()
}
}
}
@@ -1596,7 +1676,7 @@ Rectangle {
anchors.rightMargin: root.alphaVisible ? (root.dateGrouping ? 98 : 52) : 32
anchors.topMargin: 16
view: root
rows: content.showAlbumsList ? root.visibleRows : []
rowsModel: feedRows
}
// ============ Playlists LIST mode ============================
@@ -1605,7 +1685,11 @@ Rectangle {
ListView {
id: playlistsList
visible: content.showPlaylistsList
anchors.fill: parent
// Height-gated like the grid: hidden, it builds nothing.
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
height: playlistsList.visible ? parent.height - 16 : 0
anchors.leftMargin: 32
anchors.rightMargin: 32
anchors.topMargin: 16
@@ -1614,21 +1698,26 @@ Rectangle {
cacheBuffer: 60 * 8
reuseItems: true
boundsBehavior: Flickable.StopAtBounds
model: content.showPlaylistsList ? root.visibleRows : []
model: feedRows
add: QbzRowAdd { enabled: feedRows.animate }
remove: QbzRowRemove { enabled: feedRows.animate }
move: QbzRowDisplaced { enabled: feedRows.animate }
displaced: QbzRowDisplaced { enabled: feedRows.animate }
// Gated on visibility for the same reason gridWindowReport and
// listWindowReport are: `artMap` is ONE map, a report PRUNES
// outside its band, and this body still fires while hidden —
// `model` flips to [] on every tab switch (onModelChanged) and
// `anchors.fill` makes every window resize an onHeightChanged.
// outside its band, and this body still fires while hidden.
// Ungated, a resize on the Artists sidepanel evicted the rail's
// avatars and requested a `visibleRows` band the rail is not
// even showing (its order is A-Z, not feed order).
// avatars and requested a feed band the rail is not even
// showing (its order is A-Z, not feed order). Offsets count
// from `originY`: a removal above the window moves the origin,
// not the rows on screen.
function report() {
if (!playlistsList.visible) return
var first = Math.max(0, Math.floor(playlistsList.contentY / 62) - 4)
var last = Math.ceil((playlistsList.contentY + playlistsList.height) / 62) + 4
root.queueWindowReport(first, Math.min(root.visibleRows.length - 1, last))
var scrollY = playlistsList.contentY - playlistsList.originY
var first = Math.max(0, Math.floor(scrollY / 62) - 4)
var last = Math.ceil((scrollY + playlistsList.height) / 62) + 4
root.queueWindowReport(first, Math.min(feedRows.published.length - 1, last))
}
onContentYChanged: playlistsList.report()
onModelChanged: playlistsList.report()
@@ -1637,8 +1726,11 @@ Rectangle {
Component.onCompleted: playlistsList.report()
delegate: PlaylistListRow {
required property var modelData
required property string rowKey
required property int rowRev
required property int index
readonly property var modelData: feedRows.row(rowKey, rowRev)
ListView.onReused: { opacity = 1; scale = 1 }
width: playlistsList.width
item: modelData
rowIndex: index
@@ -1710,7 +1802,7 @@ Rectangle {
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
// this container's offset while it is the live page, and restores it
// when a back/forward step arms this route.
ScrollMemory { target: grid; scope: "library:" + root.activeTab }
ScrollMemory { target: grid; scope: "library:" + root.activeTab; relativeToOrigin: true }
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: root.alphaVisible ? (root.dateGrouping ? 80 : 34) : 4
@@ -1722,7 +1814,7 @@ Rectangle {
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
// this container's offset while it is the live page, and restores it
// when a back/forward step arms this route.
ScrollMemory { target: list; scope: "library:" + root.activeTab }
ScrollMemory { target: list; scope: "library:" + root.activeTab; relativeToOrigin: true }
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: root.alphaVisible ? (root.dateGrouping ? 80 : 34) : 4
@@ -1734,7 +1826,7 @@ Rectangle {
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
// this container's offset while it is the live page, and restores it
// when a back/forward step arms this route.
ScrollMemory { target: playlistsList; scope: "library:" + root.activeTab }
ScrollMemory { target: playlistsList; scope: "library:" + root.activeTab; relativeToOrigin: true }
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: 4
@@ -1747,10 +1839,12 @@ Rectangle {
}
}
// Both run from onContentYChanged, i.e. from INSIDE QQuickItemView::
// setModel() — see the visibleRows comment. Everything they touch on the
// view (width/height/contentY/cellWidth/cellHeight) is a plain qreal on
// the private; the array comes from root, never from the view.
// Both run from onContentYChanged. Everything they touch on the view
// (width/height/contentY/originY/cellWidth/cellHeight) is a plain qreal on
// the private; the array comes from the keyed model's `published` — the
// rows index for index as the view holds them — never from `.model` (see
// the visibleRows comment). Offsets count from `originY`: rows leaving
// above the window move the content origin, not the rows on screen.
//
// Both early-return while their body is hidden. `artMap` is ONE map and a
// report PRUNES everything outside the reported band, so a hidden body
@@ -1766,11 +1860,12 @@ Rectangle {
function gridWindowReport(motionOnly) {
if (!grid.visible) return
var cols = Math.max(1, Math.floor(grid.width / grid.cellWidth))
var visibleFirst = Math.max(0, Math.floor(grid.contentY / grid.cellHeight))
var scrollY = grid.contentY - grid.originY
var visibleFirst = Math.max(0, Math.floor(scrollY / grid.cellHeight))
// Match the two rows of delegates already held by cacheBuffer.
var firstRow = Math.max(0, visibleFirst - 2)
var lastRow = Math.ceil((grid.contentY + grid.height) / grid.cellHeight) + 2
var m = root.visibleRows
var lastRow = Math.ceil((scrollY + grid.height) / grid.cellHeight) + 2
var m = feedRows.published
var band = firstRow + ":" + lastRow + ":" + visibleFirst + ":" + cols
var now = Date.now()
// Do not restart the bridge timer for every pixel in the same band.
@@ -1796,12 +1891,12 @@ Rectangle {
var first = list.indexAt(4, list.contentY + 1)
var last = list.indexAt(4, list.contentY + Math.max(1, list.height) - 1)
if (first < 0)
first = Math.max(0, Math.floor(list.contentY / 50))
first = Math.max(0, Math.floor((list.contentY - list.originY) / 50))
if (last < 0)
last = Math.ceil((list.contentY + list.height) / 50)
last = Math.ceil((list.contentY - list.originY + list.height) / 50)
first = Math.max(0, first - 4)
last += 4
queueWindowReport(first, Math.min(root.visibleRows.length - 1, last))
queueWindowReport(first, Math.min(feedRows.published.length - 1, last))
}
// ============================ overlay =================================
@@ -63,7 +63,13 @@ Item {
cell.scheduleMutableRestore()
}
GridView.onPooled: cell.releaseLoadedCard()
GridView.onReused: cell.scheduleMutableRestore()
GridView.onReused: {
// A row transition cut short (QbzKeyedModel in LibraryView) can pool
// the cell half faded or shrunk; the pool hands it back as it was.
cell.opacity = 1
cell.scale = 1
cell.scheduleMutableRestore()
}
width: 200
height: 246
@@ -26,9 +26,11 @@ Item {
/// The LibraryView root (artMap, the selection map, the window reporter).
property var view: null
/// The derived album rows for this tab — `view.visibleRows`, passed in so
/// this file never re-derives and both surfaces see the same array.
property var rows: []
/// The Library's keyed model (views/LibraryView.qml `feedRows`), shared by
/// every body so a row leaving animates instead of rebuilding the list.
property var rowsModel: null
/// For the model's synchronous reset layout.
readonly property alias listView: list
QbzTheme { id: theme }
@@ -39,6 +41,8 @@ Item {
function positionAt(index) {
list.positionViewAtIndex(index, ListView.Beginning)
}
/// Re-report the artwork window (the host calls it after a reconcile).
function report() { list.report() }
Column {
anchors.fill: parent
@@ -83,7 +87,10 @@ Item {
anchors.topMargin: 4
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
// Height-gated like the host's grid: a hidden ListView with a
// model keeps refilling its viewport, and this one shares the
// Library's model with every other body.
height: root.visible ? parent.height - header.height - 4 : 0
// No strip inset here: the A-Z strip lives in LibraryView and
// the HOST already widened this component's right margin for
// it. Insetting again would double-count it.
@@ -92,7 +99,11 @@ Item {
cacheBuffer: 64 * 8
reuseItems: true
boundsBehavior: Flickable.StopAtBounds
model: root.rows
model: root.rowsModel
add: QbzRowAdd { enabled: root.rowsModel !== null && root.rowsModel.animate }
remove: QbzRowRemove { enabled: root.rowsModel !== null && root.rowsModel.animate }
move: QbzRowDisplaced { enabled: root.rowsModel !== null && root.rowsModel.animate }
displaced: QbzRowDisplaced { enabled: root.rowsModel !== null && root.rowsModel.animate }
// Windowed artwork, same contract as the grid: never read
// `list.model` back off the view (views/LibraryView.qml
@@ -101,18 +112,19 @@ Item {
// Gated on visibility like the host's own bodies
// (LibraryView.gridWindowReport / listWindowReport): `artMap`
// is ONE map and a report PRUNES outside its band, while this
// body keeps firing hidden — `rows` flips to [] on every tab
// switch (onModelChanged) and `anchors.fill` turns any window
// resize into onHeightChanged. Ungated it evicted whichever
// body keeps firing hidden (`anchors.fill` turns any window
// resize into onHeightChanged). Ungated it evicted whichever
// body is actually on screen, the Artists sidepanel rail above
// all, whose band is NOT a `visibleRows` band.
// all, whose band is NOT a feed band. Offsets count from
// `originY`, which rows leaving above the window move.
function report() {
if (!list.visible) return
if (!list.visible || root.rowsModel === null) return
var stride = root.rowH + root.rowGap
var first = Math.max(0, Math.floor(list.contentY / stride) - 4)
var last = Math.ceil((list.contentY + list.height) / stride) + 4
var scrollY = list.contentY - list.originY
var first = Math.max(0, Math.floor(scrollY / stride) - 4)
var last = Math.ceil((scrollY + list.height) / stride) + 4
root.view.queueWindowReport(first,
Math.min(root.rows.length - 1, last))
Math.min(root.rowsModel.published.length - 1, last))
}
onContentYChanged: list.report()
onModelChanged: list.report()
@@ -121,8 +133,11 @@ Item {
Component.onCompleted: list.report()
delegate: AlbumListRow {
required property var modelData
required property string rowKey
required property int rowRev
required property int index
readonly property var modelData: root.rowsModel.row(rowKey, rowRev)
ListView.onReused: { opacity = 1; scale = 1 }
width: list.width
item: modelData
rowIndex: index
@@ -95,7 +95,25 @@ Item {
root._rebuilding = false
root.report()
}
Component.onCompleted: { root.rebuild(); root.reportSoon() }
// The rail's own keyed model (2026-09-14): an artist unfollowed from the
// pane fades out of the rail while the rows below slide up, instead of
// the rail being rebuilt from the top. Same page identity as the host's
// bodies, so a search or a sort still replaces it at once.
QbzKeyedModel {
id: railModel
rows: root.entries
keyOf: function (entry) {
return entry.t === 0 ? "letter:" + entry.label : "artist:" + entry.item.id
}
scope: root.view ? root.view.rowsScope : ""
onReconciled: root.report()
}
Component.onCompleted: {
railModel.views = [railList]
root.rebuild()
root.reportSoon()
}
Connections {
target: root.view
function onVisibleRowsChanged() { root.rebuild() }
@@ -212,7 +230,11 @@ Item {
cacheBuffer: 56 * 8
reuseItems: true
boundsBehavior: Flickable.StopAtBounds
model: root.entries
model: railModel
add: QbzRowAdd { enabled: railModel.animate }
remove: QbzRowRemove { enabled: railModel.animate }
move: QbzRowDisplaced { enabled: railModel.animate }
displaced: QbzRowDisplaced { enabled: railModel.animate }
// The trigger set views/local/LocalArtistsTab.qml:161-165 already
// uses for the same shape of rail: scroll, model swap, viewport
@@ -225,7 +247,10 @@ Item {
onVisibleChanged: if (visible) root.reportSoon()
delegate: Loader {
required property var modelData
required property string rowKey
required property int rowRev
readonly property var modelData: railModel.row(rowKey, rowRev)
ListView.onReused: { opacity = 1; scale = 1 }
width: railList.width
height: modelData.t === 0 ? root.headerH : root.rowH
sourceComponent: modelData.t === 0 ? letterComp : artistComp