Files
qbz/scripts/qml-tests/tst_array_model.qml
T
vicrodh dae03e3834 fix(qt): list rebuilds no longer steal the header search box's focus
Typing in the album track filter lost the keyboard on every keystroke. A
qt.quick.focus trace named the thief: a fresh track delegate took focus
"in scope pageFlick" right after each rebuild. In Qt 6.11 assigning a new
array to ListView.model after completion runs
QQuickItemViewPrivate::connectModel, which forces currentIndex to 0, and
updateCurrent() then focuses delegate 0 — inside the view's focus scope,
where the header's TextInput lives. `currentIndex: -1` does not survive
the assignment.

QbzArrayModel (a DelegateModel) now owns such arrays: a swap reaches the
view as remove+insert changes, which honour currentIndex -1 and keep the
rows. Because ListView re-seats inserted rows around the viewport, the
model re-expresses the viewport relative to the content origin and
restores it after layout; a page replacement calls scrollToTop(). The
album page, the kiosk album and the kiosk playlist swap their rows on it,
their ScrollMemory stores origin-relative offsets, and opening another
album resets the track filter (the box mirrors `trackQuery`).

scripts/test-array-model-qml.sh pins the behaviour (focus kept, viewport
kept, top on request) and joins the runtime gate and CI.

cc
2026-09-13 23:32:00 -06:00

104 lines
3.4 KiB
QML

// QbzArrayModel: swapping the rows array must not rob the view's header of
// the keyboard (Qt 6.11 forces currentIndex 0 on a ListView.model assignment
// and focuses delegate 0), must keep the viewport where it was relative to
// the content origin, and scrollToTop() must land on the origin.
import QtQuick
import QtQml.Models
import Qt.labs.qmlmodels
import QtTest
import "../../crates/qbz-qt/qml/controls"
Item {
id: root
width: 600; height: 400
property var cells: []
function makeCells(n) {
var out = []
for (var i = 0; i < n; i++)
out.push({ "kind": (i % 5 === 0) ? "track" : "gap", "i": i })
return out
}
ListView {
id: view
width: 600; height: 400
clip: true
reuseItems: true
currentIndex: -1
cacheBuffer: 800
model: QbzArrayModel { id: rowsModel; view: view; rows: root.cells }
header: Item {
width: 600; height: 300
TextInput { objectName: "field"; width: 200; height: 30; y: 250; text: "x" }
}
delegate: DelegateChooser {
role: "kind"
DelegateChoice { roleValue: "gap"; delegate: Item { width: 600; height: 10 } }
DelegateChoice {
roleValue: "track"
delegate: Item {
required property var modelData
width: 600; height: 10
Rectangle { width: 600; height: 50; color: "#333" }
}
}
}
}
TestCase {
name: "ArrayModel"
when: windowShown
function field() { return view.headerItem.children[0] }
function settle() { wait(0); waitForRendering(view); wait(0) }
function test_a_swap_keeps_header_focus_and_current_index() {
root.cells = root.makeCells(200)
settle()
compare(view.count, 200)
var input = field()
input.forceActiveFocus()
verify(input.activeFocus)
root.cells = root.makeCells(150)
settle()
compare(view.count, 150)
compare(view.currentIndex, -1)
verify(input.activeFocus, "the header's field keeps the keyboard across a swap")
}
function test_b_swap_keeps_viewport_relative_to_origin() {
root.cells = root.makeCells(200)
settle()
view.contentY = view.originY + 1000
settle()
var before = view.contentY - view.originY
root.cells = root.makeCells(300)
settle()
fuzzyCompare(view.contentY - view.originY, before, 0.5)
compare(view.count, 300)
}
function test_c_scroll_to_top_lands_on_the_origin() {
root.cells = root.makeCells(200)
settle()
view.contentY = view.originY + 900
settle()
rowsModel.scrollToTop()
root.cells = root.makeCells(120)
settle()
fuzzyCompare(view.contentY, view.originY, 0.5)
compare(view.count, 120)
}
function test_d_first_rows_do_not_move_the_viewport() {
root.cells = []
settle()
compare(view.count, 0)
var y = view.contentY
root.cells = root.makeCells(80)
settle()
compare(view.count, 80)
fuzzyCompare(view.contentY, y, 0.5)
}
}
}