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

+48 -2
View File
@@ -42,6 +42,45 @@ assertEqual(picker.filteredPosition(images, 2, 'dark'), 1, 'image picker compute
assertEqual(picker.selectedFilteredPosition(images, 2, 'dark'), 0, 'image picker selected filtered position falls back when selected is hidden')
assertEqual(picker.nextSelectedIndexForFilter(images, 0, 'dark'), 1, 'image picker moves selection to first match when filter hides current item')
class WindowModel {
constructor() { this.items = []; this.insertions = 0 }
get count() { return this.items.length }
get(i) { return this.items[i] }
remove(i) { this.items.splice(i, 1) }
insert(i, item) { this.items.splice(i, 0, { ...item }); this.insertions++ }
move(from, to) { this.items.splice(to, 0, this.items.splice(from, 1)[0]) }
setProperty(i, key, value) { this.items[i][key] = value }
}
for (const count of [0, 1, 200, 530, 10000]) {
const collection = Array.from({ length: count }, (_, i) => ({ filePath: `/themes/theme-${i}.png` }))
const indices = picker.matchingIndices(collection, '')
const model = new WindowModel()
for (const selected of [0, Math.min(1, count - 1), Math.floor(count / 2), count - 1]) {
const window = picker.visibleWindow(indices, selected, 16)
picker.syncWindow(model, window)
assertDeepEqual(model.items, window, `carousel window reconciles ${count} images at ${selected}`)
assert(model.count <= 33, `carousel bounds delegates for ${count} images`)
if (count) assert(window.some(item => item.imageIndex === selected && item.relativeIndex === 0), `carousel includes selection in ${count} images`)
}
const matches = picker.matchingIndices(collection, 'theme-19')
picker.syncWindow(model, picker.visibleWindow(matches, matches[0], 16))
assert(model.items.every(item => collection[item.imageIndex].filePath.includes('theme-19')), `carousel filters ${count} images`)
picker.syncWindow(model, picker.visibleWindow([], 0, 16))
assertEqual(model.count, 0, `carousel clears ${count} images when there are no matches`)
}
const windowModel = new WindowModel()
const allIndices = Array.from({ length: 200 }, (_, i) => i)
picker.syncWindow(windowModel, picker.visibleWindow(allIndices, 100, 16))
const retained = windowModel.get(17)
picker.syncWindow(windowModel, picker.visibleWindow(allIndices, 101, 16))
assertEqual(windowModel.get(16), retained, 'carousel retains overlapping delegates when navigating')
assertEqual(windowModel.insertions, 34, 'carousel creates only one new delegate for an adjacent selection')
for (const radius of [1, 8, 16]) {
assertEqual(picker.visibleWindow(allIndices, 100, radius).length, radius * 2 + 1, `carousel scales its window to radius ${radius}`)
}
const imagePickerQml = fs.readFileSync(path.join(root, 'shell/plugins/image-picker/ImagePicker.qml'), 'utf8')
assert(
/function preloadRows[\s\S]*if \(opened \|\| requestActive\) return/.test(imagePickerQml),
@@ -74,7 +113,14 @@ assert(
'image picker parks on OverlayWindow and takes the keyboard once images load'
)
assert(
/source: item\.sourceActivated && item\.thumbnailPath \? Util\.fileUrl\(item\.thumbnailPath\) : ""[\s\S]*asynchronous: false/.test(imagePickerQml),
'image picker loads activated thumbnails synchronously to avoid carousel flicker'
/model: visibleImages/.test(imagePickerQml) &&
/sourceSize: Qt\.size\(root\.expandedWidth, root\.expandedHeight\)/.test(imagePickerQml) &&
/asynchronous: true\s*cache: false/.test(imagePickerQml),
'image picker renders its window with bounded asynchronous decoding'
)
assert(
imagePickerQml.includes('(item.selected || root.neighborImagesEnabled)') &&
/onStatusChanged: if \(item.selected && \(status === Image.Ready \|\| status === Image.Error\)\) root.neighborImagesEnabled = true/.test(imagePickerQml),
'image picker prioritizes the selected preview and releases neighbors on success or failure'
)
JS
+74
View File
@@ -151,3 +151,77 @@ while IFS=$'\t' read -r row_image row_thumbnail; do
fail "image menu prints each image with its generated thumbnail"
done <<<"$rows"
pass "image menu prints its rows for the shell to hold"
# Block converters behind a gate: printing lazy rows must neither await them
# nor start one process per image. Repeated refreshes share one worker pool.
lazy_images="$tmp/lazy-images"
lazy_state="$tmp/lazy-state"
mkdir -p "$lazy_images" "$lazy_state"
for (( i = 0; i < 40; i++ )); do
printf 'image' >"$lazy_images/$i.png"
done
printf '0\n' >"$lazy_state/active"
printf '0\n' >"$lazy_state/peak"
cat >"$stub_bin/nproc" <<'EOF'
#!/bin/bash
echo "${FAKE_CORES:-2}"
EOF
cat >"$stub_bin/vipsthumbnail" <<'EOF'
#!/bin/bash
while (( $# > 0 )); do
if [[ $1 == "--path" ]]; then output=${2%%\[*}; break; fi
shift
done
exec 9>"$LAZY_STATE/lock"
priority=$(ps -o ni= -p "$$")
(( priority >= 10 )) || : >"$LAZY_STATE/priority-failed"
[[ $(ionice -p "$$") == "idle" ]] || : >"$LAZY_STATE/priority-failed"
flock 9
active=$(<"$LAZY_STATE/active")
active=$((active + 1))
printf '%s\n' "$active" >"$LAZY_STATE/active"
(( active <= $(<"$LAZY_STATE/peak") )) || printf '%s\n' "$active" >"$LAZY_STATE/peak"
flock -u 9
while [[ ! -f $LAZY_STATE/gate ]]; do sleep 0.02; done
printf 'thumbnail' >"$output"
flock 9
active=$(<"$LAZY_STATE/active")
printf '%s\n' "$((active - 1))" >"$LAZY_STATE/active"
echo done >>"$LAZY_STATE/completed"
EOF
chmod +x "$stub_bin/nproc" "$stub_bin/vipsthumbnail"
# Also release the gate on failure, so background fixtures cannot outlive us.
trap 'touch "$lazy_state/gate"' EXIT
for cores in 1 2 8; do
lazy_state="$tmp/lazy-state-$cores"
mkdir -p "$lazy_state"
printf '0\n' >"$lazy_state/active"
printf '0\n' >"$lazy_state/peak"
expected_workers=1
(( cores < 4 )) || expected_workers=2
for run in 1 2; do
rows=$(PATH="$stub_bin:$PATH" XDG_CACHE_HOME="$tmp/lazy-cache-$cores" LAZY_STATE="$lazy_state" FAKE_CORES="$cores" \
timeout 10 "$ROOT/bin/omarchy-menu-images" --lazy-thumbnails --print-rows "$lazy_images")
(( $(wc -l <<<"$rows") == 40 )) || fail "lazy image menu returns all rows before conversion"
done
for attempt in {1..100}; do
(( $(<"$lazy_state/active") == expected_workers )) && break
sleep 0.02
done
(( $(<"$lazy_state/active") == expected_workers && $(<"$lazy_state/peak") == expected_workers )) ||
fail "lazy image menu bounds repeated refreshes to $expected_workers workers on $cores cores"
[[ ! -e $lazy_state/completed ]] || fail "lazy image menu does not wait for conversion"
[[ ! -e $lazy_state/priority-failed ]] || fail "lazy image menu reserves CPU and I/O priority for the UI"
pass "lazy image menu opens with at most $expected_workers workers on $cores cores"
touch "$lazy_state/gate"
for attempt in {1..500}; do
if [[ -f $lazy_state/completed ]] && (( $(wc -l <"$lazy_state/completed") == 40 )); then break; fi
sleep 0.02
done
(( $(wc -l <"$lazy_state/completed") == 40 && $(<"$lazy_state/peak") == expected_workers )) ||
fail "lazy image menu completes the queue after its parent and queue path are gone"
pass "lazy image menu workers finish every queued thumbnail after the caller exits"
done
trap 'rm -rf "$tmp"' EXIT