diff --git a/.github/workflows/test-crates.yml b/.github/workflows/test-crates.yml index a14dd9e8c..35721081d 100644 --- a/.github/workflows/test-crates.yml +++ b/.github/workflows/test-crates.yml @@ -87,6 +87,7 @@ on: - "scripts/test-compact-seek-qml.sh" - "scripts/test-library-folders-qml.sh" - "scripts/test-array-model-qml.sh" + - "scripts/test-keyed-model-qml.sh" - "scripts/test-card-menu-qml.sh" - "scripts/test-orbit-daemon.py" - ".github/workflows/test-crates.yml" @@ -117,6 +118,7 @@ on: - "scripts/test-compact-seek-qml.sh" - "scripts/test-library-folders-qml.sh" - "scripts/test-array-model-qml.sh" + - "scripts/test-keyed-model-qml.sh" - "scripts/test-card-menu-qml.sh" - "scripts/test-orbit-daemon.py" - ".github/workflows/test-crates.yml" diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index 31e86e5aa..454c413de 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -575,6 +575,10 @@ fn main() { "qml/controls/PlaylistPickerModal.qml", "qml/controls/PmFolderIcon.qml", "qml/controls/QbzArrayModel.qml", + "qml/controls/QbzKeyedModel.qml", + "qml/controls/QbzRowAdd.qml", + "qml/controls/QbzRowDisplaced.qml", + "qml/controls/QbzRowRemove.qml", "qml/controls/QbzCircleAction.qml", "qml/controls/QbzClipboard.qml", "qml/controls/QbzColorPicker.qml", diff --git a/crates/qbz-qt/qml/controls/QbzKeyedModel.qml b/crates/qbz-qt/qml/controls/QbzKeyedModel.qml new file mode 100644 index 000000000..e1ac683a4 --- /dev/null +++ b/crates/qbz-qt/qml/controls/QbzKeyedModel.qml @@ -0,0 +1,405 @@ +// QbzKeyedModel — a list model that CHANGES instead of being replaced +// (2026-09-14). +// +// Handing an item view a new JS array is a model swap: QQuickItemView:: +// setModel() throws every delegate away, resets the scroll offset and builds +// the page again, so one album leaving the Library grid used to rebuild the +// whole grid at the top with every cover fading back in. This model takes the +// same arrays (`rows`) but tells the view only what changed: rows whose key +// disappeared are REMOVED, new keys are INSERTED where they belong, keys that +// changed places are MOVED, and a row whose data changed is UPDATED in place. +// The view keeps its delegates, its scroll offset and its covers, and with the +// row transitions next door (QbzRowAdd / QbzRowRemove / QbzRowDisplaced) the +// leaving row fades out while its neighbours slide into the gap. +// +// WHAT THE VIEW SEES. Each model row carries two roles, `rowKey` and `rowRev`, +// never the row itself. A delegate looks its row up by key: +// +// required property string rowKey +// required property int rowRev +// readonly property var modelData: keyedModel.row(rowKey, rowRev) +// ListView.onReused: { opacity = 1; scale = 1 } // GridView.onReused too +// +// The delegate reads the SAME JS object the host derived (no copy into a +// ListModel map, no conversion per read, and an in-place mutation of a row is +// visible to delegates built later exactly as it was with a plain array). +// `rowRev` is bumped when a row's data changed, which re-runs that binding and +// hands the delegate its new object; an unchanged row keeps its delegate +// untouched. A removed row stays readable (a small graveyard) because its +// delegate may still be fading out: Qt does not re-notify a removed item's +// roles (QQmlDMAbstractItemModelData reads its cache once the index is -1), so +// its binding keeps the value it had. The `onReused` restore matters because +// a transition cut short (a fast flick during an insert) can pool a delegate +// half faded, and the pool hands it back exactly as it was left. +// +// SCOPE. A change of `scope` (another tab, sort, search, filter set) is not a +// reconcile — the rows are replaced wholesale, WITHOUT animation (`animate` +// drops for the reset and the registered `views` lay out synchronously, so no +// transition ever sees it), which is what a tab switch has always looked like. +// Inside one scope everything reconciles and animates. +// +// COST. Keys are compared as strings in one pass; unchanged row objects are +// skipped by identity; a new object for an existing key is compared field by +// field (`ignoredKeys` excluded) and only a real difference bumps its rev. A +// reorder is applied with the fewest moves (the longest increasing run stays +// put); past `maxMoves` a reset is both cheaper and calmer than a cascade of +// slides. The reconcile is deferred with Qt.callLater, so a derive that +// changes `rows` and `scope` together lands as one operation. + +import QtQuick + +ListModel { + id: root + + /// The rows to show, in order. Assign a new array whenever the source + /// changes; the array itself does not have to be stable. + property var rows: [] + /// A row's identity. Equal keys are the same entity; repeats are told + /// apart by occurrence (the second "x" is "x#1"). + property var keyOf: function (row) { return row.id } + /// Another scope replaces the rows without animation. + property string scope: "" + /// Row fields whose change is not a change (positional caches). + property var ignoredKeys: [] + /// While true, `rows` changes wait and apply when it clears. + property bool paused: false + /// Item views bound to this model: laid out synchronously around a reset + /// so no add/remove transition runs for it. + property var views: [] + /// Beyond this many moves in one reconcile, reset instead. + property int maxMoves: 48 + + /// Bind each row transition's `enabled` to this. + readonly property bool animate: root._animate + /// The array the model currently reflects, index for index — what an + /// index-based reader (an artwork window report) must use. + readonly property var published: root._published + + /// After every applied change; `reset` is true for a wholesale replace. + signal reconciled(bool reset) + + property bool _animate: true + property var _published: [] + // Registries live on one never-reassigned object so that reading them + // inside a delegate binding does not subscribe the binding to anything. + property var _state: ({ + "initialized": false, + "scope": "", + "keys": [], + "byKey": new Map(), + "revs": new Map(), + "graveyard": new Map(), + "rev": 0 + }) + + onRowsChanged: Qt.callLater(root.sync) + onScopeChanged: Qt.callLater(root.sync) + onPausedChanged: if (!root.paused) Qt.callLater(root.sync) + Component.onCompleted: Qt.callLater(root.sync) + + /// The row behind `key` — also for a row that just left, while its + /// delegate fades out. `rev` is only a binding dependency. + function row(key, rev) { + var st = root._state + var r = st.byKey.get(key) + if (r === undefined) + r = st.graveyard.get(key) + return r === undefined ? ({}) : r + } + + /// Model index of `key`, or -1. + function indexOfKey(key) { + return root._state.keys.indexOf(key) + } + + /// Re-hand `key`'s row to its delegate after the host mutated that row + /// in place and a mounted delegate must re-read it. + function touch(key) { + var st = root._state + var i = st.keys.indexOf(key) + if (i < 0) + return + var rev = ++st.rev + st.revs.set(key, rev) + root.setProperty(i, "rowRev", rev) + } + + function keysFor(rows) { + var seen = new Map() + var out = new Array(rows.length) + for (var i = 0; i < rows.length; i++) { + var k = String(root.keyOf(rows[i])) + var n = seen.get(k) || 0 + seen.set(k, n + 1) + out[i] = n === 0 ? k : k + "#" + n + } + return out + } + + /// Field-by-field equality, `ignoredKeys` excluded. Arrays of scalars are + /// compared element-wise; anything deeper falls back to JSON. + function sameRow(a, b) { + if (a === b) + return true + if (!a || !b || typeof a !== "object" || typeof b !== "object") + return false + var ignored = root.ignoredKeys + var k + var counted = 0 + for (k in a) { + if (ignored.indexOf(k) >= 0) + continue + counted++ + if (!(k in b) || !root._sameValue(a[k], b[k])) + return false + } + for (k in b) { + if (ignored.indexOf(k) >= 0) + continue + counted-- + } + return counted === 0 + } + + function _sameValue(x, y) { + if (x === y) + return true + if (x === null || y === null || typeof x !== "object" || typeof y !== "object") + return false + if (Array.isArray(x) && Array.isArray(y) && x.length === y.length) { + for (var i = 0; i < x.length; i++) { + if (x[i] !== y[i]) { + if (typeof x[i] !== "object" || typeof y[i] !== "object") + return false + return JSON.stringify(x) === JSON.stringify(y) + } + } + return true + } + return JSON.stringify(x) === JSON.stringify(y) + } + + function sync() { + if (root.paused) + return + var next = root.rows || [] + var keys = root.keysFor(next) + var st = root._state + if (!st.initialized || st.scope !== root.scope) { + root._reset(next, keys) + return + } + var old = st.keys + var sameOrder = old.length === keys.length + for (var s = 0; sameOrder && s < keys.length; s++) + sameOrder = old[s] === keys[s] + if (!sameOrder && !root._restructure(next, keys)) + return + var changed = sameOrder ? root._update(next, keys) : true + root._published = next + if (changed) + root.reconciled(false) + } + + function _reset(next, keys) { + var st = root._state + var had = root.count > 0 + root._animate = false + st.byKey = new Map() + st.revs = new Map() + st.graveyard = new Map() + var batch = new Array(next.length) + for (var i = 0; i < next.length; i++) { + st.byKey.set(keys[i], next[i]) + batch[i] = { "rowKey": keys[i], "rowRev": 0 } + } + root.clear() + if (batch.length > 0) + root.append(batch) + st.keys = keys + st.scope = root.scope + st.initialized = true + root._published = next + root._layoutViews(had) + root._animate = true + root.reconciled(true) + } + + /// Apply the reset now, with transitions off, and — when it replaced + /// rows the user was looking at — start from the top, as the model swap + /// it replaces did. (A ListView lands there by itself; a GridView keeps + /// its offset clamped into the new, shorter content.) + function _layoutViews(toTop) { + var vs = root.views || [] + for (var i = 0; i < vs.length; i++) { + if (!vs[i] || typeof vs[i].forceLayout !== "function") + continue + vs[i].forceLayout() + if (toTop && typeof vs[i].positionViewAtBeginning === "function") + vs[i].positionViewAtBeginning() + } + } + + /// Removals, then inserts and moves; false when it fell back to a reset. + function _restructure(next, keys) { + var st = root._state + var wanted = new Map() + for (var w = 0; w < keys.length; w++) + wanted.set(keys[w], w) + + // Survivors in their new order, and the longest run of them that is + // already in order: those never move. + var current = st.keys.slice() + var positions = [] + var survivors = [] + for (var c = 0; c < current.length; c++) { + if (wanted.has(current[c])) { + survivors.push(current[c]) + positions.push(wanted.get(current[c])) + } + } + var steady = root._longestIncreasing(positions, survivors) + if (survivors.length - steady.size > root.maxMoves) { + root._reset(next, keys) + return false + } + + // 1. Removals, bottom-up, contiguous runs in one call. + var i = current.length - 1 + while (i >= 0) { + if (wanted.has(current[i])) { + i-- + continue + } + var end = i + while (i - 1 >= 0 && !wanted.has(current[i - 1])) + i-- + if (st.graveyard.size > 512) + st.graveyard = new Map() + for (var g = i; g <= end; g++) { + st.graveyard.set(current[g], st.byKey.get(current[g])) + st.byKey.delete(current[g]) + st.revs.delete(current[g]) + } + root.remove(i, end - i + 1) + current.splice(i, end - i + 1) + i-- + } + + // 2. Inserts (contiguous runs) and moves, walking the new order. + var p = 0 + var guard = 3 * keys.length + 16 + while (p < keys.length) { + if (--guard < 0) { + root._reset(next, keys) + return false + } + if (current[p] === keys[p]) { + p++ + continue + } + var key = keys[p] + if (!st.byKey.has(key)) { + var runEnd = p + while (runEnd + 1 < keys.length && !st.byKey.has(keys[runEnd + 1])) + runEnd++ + var batch = [] + for (var r = p; r <= runEnd; r++) { + batch.push({ "rowKey": keys[r], "rowRev": 0 }) + st.byKey.set(keys[r], next[r]) + } + root.insert(p, batch) + Array.prototype.splice.apply(current, [p, 0].concat(keys.slice(p, runEnd + 1))) + p = runEnd + 1 + continue + } + if (!steady.has(key)) { + // A row that has to move: bring it here. + var from = current.indexOf(key, p + 1) + if (from < 0) { + root._reset(next, keys) + return false + } + root.move(from, p, 1) + current.splice(p, 0, current.splice(from, 1)[0]) + p++ + continue + } + // `key` stays put, so the row sitting here is one that has to + // move further down: send it next to the first steady row that + // follows its place in the new order. + var floating = current[p] + var target = wanted.get(floating) + var anchor = -1 + for (var t = target + 1; t < keys.length; t++) { + if (steady.has(keys[t])) { + anchor = current.indexOf(keys[t]) + break + } + } + var to = anchor < 0 ? current.length - 1 : anchor - 1 + if (to <= p) { + root._reset(next, keys) + return false + } + root.move(p, to, 1) + current.splice(to, 0, current.splice(p, 1)[0]) + } + + st.keys = keys + root._update(next, keys) + return true + } + + /// Hand changed rows to their delegates; true when anything changed. + function _update(next, keys) { + var st = root._state + var changed = false + for (var i = 0; i < keys.length; i++) { + var key = keys[i] + var prev = st.byKey.get(key) + var row = next[i] + if (prev === row) + continue + st.byKey.set(key, row) + if (prev !== undefined && root.sameRow(prev, row)) + continue + var rev = ++st.rev + st.revs.set(key, rev) + root.setProperty(i, "rowRev", rev) + changed = true + } + return changed + } + + /// Keys (of `items`) on one longest strictly increasing run of + /// `positions` — O(n log n) patience sorting. + function _longestIncreasing(positions, items) { + var n = positions.length + var tails = [] + var tailIdx = [] + var prevIdx = new Array(n) + for (var i = 0; i < n; i++) { + var v = positions[i] + var lo = 0 + var hi = tails.length + while (lo < hi) { + var mid = (lo + hi) >> 1 + if (tails[mid] < v) + lo = mid + 1 + else + hi = mid + } + tails[lo] = v + tailIdx[lo] = i + prevIdx[i] = lo > 0 ? tailIdx[lo - 1] : -1 + } + var out = new Set() + var k = tails.length > 0 ? tailIdx[tails.length - 1] : -1 + while (k >= 0) { + out.add(items[k]) + k = prevIdx[k] + } + return out + } +} diff --git a/crates/qbz-qt/qml/controls/QbzRowAdd.qml b/crates/qbz-qt/qml/controls/QbzRowAdd.qml new file mode 100644 index 000000000..71d36158f --- /dev/null +++ b/crates/qbz-qt/qml/controls/QbzRowAdd.qml @@ -0,0 +1,28 @@ +// QbzRowAdd — a row joining a QbzKeyedModel view fades in (a card also grows +// from a touch smaller) once its neighbours made room (2026-09-14). Assign +// it to the view's `add`, with `enabled: .animate`; a scope reset +// never runs it. + +import QtQuick + +Transition { + id: root + property int duration: 200 + /// 1.0 for list rows; cards read better growing from slightly smaller. + property real grow: 1.0 + + NumberAnimation { + property: "opacity" + from: 0.0 + to: 1.0 + duration: root.duration + easing.type: Easing.OutCubic + } + NumberAnimation { + property: "scale" + from: root.grow + to: 1.0 + duration: root.duration + easing.type: Easing.OutCubic + } +} diff --git a/crates/qbz-qt/qml/controls/QbzRowDisplaced.qml b/crates/qbz-qt/qml/controls/QbzRowDisplaced.qml new file mode 100644 index 000000000..4cc694d6c --- /dev/null +++ b/crates/qbz-qt/qml/controls/QbzRowDisplaced.qml @@ -0,0 +1,36 @@ +// QbzRowDisplaced — the neighbours of an inserted, removed or moved row slide +// to their new place (QbzKeyedModel, 2026-09-14). Assign it to a ListView or +// GridView's `displaced` and `move`, with `enabled: .animate`. +// +// A row displaced while it was still fading in or out would otherwise keep +// that partial opacity or scale for good (Qt's ViewTransition notes), so the +// slide also settles both back to 1. +// +// Not a continuous animation: it runs once per structural change, for the +// rows actually on screen, and leaves nothing ticking (the repaint pulse +// rule in CLAUDE.md is about animations that keep presenting frames). + +import QtQuick + +Transition { + id: root + property int duration: 220 + + NumberAnimation { + properties: "x,y" + duration: root.duration + easing.type: Easing.OutCubic + } + NumberAnimation { + property: "opacity" + to: 1.0 + duration: root.duration + easing.type: Easing.OutCubic + } + NumberAnimation { + property: "scale" + to: 1.0 + duration: root.duration + easing.type: Easing.OutCubic + } +} diff --git a/crates/qbz-qt/qml/controls/QbzRowRemove.qml b/crates/qbz-qt/qml/controls/QbzRowRemove.qml new file mode 100644 index 000000000..582bf3949 --- /dev/null +++ b/crates/qbz-qt/qml/controls/QbzRowRemove.qml @@ -0,0 +1,40 @@ +// QbzRowRemove — a row leaving a QbzKeyedModel view fades out, a card also +// shrinks a touch (2026-09-14). Assign it to the view's `remove`, with +// `enabled: .animate`. The view keeps the delegate alive until this +// ends; its content stays because a removed row's roles are not re-notified. +// +// THE LAST STEP PUTS OPACITY AND SCALE BACK. With `reuseItems` the view pools +// the delegate the moment this transition finishes (QQuickItemViewPrivate:: +// viewItemTransitionFinished -> releaseItem -> setVisible(false), all in one +// call), and a pooled delegate is reused as it was left: without the restore +// the next row it renders is invisible. Measured 2026-09-14: 76 blank rows +// after three removals and a scroll. The restore runs before the release, in +// the same call chain, so it never paints. + +import QtQuick + +Transition { + id: root + property int duration: 180 + /// 1.0 for list rows; cards read better with a slight shrink. + property real shrink: 1.0 + + SequentialAnimation { + ParallelAnimation { + NumberAnimation { + property: "opacity" + to: 0.0 + duration: root.duration + easing.type: Easing.OutCubic + } + NumberAnimation { + property: "scale" + to: root.shrink + duration: root.duration + easing.type: Easing.OutCubic + } + } + PropertyAction { property: "opacity"; value: 1.0 } + PropertyAction { property: "scale"; value: 1.0 } + } +} diff --git a/scripts/qml-tests/tst_keyed_model.qml b/scripts/qml-tests/tst_keyed_model.qml new file mode 100644 index 000000000..c00d13ecc --- /dev/null +++ b/scripts/qml-tests/tst_keyed_model.qml @@ -0,0 +1,339 @@ +// QbzKeyedModel: rows reconcile into removes / inserts / moves / updates +// instead of a model swap. Guards the order and identity contract under +// random edits, the minimal-move reorder, the silent reset, a removal that +// keeps the viewport and the delegates, the fade that keeps its content, and +// the pooled delegate that must come back opaque (76 blank rows, 2026-09-14). +import QtQuick +import QtTest +import "../../crates/qbz-qt/qml/controls" + +Item { + id: root + width: 400; height: 400 + + property var countedRows: [] + property var fadeRows: [] + property var plainRows: [] + property int created: 0 + property int addRuns: 0 + property int removeRuns: 0 + property int moveSignals: 0 + + function make(n, prefix) { + var out = [] + for (var i = 0; i < n; i++) + out.push({ "id": prefix + i, "title": "T" + prefix + i }) + return out + } + + // --- counted transitions: the reset / structure tests ------------------ + QbzKeyedModel { id: counted; rows: root.countedRows; views: [countedView] } + Connections { + target: counted + function onRowsMoved() { root.moveSignals++ } + } + ListView { + id: countedView + width: 200; height: 400 + model: counted + reuseItems: true + add: Transition { + enabled: counted.animate + ScriptAction { script: root.addRuns++ } + NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 60 } + } + remove: Transition { + enabled: counted.animate + ScriptAction { script: root.removeRuns++ } + SequentialAnimation { + NumberAnimation { property: "opacity"; to: 0; duration: 60 } + PropertyAction { property: "opacity"; value: 1 } + } + } + displaced: QbzRowDisplaced { enabled: counted.animate; duration: 60 } + delegate: Item { + required property string rowKey + required property int rowRev + readonly property var modelData: counted.row(rowKey, rowRev) + width: 200; height: 20 + Component.onCompleted: root.created++ + ListView.onReused: { opacity = 1; scale = 1 } + } + } + + // --- the shipped transitions: fade / reuse tests ----------------------- + QbzKeyedModel { id: faded; rows: root.fadeRows; views: [fadeView] } + ListView { + id: fadeView + x: 200 + width: 200; height: 400 + model: faded + reuseItems: true + remove: QbzRowRemove { enabled: faded.animate; duration: 240 } + displaced: QbzRowDisplaced { enabled: faded.animate; duration: 240 } + delegate: Item { + required property string rowKey + required property int rowRev + readonly property var modelData: faded.row(rowKey, rowRev) + readonly property string shown: modelData.title || "" + width: 200; height: 20 + } + } + + // --- no view: reconcile arithmetic ------------------------------------- + QbzKeyedModel { id: plain; rows: root.plainRows } + + TestCase { + name: "KeyedModel" + when: windowShown + + function settle(view) { wait(0); waitForRendering(view); wait(0) } + function order(m) { + var out = [] + for (var i = 0; i < m.count; i++) + out.push(m.get(i).rowKey) + return out + } + function topKey(view) { + var it = view.itemAt(10, view.contentY + 1) + return it ? it.rowKey : "" + } + function prng(seed) { + var a = seed >>> 0 + return function () { + a = (a + 0x6D2B79F5) >>> 0 + var t = a + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } + } + + function test_a_random_edits_reconcile_order_identity_and_revs() { + var rnd = prng(20260914) + var rows = root.make(60, "r") + var serial = 60 + root.plainRows = rows + plain.sync() + for (var step = 0; step < 400; step++) { + var next = rows.slice() + var kind = Math.floor(rnd() * 6) + if (kind === 0 && next.length > 0) { + next.splice(Math.floor(rnd() * next.length), 1 + Math.floor(rnd() * 3)) + } else if (kind === 1) { + var at = Math.floor(rnd() * (next.length + 1)) + var burst = [] + for (var b = 0; b < 1 + Math.floor(rnd() * 4); b++) + burst.push({ "id": "r" + (serial++), "title": "new" }) + Array.prototype.splice.apply(next, [at, 0].concat(burst)) + } else if (kind === 2 && next.length > 1) { + var from = Math.floor(rnd() * next.length) + var moved = next.splice(from, 1)[0] + next.splice(Math.floor(rnd() * (next.length + 1)), 0, moved) + } else if (kind === 3 && next.length > 0) { + // A repeat of an existing entity (the queue allows it). + var dup = next[Math.floor(rnd() * next.length)] + next.splice(Math.floor(rnd() * (next.length + 1)), 0, + { "id": dup.id, "title": dup.title }) + } else if (kind === 4 && next.length > 0) { + var ci = Math.floor(rnd() * next.length) + next[ci] = { "id": next[ci].id, "title": "changed " + step } + } else { + // Same data, all-new objects (a republished document). + next = next.map(function (r) { return { "id": r.id, "title": r.title } }) + } + var keys = plain.keysFor(next) + var revsBefore = ({}) + for (var k = 0; k < plain.count; k++) + revsBefore[plain.get(k).rowKey] = plain.get(k).rowRev + var before = ({}) + var prevKeys = plain.keysFor(rows) + for (var p = 0; p < rows.length; p++) + before[prevKeys[p]] = rows[p] + root.plainRows = next + plain.sync() + compare(order(plain), keys, "order after step " + step) + for (var i = 0; i < next.length; i++) { + verify(plain.row(keys[i]) === next[i], "identity " + keys[i]) + var prev = before[keys[i]] + if (prev !== undefined && revsBefore[keys[i]] !== undefined) { + var contentChanged = prev.title !== next[i].title + compare(plain.get(i).rowRev !== revsBefore[keys[i]], contentChanged, + "rev bump iff content changed: " + keys[i] + " step " + step) + } + } + rows = next + } + } + + function test_b_one_row_moved_far_is_one_move() { + root.moveSignals = 0 + var rows = root.make(100, "m") + root.countedRows = rows + counted.scope = "moves" + counted.sync() + settle(countedView) + root.moveSignals = 0 + var next = rows.slice() + next.splice(90, 0, next.splice(3, 1)[0]) + root.countedRows = next + counted.sync() + compare(root.moveSignals, 1, "down") + compare(order(counted), counted.keysFor(next)) + root.moveSignals = 0 + var back = next.slice() + back.splice(3, 0, back.splice(90, 1)[0]) + root.countedRows = back + counted.sync() + compare(root.moveSignals, 1, "up") + compare(order(counted), counted.keysFor(back)) + } + + function test_c_scope_change_resets_without_transitions_at_the_top() { + root.countedRows = root.make(300, "s") + counted.scope = "tab-a" + counted.sync() + settle(countedView) + countedView.contentY = countedView.originY + 2000 + settle(countedView) + var adds = root.addRuns + var removes = root.removeRuns + counted.scope = "tab-b" + root.countedRows = root.make(120, "t") + wait(0) + settle(countedView) + wait(150) + compare(counted.count, 120) + compare(root.addRuns, adds, "no add transition for a reset") + compare(root.removeRuns, removes, "no remove transition for a reset") + verify(countedView.atYBeginning, "a reset starts at the top") + compare(topKey(countedView), "t0") + } + + function test_c2_reset_from_empty_does_not_animate_either() { + counted.scope = "empty" + root.countedRows = [] + wait(0) + settle(countedView) + var adds = root.addRuns + counted.scope = "filled" + root.countedRows = root.make(50, "e") + wait(0) + settle(countedView) + wait(150) + compare(counted.count, 50) + compare(root.addRuns, adds, "the first page of a scope appears at once") + } + + function test_d_removal_keeps_the_viewport_and_the_delegates() { + var rows = root.make(400, "v") + root.countedRows = rows + counted.scope = "viewport" + counted.sync() + settle(countedView) + countedView.contentY = countedView.originY + 1000 + settle(countedView) + var top = topKey(countedView) + compare(top, "v50") + var made = root.created + var removes = root.removeRuns + // Ten rows above the viewport and one inside it leave. + root.countedRows = rows.filter(function (r, i) { return i >= 10 && i !== 55 }) + counted.sync() + settle(countedView) + wait(150) + compare(topKey(countedView), top, "the row at the top stays at the top") + verify(root.created - made <= 2, "no rebuild: " + (root.created - made) + " delegates created") + verify(root.removeRuns > removes, "the visible row animated out") + } + + function test_e_removed_row_fades_out_with_its_content() { + root.fadeRows = root.make(40, "f") + faded.scope = "fade" + faded.sync() + settle(fadeView) + root.fadeRows = root.make(40, "f").filter(function (r) { return r.id !== "f5" }) + faded.sync() + wait(80) + var ghost = null + for (var i = 0; i < fadeView.contentItem.children.length; i++) { + var ch = fadeView.contentItem.children[i] + if (ch.rowKey === "f5" && ch.opacity > 0.05 && ch.opacity < 0.99) + ghost = ch + } + verify(ghost !== null, "the leaving row is mid-fade") + compare(ghost.shown, "Tf5", "and still shows its own content") + wait(400) + } + + function test_f_pooled_rows_come_back_opaque() { + var rows = root.make(40, "p") + root.fadeRows = rows + faded.scope = "reuse" + faded.sync() + settle(fadeView) + for (var pass = 0; pass < 3; pass++) { + rows = rows.filter(function (r, i) { return i % 7 !== 3 }) + root.fadeRows = rows + faded.sync() + wait(400) + } + root.fadeRows = rows.concat(root.make(300, "q")) + faded.sync() + settle(fadeView) + var blank = [] + for (var y = 0; y < 5000; y += 150) { + fadeView.contentY = fadeView.originY + y + wait(20) + for (var c = 0; c < fadeView.contentItem.children.length; c++) { + var it = fadeView.contentItem.children[c] + if (it.rowKey === undefined || it.index === -1) + continue + if (fadeView.itemAtIndex(faded.indexOfKey(it.rowKey)) !== it) + continue + if (it.opacity < 0.99) + blank.push(it.rowKey) + } + } + compare(blank.length, 0, "rows reused from the pool are opaque: " + blank.slice(0, 8).join(" ")) + } + + function test_g_paused_holds_rows_until_it_clears() { + root.plainRows = root.make(5, "h") + plain.scope = "paused" + plain.sync() + plain.paused = true + root.plainRows = root.make(8, "h") + plain.sync() + compare(plain.count, 5) + plain.paused = false + wait(0) + compare(plain.count, 8) + } + + function test_h_ignored_keys_do_not_count_as_a_change() { + plain.ignoredKeys = ["_order"] + root.plainRows = [{ "id": "a", "title": "A", "_order": 0 }, { "id": "b", "title": "B", "_order": 1 }] + plain.scope = "ignored" + plain.sync() + var revA = plain.get(0).rowRev + root.plainRows = [{ "id": "a", "title": "A", "_order": 5 }, { "id": "b", "title": "B", "_order": 6 }] + plain.sync() + compare(plain.get(0).rowRev, revA) + root.plainRows = [{ "id": "a", "title": "A2", "_order": 5 }, { "id": "b", "title": "B", "_order": 6 }] + plain.sync() + verify(plain.get(0).rowRev !== revA) + plain.ignoredKeys = [] + } + + function test_i_repeated_entities_are_distinct_rows() { + root.plainRows = [{ "id": "x" }, { "id": "y" }, { "id": "x" }] + plain.scope = "dups" + plain.sync() + compare(order(plain), ["x", "y", "x#1"]) + root.plainRows = [{ "id": "y" }, { "id": "x" }] + plain.sync() + compare(order(plain), ["y", "x"]) + } + } +} diff --git a/scripts/qt-runtime-gate.sh b/scripts/qt-runtime-gate.sh index 0f7c14ec7..c57b95ebd 100644 --- a/scripts/qt-runtime-gate.sh +++ b/scripts/qt-runtime-gate.sh @@ -10,6 +10,7 @@ bash scripts/test-text-edit-menu-qml.sh bash scripts/test-compact-seek-qml.sh bash scripts/test-library-folders-qml.sh bash scripts/test-array-model-qml.sh +bash scripts/test-keyed-model-qml.sh bash scripts/test-card-menu-qml.sh bash scripts/test-orbit-qml.sh node scripts/test_qt_local_views.mjs diff --git a/scripts/test-keyed-model-qml.sh b/scripts/test-keyed-model-qml.sh new file mode 100755 index 000000000..db31f3b65 --- /dev/null +++ b/scripts/test-keyed-model-qml.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# QbzKeyedModel regression: rows reconcile into removes / inserts / moves / +# updates, a scope change resets silently at the top, a removal keeps the +# viewport and the delegates, and pooled delegates come back opaque. Same run +# in CI and locally. +set -euo pipefail +cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.." +if [[ -n "${QT_ROOT_DIR:-}" ]]; then + qt_keyed_model_bins="$QT_ROOT_DIR/bin" +else + qt_keyed_model_qmake="${QMAKE:-}" + if [[ -z "$qt_keyed_model_qmake" ]]; then + qt_keyed_model_qmake="$(command -v qmake6 || command -v qmake)" + fi + qt_keyed_model_bins="$("$qt_keyed_model_qmake" -query QT_INSTALL_BINS)" +fi +QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \ + "$qt_keyed_model_bins/qmltestrunner" \ + -input scripts/qml-tests/tst_keyed_model.qml \ + -import scripts/qml-tests/imports