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
This commit is contained in:
vicrodh committed 2026-09-13 23:32:00 -06:00
1 parent b868319233
commit dae03e3834
10 files changed
+288 -36

No files matched your search

+1
View File
@@ -574,6 +574,7 @@ fn main() {
"qml/controls/PlaylistImportModal.qml",
"qml/controls/PlaylistPickerModal.qml",
"qml/controls/PmFolderIcon.qml",
"qml/controls/QbzArrayModel.qml",
"qml/controls/QbzCircleAction.qml",
"qml/controls/QbzClipboard.qml",
"qml/controls/QbzColorPicker.qml",
@@ -0,0 +1,87 @@
// QbzArrayModel — a DelegateModel over a JS array that a view can SWAP
// (2026-09-13). Assigning a fresh array straight to `ListView.model` after
// the view has completed has two side effects in Qt 6.11
// (qquickitemview.cpp, QQuickItemViewPrivate::connectModel):
//
// 1. `currentIndex` is forced to 0 and updateCurrent() calls
// setFocus(true) on delegate 0. The view is a focus scope, so a search
// box living in its header loses the keyboard on every rebuild — the
// album track filter lost focus on each keystroke (qt.quick.focus
// trace: "focus trackCell in scope pageFlick", activeFocusItem
// TextInput -> trackCell). A `currentIndex: -1` on the view does not
// survive the assignment.
// 2. setModel() clears the view and resets contentY to the content start.
//
// Swapping the array HERE instead reaches the view as remove+insert changes:
// `currentIndex: -1` stays honoured (nothing is ever focused) and the view
// keeps its rows. ListView re-seats the inserted rows around the viewport,
// which drifts the content origin, so the viewport is re-expressed relative
// to that origin (0 = the header's top edge) before the change and restored
// once it is laid out. A swap that replaces the page calls `scrollToTop()`.
//
// Probe that measured both behaviours against Qt 6.11.2:
// scripts/qml-tests/tst_array_model.qml.
import QtQuick
import QtQml.Models
DelegateModel {
id: swap
/// The rows. Replace the array freely; the view keeps its place.
property var rows: []
/// The ListView (or GridView) this model feeds — needed for the restore.
property Item view: null
model: rows
property bool _live: false
property int _rowsBefore: 0
property real _offset: 0
property int _epoch: 0
property bool _toTop: false
Component.onCompleted: {
swap._live = true
swap._rowsBefore = swap.count
}
onModelChanged: {
if (!swap._live || !swap.view)
return
var had = swap._rowsBefore
swap._rowsBefore = swap.count
// Nothing to keep: the page had no rows (first document, or the
// empty document a detail publishes while the next one loads), or a
// page replacement already asked for the top.
if (had === 0 || swap._toTop)
return
// Before the pending remove+insert is laid out: how far the viewport
// sits below the content origin.
swap._offset = swap.view.contentY - swap.view.originY
var epoch = ++swap._epoch
Qt.callLater(function () {
if (epoch !== swap._epoch || !swap.view)
return
swap.view.forceLayout()
var minY = swap.view.originY
var maxY = minY + Math.max(0, swap.view.contentHeight - swap.view.height)
swap.view.contentY = Math.max(minY, Math.min(minY + swap._offset, maxY))
})
}
/// A swap that replaces the page (a different document): the viewport
/// goes to the top once the new rows are laid out. Works whether it is
/// called before or after the `rows` assignment in the same turn.
function scrollToTop() {
swap._toTop = true
var epoch = ++swap._epoch
Qt.callLater(function () {
swap._toTop = false
if (epoch !== swap._epoch || !swap.view)
return
swap.view.forceLayout()
swap.view.positionViewAtBeginning()
})
}
}
+6 -4
View File
@@ -44,10 +44,12 @@
// every one of those snaps the list back to the top — over a restore that had
// already run correctly.
//
// (The same reset also throws a plain forward visit to the top if a rail lands
// while you are reading. The re-assert below only stops it from eating the
// restore; fixing that properly means giving those rails a model of their own,
// which is an AlbumView change and not this file's business.)
// (The album page no longer resets: since 2026-09-13 its tape is swapped on a
// QbzArrayModel, which reaches the ListView as remove+insert changes and keeps
// the viewport — see that control for the Qt 6.11 focus theft that forced the
// move. Those swaps drift `originY` instead, which is what `relativeToOrigin`
// is for. The re-assert below stays for every page that still assigns
// `model` directly.)
//
// So the offset is held in `_wanted` and re-applied on every contentHeight
// change, every count change and every contentY move we did not make, until
+17 -3
View File
@@ -53,14 +53,28 @@ Rectangle {
function toggleSelected(id, mods) { selected = selection.next(selected, id, visibleTracks, mods === undefined ? Qt.NoModifier : mods) }
readonly property string navigationStateJson: JSON.stringify({trackQuery: trackQuery})
function restoreNavigationState(state) { trackQuery = state.trackQuery || "" }
onDocChanged: exitMultiSelectMode()
// An in-place album switch starts at the top; the first document of a
// mount does not touch the viewport (ScrollMemory may be restoring it).
property string loadedAlbumId: ""
onDocChanged: {
exitMultiSelectMode()
var id = root.album ? String(root.album.id || "") : ""
if (id === root.loadedAlbumId)
return
if (root.loadedAlbumId !== "")
rowsModel.scrollToTop()
root.loadedAlbumId = id
}
ListView {
id: list
anchors.fill: parent
anchors.margins: 12
clip: true
model: root.visibleTracks
// Swapped on a QbzArrayModel, never on `model:` — a fresh array there
// makes Qt 6.11 focus delegate 0 and the header's search box loses the
// keyboard on every keystroke (see the control's header).
model: QbzArrayModel { id: rowsModel; view: list; rows: root.visibleTracks }
cacheBuffer: height
reuseItems: true
boundsBehavior: Flickable.StopAtBounds
@@ -168,6 +182,6 @@ Rectangle {
]
onPicked: function(a) { if (root.selectedCount > 0) root.bulkAction(a) }
}
ScrollMemory { target: list; scope: "localalbum" }
ScrollMemory { target: list; scope: "localalbum"; relativeToOrigin: true }
QbzScrollBar { target: list; anchors.right: parent.right; anchors.top: parent.top; anchors.bottom: parent.bottom }
}
+9 -2
View File
@@ -84,6 +84,10 @@ Rectangle {
var id = doc.id || ""
if (id === loadedPlaylistId)
return
// An in-place playlist switch starts at the top; the first document
// of a mount leaves the viewport to ScrollMemory.
if (loadedPlaylistId !== "")
rowsModel.scrollToTop()
loadedPlaylistId = id
setMultiSelect(false)
}
@@ -396,7 +400,10 @@ Rectangle {
cacheBuffer: height
reuseItems: true
boundsBehavior: Flickable.StopAtBounds
model: root.tracks
// Swapped on a QbzArrayModel, never on `model:` — a fresh array there
// makes Qt 6.11 focus delegate 0, and the header's search box lost the
// keyboard whenever a filtered document landed (see the control).
model: QbzArrayModel { id: rowsModel; view: trackList; rows: root.tracks }
header: Column {
width: trackList.width
spacing: 12
@@ -586,6 +593,6 @@ Rectangle {
]
onPicked:function(a){if(root.selectedCount>0)root.bulkAction(a)}
}
ScrollMemory { target:trackList; scope:"playlist" }
ScrollMemory { target:trackList; scope:"playlist"; relativeToOrigin:true }
QbzScrollBar { target:trackList; anchors.right:parent.right; anchors.top:parent.top; anchors.bottom:parent.bottom }
}
+43 -27
View File
@@ -59,14 +59,15 @@ Rectangle {
// The view's album + url-keyed cover map (artwork pipeline).
//
// Applied imperatively so a SAME-ALBUM deferred rail publish can preserve
// the ListView offset. Binding `model` to a freshly parsed JS array makes
// QQuickItemView reset contentY whenever Similar albums / Suggestions / More
// from this artist lands — the forced jump the owner caught while reading.
// Applied imperatively so the page can tell a SAME-ALBUM deferred rail
// publish (Similar albums / Suggestions / More from this artist landing
// while the user reads: the viewport stays put) from a NEW album (the
// track filter resets and the page starts at the top). The tape itself is
// swapped on `cellsModel`, a QbzArrayModel: reassigning the ListView's
// `model` would reset contentY on every publish and, on Qt 6.11, focus a
// delegate — see that control's header for both.
property var album: ({})
property string documentAlbumId: ""
property int albumDocumentEpoch: 0
property real albumRestoreY: 0
function parseAlbumDocument() {
try {
@@ -82,27 +83,18 @@ Rectangle {
var nextId = nextHeader.id !== undefined && nextHeader.id !== null
? String(nextHeader.id) : ""
var sameAlbum = nextId !== "" && nextId === root.documentAlbumId
var savedY = sameAlbum ? pageFlick.contentY : 0
root.albumDocumentEpoch += 1
var epoch = root.albumDocumentEpoch
if (!sameAlbum && root.documentAlbumId !== "") {
// A different release replacing the one on screen (the empty
// document a load publishes first counts): its track list starts
// unfiltered — the header's search box mirrors `trackQuery` — and
// at the top. The first document of a mount leaves the viewport
// alone: ScrollMemory may be restoring a Back navigation.
root.trackQuery = ""
cellsModel.scrollToTop()
}
root.album = next
if (nextId !== "")
root.documentAlbumId = nextId
if (!sameAlbum || savedY <= pageFlick.originY)
return
root.albumRestoreY = savedY
// The model reset and its content-height polish happen after the
// document assignment. One deferred restore coalesces a publish burst
// and clamps if the replacement document is genuinely shorter.
Qt.callLater(function () {
if (epoch !== root.albumDocumentEpoch
|| nextId !== root.documentAlbumId)
return
pageFlick.forceLayout()
var minY = pageFlick.originY
var maxY = minY + Math.max(0, pageFlick.contentHeight - pageFlick.height)
pageFlick.contentY = Math.max(minY, Math.min(root.albumRestoreY, maxY))
})
}
readonly property var albumHeader: album.header || ({})
@@ -850,7 +842,17 @@ Rectangle {
anchors.fill: parent
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.listCells
// The tape is swapped on this model, never on `model:` itself: a
// fresh array assigned to the ListView after completion makes Qt 6.11
// force currentIndex 0 and focus delegate 0, which robbed the header's
// track filter of the keyboard on every keystroke (2026-09-13). The
// wrapper also keeps the viewport across a same-album rail publish;
// applyAlbumDocument() asks for the top when the album changes.
model: QbzArrayModel {
id: cellsModel
view: pageFlick
rows: root.listCells
}
reuseItems: true
currentIndex: -1
// The track tape is cheap enough that a fast wheel can consume one
@@ -1561,13 +1563,25 @@ Rectangle {
height: 1
}
// Track filter (QbzSearchField: Escape clears + blurs,
// clear cross, focus kept across the list rebuild).
// clear cross). The keyboard survives the tape rebuild
// because the tape lives on a QbzArrayModel.
QbzSearchField {
id: trackSearch
width: 168
height: 34
anchors.verticalCenter: parent.verticalCenter
placeholder: QbzSession.tr("Search tracks...", QbzSession.trRev)
onEdited: function (text) { root.trackQuery = text }
// A new release resets `trackQuery`
// (applyAlbumDocument); the box follows, so no
// stale query outlives the album it was typed on.
Connections {
target: root
function onTrackQueryChanged() {
if (trackSearch.text !== root.trackQuery)
trackSearch.text = root.trackQuery
}
}
}
// Multi-select toggle (AlbumPageView.slint:752-787):
// accent border + tint while active; leaving the mode
@@ -2029,7 +2043,9 @@ Rectangle {
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
// this container's offset while it is the live page, and restores it
// when a back/forward step arms this route.
ScrollMemory { target: pageFlick; scope: "album" }
// Origin-relative: the tape swaps on cellsModel drift ListView.originY,
// so an absolute contentY would restore a different row after Back.
ScrollMemory { target: pageFlick; scope: "album"; relativeToOrigin: true }
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: 4