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)")); } }