feat(qt): a keyed list model whose rows change instead of being replaced
Handing an item view a new JS array is a model swap: every delegate is thrown away, the scroll offset resets and the page is built again. QbzKeyedModel takes the same arrays and reconciles them by key into removes, inserts, moves and in-place updates, so a view keeps its delegates, its offset and its artwork, and a leaving row can fade out while its neighbours slide into the gap. Each model row carries only `rowKey` and `rowRev`; a delegate looks the row up by key, so it reads the host's own JS object (no copy into a ListModel map) and a rev bump hands it a changed row. New objects for an existing key are compared field by field, so a republished document with the same data touches no delegate. A reorder keeps the longest in-order run steady and moves only the rest; past a cap, and on any scope change (tab, sort, search), the rows are replaced without animation, laid out synchronously and started from the top, like the swap they replace. Three shared transitions (add, remove, displaced) give every adopter the same motion. The remove transition restores opacity and scale as its last step: the view pools a delegate the moment the transition ends and hands it back as it was left, which measured 76 invisible rows after three removals and a scroll. tst_keyed_model covers order and identity under 400 random edits, the one-move reorder, silent resets, a removal that keeps the viewport and creates at most two delegates, the fade that keeps its content, and the pooled rows; it runs in the runtime gate. cc
This commit is contained in:
9 files changed
+875
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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: <model>.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
|
||||
}
|
||||
}
|
||||
@@ -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: <model>.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
|
||||
}
|
||||
}
|
||||
@@ -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: <model>.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 }
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Executable
+20
@@ -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
|
||||
Reference in new issue
Block a user