Keep image picker work bounded for large theme collections

This commit is contained in:
Ric Lewis committed 2026-10-02 22:45:39 -07:00
1 parent a85e29abb5
commit 2ba1015ef2
6 files changed
+240 -33

No files matched your search

+2
View File
@@ -77,6 +77,8 @@ clears it without writing a selection.
The plugin has `keepLoaded: true` so the layer-shell window survives
between summons within a single shell session.
The carousel renders only the slices that fit on the screen plus one prefetch slice per side, capped at 33 cards. It preserves overlapping delegates during navigation, decodes images asynchronously at card size, and loads the selected preview before its neighbors. Lazy thumbnail preparation runs below normal CPU and I/O priority in one shared worker pool per image list: one worker on single/dual-core machines and at most two on larger machines. Opening and navigating the picker does not wait for that queue to finish.
## Lock screen
Session-lock surface using Quickshell's native `WlSessionLock` and two
+51 -25
View File
@@ -25,6 +25,7 @@ Item {
property bool showLabels: false
property bool filterable: false
property bool layoutSettled: false
property bool neighborImagesEnabled: false
property bool requestActive: false
property int requestSerial: 0
property int applySerial: 0
@@ -51,6 +52,9 @@ Item {
property int sliceSpacing: -30
property int skewOffset: 28
property int bottomChromeHeight: showLabels ? (filterable ? 104 : 74) : (filterable ? 60 : 30)
// Render only what fits on this display, plus one prefetch slice per side.
readonly property int previewRadius: Math.max(1, Math.min(16, Math.ceil((panel.width - expandedWidth) / (2 * (sliceWidth + sliceSpacing))) + 1))
onPreviewRadiusChanged: updateVisibleItems()
onOpenedChanged: if (!opened) layoutSettled = false
@@ -100,14 +104,6 @@ Item {
return ImagePickerModel.firstMatchingIndex(imageArray, filterText)
}
function filteredPosition(index) {
return ImagePickerModel.filteredPosition(imageArray, index, filterText)
}
function selectedFilteredPosition() {
return ImagePickerModel.selectedFilteredPosition(imageArray, selectedIndex, filterText)
}
function select(index, immediate) {
if (imageArray.length === 0) return
if (index < 0) index = 0
@@ -215,8 +211,10 @@ Item {
root.loadedImageRows = rows
root.selectedIndex = root.indexForSelectedImage(newImages)
root.neighborImagesEnabled = false
root.imageArray = newImages
root.imagesLoaded = true
Qt.callLater(root.enableNeighborsWhenReady)
if (reveal !== false) {
root.opened = true
@@ -273,6 +271,22 @@ Item {
}
property var imageArray: []
readonly property var matchingImageIndices: ImagePickerModel.matchingIndices(imageArray, filterText)
onMatchingImageIndicesChanged: updateVisibleItems()
onSelectedIndexChanged: updateVisibleItems()
function updateVisibleItems() {
ImagePickerModel.syncWindow(visibleImages, ImagePickerModel.visibleWindow(matchingImageIndices, selectedIndex, previewRadius))
}
function enableNeighborsWhenReady() {
for (var i = 0; i < imageCards.count; i++) {
var item = imageCards.itemAt(i)
if (item && item.selected && item.previewReady) neighborImagesEnabled = true
}
}
ListModel { id: visibleImages }
function currentThemePreview() {
@@ -465,6 +479,8 @@ Item {
Item {
id: card
visible: root.opened && root.imagesLoaded && root.layoutSettled && root.imageArray.length > 0
opacity: root.layoutSettled ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 90 } }
width: Math.min(parent.width - 80, root.expandedWidth + 13 * (root.sliceWidth + root.sliceSpacing) + 40)
height: root.expandedHeight + Style.space(30) + root.bottomChromeHeight
anchors.centerIn: parent
@@ -515,30 +531,32 @@ Item {
Component.onCompleted: forceActiveFocus()
Repeater {
model: root.imageArray.length
id: imageCards
model: visibleImages
delegate: Item {
id: item
required property int index
required property int imageIndex
required property int relativeIndex
readonly property var imageData: root.imageArray[index]
readonly property var imageData: root.imageArray[imageIndex]
readonly property string filePath: imageData ? imageData.filePath : ""
readonly property string fileName: imageData ? imageData.fileName : ""
readonly property string thumbnailPath: imageData ? imageData.thumbnailPath : ""
readonly property bool matched: root.itemMatches(index)
readonly property int relativeIndex: root.filteredPosition(index) - root.selectedFilteredPosition()
readonly property bool selected: matched && index === root.selectedIndex
readonly property bool nearby: matched && Math.abs(relativeIndex) <= 16
property bool sourceActivated: nearby
onNearbyChanged: if (nearby) sourceActivated = true
readonly property bool selected: imageIndex === root.selectedIndex
readonly property bool previewReady: image.status === Image.Ready || image.status === Image.Error
onSelectedChanged: if (selected && previewReady) root.neighborImagesEnabled = true
visible: nearby
x: selected ? carousel.previewX : (relativeIndex < 0 ? carousel.previewX + relativeIndex * carousel.itemStep : carousel.previewX + root.expandedWidth + root.sliceSpacing + (relativeIndex - 1) * carousel.itemStep)
width: selected ? root.expandedWidth : root.sliceWidth
height: selected ? root.expandedHeight : root.sliceHeight
y: selected ? 0 : (root.expandedHeight - root.sliceHeight) / 2
z: selected ? 100 : 50 - Math.min(Math.abs(relativeIndex), 40)
Behavior on x { NumberAnimation { duration: 90; easing.type: Easing.OutCubic } }
Behavior on y { NumberAnimation { duration: 90; easing.type: Easing.OutCubic } }
Behavior on width { NumberAnimation { duration: 90; easing.type: Easing.OutCubic } }
Behavior on height { NumberAnimation { duration: 90; easing.type: Easing.OutCubic } }
readonly property real skAbs: Math.abs(root.skewOffset)
readonly property real topLeft: root.skewOffset >= 0 ? skAbs : 0
@@ -579,17 +597,25 @@ Item {
maskSpreadAtMin: 0.3
}
Rectangle { anchors.fill: parent; color: root.dimColor }
Image {
id: image
anchors.fill: parent
// Load only the initial/visited nearby images, but keep the
// source once activated so Qt does not tear textures down as
// selection moves through the carousel.
source: item.sourceActivated && item.thumbnailPath ? Util.fileUrl(item.thumbnailPath) : ""
// Even an uncached 6K wallpaper decodes at card size, off the
// GUI thread. Departing cards release their images instead of
// retaining every preview visited in a large collection.
// Queue the selected preview first; neighbors must not delay
// the image the user opened the picker to see.
source: (item.selected || root.neighborImagesEnabled) && item.thumbnailPath ? Util.fileUrl(item.thumbnailPath) : ""
sourceSize: Qt.size(root.expandedWidth, root.expandedHeight)
fillMode: Image.PreserveAspectCrop
asynchronous: false
cache: true
asynchronous: true
cache: false
smooth: true
opacity: status === Image.Ready ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 90 } }
onStatusChanged: if (item.selected && (status === Image.Ready || status === Image.Error)) root.neighborImagesEnabled = true
}
Rectangle {
@@ -617,7 +643,7 @@ Item {
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: item.selected ? root.applySelected() : root.select(index)
onClicked: item.selected ? root.applySelected() : root.select(item.imageIndex)
}
}
}
+43 -1
View File
@@ -82,6 +82,45 @@ function nextSelectedIndexForFilter(images, selectedIndex, filterText) {
return firstMatchingIndex(images, filterText)
}
function matchingIndices(images, filterText) {
var indices = []
for (var i = 0; i < images.length; i++) {
if (itemMatches(images, i, filterText)) indices.push(i)
}
return indices
}
// Keep the rendered carousel independent of the size of the collection.
function visibleWindow(indices, selectedIndex, radius) {
var position = indices.indexOf(selectedIndex)
if (position < 0) position = 0
var items = []
for (var i = Math.max(0, position - radius); i < Math.min(indices.length, position + radius + 1); i++) {
items.push({ imageIndex: indices[i], relativeIndex: i - position })
}
return items
}
// Preserve overlapping delegates as selection moves, including their decoded
// images. Replacing the entire model on every keypress makes previews flicker.
function syncWindow(model, items) {
var wanted = {}
for (var i = 0; i < items.length; i++) wanted[items[i].imageIndex] = true
for (var i = model.count - 1; i >= 0; i--) {
if (!wanted[model.get(i).imageIndex]) model.remove(i)
}
for (var i = 0; i < items.length; i++) {
var existing = i
while (existing < model.count && model.get(existing).imageIndex !== items[i].imageIndex) existing++
if (existing === model.count) {
model.insert(i, items[i])
} else {
if (existing !== i) model.move(existing, i, 1)
model.setProperty(i, "relativeIndex", items[i].relativeIndex)
}
}
}
if (typeof module !== "undefined") {
module.exports = {
nameForPath: nameForPath,
@@ -92,6 +131,9 @@ if (typeof module !== "undefined") {
filteredPosition: filteredPosition,
selectedFilteredPosition: selectedFilteredPosition,
indexForSelectedImage: indexForSelectedImage,
nextSelectedIndexForFilter: nextSelectedIndexForFilter
nextSelectedIndexForFilter: nextSelectedIndexForFilter,
matchingIndices: matchingIndices,
visibleWindow: visibleWindow,
syncWindow: syncWindow
}
}