From fcb9851736c8c8b9a4be4efee8e72ca1a4a0a89e Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:44:13 -0600 Subject: [PATCH] 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 --- .../qbz-qt/qml/views/local/LocalTracksTab.qml | 72 +++++++++++++++++-- crates/qbz-qt/src/local_state.rs | 17 +++-- 2 files changed, 77 insertions(+), 12 deletions(-) diff --git a/crates/qbz-qt/qml/views/local/LocalTracksTab.qml b/crates/qbz-qt/qml/views/local/LocalTracksTab.qml index f35434c43..7989aa13b 100644 --- a/crates/qbz-qt/qml/views/local/LocalTracksTab.qml +++ b/crates/qbz-qt/qml/views/local/LocalTracksTab.qml @@ -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() diff --git a/crates/qbz-qt/src/local_state.rs b/crates/qbz-qt/src/local_state.rs index 484f3b2e6..98e55265d 100644 --- a/crates/qbz-qt/src/local_state.rs +++ b/crates/qbz-qt/src/local_state.rs @@ -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)")); } }