fix(library): append Tracks pages without model reset

Keep one compatibility ListModel mounted and publish stable page prefixes through rowsInserted notifications. Preserve semantic anchor restoration only for grouped reorders so ordinary lazy pagination cannot move the viewport.

cx
This commit is contained in:
vicrodh committed 2026-08-23 18:44:13 -06:00
1 parent 1244687ab9
commit fcb9851736
2 files changed
+77 -12

No files matched your search

@@ -9,6 +9,7 @@
// heights instead of a Repeater per group.
import QtQuick
import QtQml.Models
import com.blitzfc.qbz
import "../../controls"
import "../../theme"
@@ -45,6 +46,62 @@ Item {
// replacement wins before Qt's next event-loop turn.
property int entriesEpoch: 0
// Keep one QAbstractItemModel object mounted for the entire compatibility
// session. Binding a fresh JS array to ListView.model for every accumulated
// 500-row document invokes setModel() and resets the view before any anchor
// repair can run. A ListModel append is a real rowsInserted notification:
// Qt keeps contentY and the visible delegate untouched.
ListModel {
id: legacyEntriesModel
}
function sameEntryIdentity(a, b) {
if (!a || !b || a.t !== b.t)
return false
if (a.t === 0)
return String(a.label) === String(b.label)
return a.row && b.row && String(a.row.id) === String(b.row.id)
}
function canAppendLegacyEntries(nextEntries) {
if (legacyEntriesModel.count !== root.entries.length
|| nextEntries.length < root.entries.length)
return false
for (var i = 0; i < root.entries.length; i++) {
if (!root.sameEntryIdentity(root.entries[i], nextEntries[i]))
return false
}
return true
}
function publishLegacyEntries(nextEntries, anchor, epoch) {
var previousCount = root.entries.length
if (root.canAppendLegacyEntries(nextEntries)) {
// The normal ungrouped paging path has an immutable prefix. Append
// only the new tail, preserving the viewport by construction.
// Hold the request guard through the rowsInserted polish so a
// kinetic-scroll signal cannot request a second page recursively.
if (root.pageRequestPending)
root.restoringPageAnchor = true
for (var i = previousCount; i < nextEntries.length; i++)
legacyEntriesModel.append({"modelData": nextEntries[i]})
root.entries = nextEntries
if (root.pageRequestPending)
pageAnchorSettle.restart()
return
}
// Search/sort and grouped compatibility modes can reorder the prefix.
// Rebuild rows inside the SAME ListModel object and then restore the
// semantic anchor. This avoids QQuickItemView::setModel() even when a
// full reconciliation is necessary.
legacyEntriesModel.clear()
for (var j = 0; j < nextEntries.length; j++)
legacyEntriesModel.append({"modelData": nextEntries[j]})
root.entries = nextEntries
root.restorePageAnchor(anchor, nextEntries, epoch)
}
function requestNextPage(force) {
if (root.nativeModelActive
|| root.pageRequestPending
@@ -122,10 +179,10 @@ Item {
if (target < 0)
return
// Assigning a fresh JS array invokes QQuickItemView::setModel(); the
// reset and its content-height polish finish after `entries = out`.
// Restore on the next turn, then use the delegate's viewport position
// to retain the exact partial-row offset.
// A grouped update can clear and refill the stable ListModel because
// newly fetched rows belong before the current viewport. Let those
// remove/insert notifications settle, then retain the exact semantic
// track and partial-row offset on the next event-loop turn.
Qt.callLater(function () {
if (epoch !== root.entriesEpoch || root.nativeModelActive)
return
@@ -162,6 +219,7 @@ Item {
root.entriesEpoch += 1
var epoch = root.entriesEpoch
if (root.nativeModelActive) {
legacyEntriesModel.clear()
entries = []
rowIndex = []
try {
@@ -193,11 +251,10 @@ Item {
out.push({ "t": 1, "row": t, "n": i + 1 })
idx.push(i)
}
entries = out
alphaJumps = jumps
rowIndex = idx
root.publishLegacyEntries(out, anchor, epoch)
report()
root.restorePageAnchor(anchor, out, epoch)
}
Component.onCompleted: { rebuild(); reportSoon() }
Component.onDestruction: if (view) view.releaseWindow("tracks")
@@ -357,7 +414,8 @@ Item {
clip: true
cacheBuffer: 50 * 12
boundsBehavior: Flickable.StopAtBounds
model: root.nativeModelActive ? root.view.nativeTracksModel : root.entries
model: root.nativeModelActive
? root.view.nativeTracksModel : legacyEntriesModel
onContentYChanged: {
root.report()
+12 -5
View File
@@ -521,11 +521,17 @@ mod phase_a_tests {
#[test]
fn tracks_page_republish_keeps_the_visible_track_anchor() {
// A legacy page append republishes the accumulated rows as a fresh JS
// array, which makes QQuickItemView reset to the top. The restoration
// must follow track identity (grouping can move its index), retain a
// partially clipped row's pixel offset and run after the relayout.
// A legacy page append republishes the accumulated rows as JSON. Keep
// one ListModel mounted and append an immutable prefix in-place so
// QQuickItemView never receives setModel(). Grouped reorder fallback
// must still follow track identity and retain the clipped-row offset.
let qml = include_str!("../qml/views/local/LocalTracksTab.qml");
assert!(qml.contains("id: legacyEntriesModel"));
assert!(!qml.contains("dynamicRoles: true"));
assert!(qml.contains("function canAppendLegacyEntries(nextEntries)"));
assert!(qml.contains("legacyEntriesModel.append({\"modelData\": nextEntries[i]})"));
assert!(qml.contains("? root.view.nativeTracksModel : legacyEntriesModel"));
assert!(!qml.contains("? root.view.nativeTracksModel : root.entries"));
assert!(qml.contains("function capturePageAnchor()"));
assert!(qml.contains("String(root.entries[i].row.id)"));
assert!(qml.contains("cell.y - list.contentY"));
@@ -540,6 +546,7 @@ mod phase_a_tests {
assert!(qml.contains("function restorePageAnchor(anchor, nextEntries, epoch)"));
assert!(qml.contains("Qt.callLater(function ()"));
assert!(qml.contains("list.positionViewAtIndex(target, ListView.Beginning)"));
assert!(qml.contains("root.restorePageAnchor(anchor, out, epoch)"));
assert!(qml.contains("root.publishLegacyEntries(out, anchor, epoch)"));
assert!(qml.contains("root.restorePageAnchor(anchor, nextEntries, epoch)"));
}
}