From 523803e99ebac4b14d0b4ebb27e26e8b34b541a1 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 17 May 2026 11:40:03 +0200 Subject: [PATCH] Performance improve pickers --- bin/omarchy-menu-images | 90 ++++++++++++--- bin/omarchy-theme-switcher | 33 ++++-- .../plugins/image-picker/ImagePicker.qml | 103 ++++++++++-------- 3 files changed, 160 insertions(+), 66 deletions(-) diff --git a/bin/omarchy-menu-images b/bin/omarchy-menu-images index 11862d2c..c337347b 100755 --- a/bin/omarchy-menu-images +++ b/bin/omarchy-menu-images @@ -106,22 +106,34 @@ mkdir -p "$cache_dir" cache_key=$(printf '%s' "$image_dirs_env" | md5sum | cut -d ' ' -f 1) rows_cache_file="$cache_dir/$cache_key.rows" rows_signature_file="$cache_dir/$cache_key.signature" +rows_fast_signature_file="$cache_dir/$cache_key.fast-signature" rows_signature="v2"$'\n' +rows_fast_signature="v1"$'\n' rows_cacheable=true +rows_cache_hit=false image_files=() for dir in "${image_dirs[@]}"; do - if [[ -d $dir ]]; then - rows_signature+="$dir:$(stat -Lc '%Y' "$dir")"$'\n' - - while IFS= read -r -d '' image; do - image_files+=("$image") - image_signature=$(stat -Lc '%s:%Y' "$image") || continue - rows_signature+="$image:$image_signature"$'\n' - done < <(find -L "$dir" -maxdepth 1 -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \) -print0 2>/dev/null | sort -z) - fi + [[ -d $dir ]] && rows_fast_signature+="$dir:$(stat -Lc '%Y' "$dir")"$'\n' done +if [[ -f $rows_cache_file && -f $rows_fast_signature_file ]] && cmp -s "$rows_fast_signature_file" <(printf '%s' "$rows_fast_signature"); then + rows=$(<"$rows_cache_file") + rows_cache_hit=true +else + for dir in "${image_dirs[@]}"; do + if [[ -d $dir ]]; then + rows_signature+="$dir:$(stat -Lc '%Y' "$dir")"$'\n' + + while IFS= read -r -d '' image; do + image_files+=("$image") + image_signature=$(stat -Lc '%s:%Y' "$image") || continue + rows_signature+="$image:$image_signature"$'\n' + done < <(find -L "$dir" -maxdepth 1 -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \) -print0 2>/dev/null | sort -z) + fi + done +fi + generate_thumbnail() { local image="$1" local thumbnail="$2" @@ -177,9 +189,10 @@ thumbnail_for() { [[ -f $thumbnail ]] && printf '%s' "$thumbnail" } -if [[ -f $rows_cache_file && -f $rows_signature_file ]] && cmp -s "$rows_signature_file" <(printf '%s' "$rows_signature"); then +if [[ $rows_cache_hit != true && -f $rows_cache_file && -f $rows_signature_file ]] && cmp -s "$rows_signature_file" <(printf '%s' "$rows_signature"); then rows=$(<"$rows_cache_file") -else + printf '%s' "$rows_fast_signature" >"$rows_fast_signature_file" +elif [[ $rows_cache_hit != true ]]; then for image in "${image_files[@]}"; do thumbnail=$(thumbnail_for "$image") [[ -n $thumbnail ]] || continue @@ -197,8 +210,9 @@ else if [[ $rows_cacheable == true ]]; then printf '%s' "$rows" >"$rows_cache_file" printf '%s' "$rows_signature" >"$rows_signature_file" + printf '%s' "$rows_fast_signature" >"$rows_fast_signature_file" else - rm -f "$rows_cache_file" "$rows_signature_file" + rm -f "$rows_cache_file" "$rows_signature_file" "$rows_fast_signature_file" fi fi @@ -211,7 +225,57 @@ fi # ImagePicker plugin Qt.atob()s on the other side. rows_b64=$(printf '%s' "$rows" | base64 -w 0) -if ! omarchy-shell-ipc image-selector open \ +send_builtin_ipc_request() { + perl -MCwd=abs_path -MEncode=encode,decode -MSocket \ + -e ' + sub read_qstring { + my ($data, $offset) = @_; + return ("", $offset) if $offset + 4 > length($data); + my $length = unpack("N", substr($data, $offset, 4)); + $offset += 4; + return ("", $offset) if $length == 0xffffffff || $offset + $length > length($data); + return (decode("UTF-16BE", substr($data, $offset, $length)), $offset + $length); + } + + sub qstring { + my $encoded = encode("UTF-16BE", $_[0] // ""); + return pack("N", length($encoded)) . $encoded; + } + + my $shell_qml = abs_path(shift @ARGV); + my @open_args = @ARGV; + my $runtime_dir = $ENV{"XDG_RUNTIME_DIR"} || "/run/user/$<"; + my $payload = chr(3) . qstring("image-selector") . qstring("open") . pack("N", scalar @open_args) . join("", map { qstring($_) } @open_args); + my @candidates; + + for my $lock_path (glob("$runtime_dir/quickshell/by-id/*/instance.lock")) { + my $data; + next unless open(my $lock, "<:raw", $lock_path); + { local $/; $data = <$lock>; } + close($lock); + my (undef, $offset) = read_qstring($data, 0); + my ($path) = read_qstring($data, $offset); + next unless $path && abs_path($path) eq $shell_qml; + (my $socket_path = $lock_path) =~ s{/instance\.lock$}{/ipc.sock}; + push @candidates, [(stat($lock_path))[9] || 0, $socket_path]; + } + + for my $candidate (sort { $b->[0] <=> $a->[0] } @candidates) { + socket(my $client, AF_UNIX, SOCK_STREAM, 0) || next; + if (connect($client, sockaddr_un($candidate->[1]))) { + syswrite($client, $payload); + close($client); + exit 0; + } + close($client); + } + + exit 1; + ' "$OMARCHY_PATH/default/quickshell/omarchy-shell/shell.qml" \ + "" "$rows_b64" "$selected_list_image" "$selection_file" "$done_file" "$show_labels" "$filterable" +} + +if ! send_builtin_ipc_request && ! omarchy-shell-ipc image-selector open \ "" \ "$rows_b64" \ "$selected_list_image" \ diff --git a/bin/omarchy-theme-switcher b/bin/omarchy-theme-switcher index 4a1e55e5..f1274cf0 100755 --- a/bin/omarchy-theme-switcher +++ b/bin/omarchy-theme-switcher @@ -8,6 +8,7 @@ OMARCHY_THEMES_PATH="$OMARCHY_PATH/themes" CACHE_PATH="${XDG_CACHE_HOME:-$HOME/.cache}/omarchy/theme-selector" preview_dir="$CACHE_PATH/previews" signature_file="$CACHE_PATH/signature" +fast_signature_file="$CACHE_PATH/fast-signature" mkdir -p "$preview_dir" @@ -41,23 +42,36 @@ add_theme_preview() { ln -s "$preview" "$preview_dir/$theme_name.$extension" } -theme_signature="" +fast_signature="v1"$'\n' for theme_dir in "$USER_THEMES_PATH" "$OMARCHY_THEMES_PATH"; do if [[ -d $theme_dir ]]; then - theme_signature+="$theme_dir:$(stat -Lc '%Y' "$theme_dir")"$'\n' + fast_signature+="$theme_dir:$(stat -Lc '%Y' "$theme_dir")"$'\n' while IFS= read -r -d '' theme_path; do - preview=$(find_preview "$theme_path") - theme_signature+="$theme_path:$(stat -Lc '%Y' "$theme_path")"$'\n' - - if [[ -n $preview ]]; then - theme_signature+="$preview:$(stat -Lc '%s:%Y' "$preview")"$'\n' - fi + fast_signature+="$theme_path:$(stat -Lc '%Y' "$theme_path")"$'\n' done < <(find -L "$theme_dir" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) -print0 2>/dev/null | sort -z) fi done -if [[ ! -f $signature_file ]] || ! cmp -s "$signature_file" <(printf '%s' "$theme_signature"); then +if [[ ! -f $fast_signature_file ]] || ! cmp -s "$fast_signature_file" <(printf '%s' "$fast_signature"); then + theme_signature="" + for theme_dir in "$USER_THEMES_PATH" "$OMARCHY_THEMES_PATH"; do + if [[ -d $theme_dir ]]; then + theme_signature+="$theme_dir:$(stat -Lc '%Y' "$theme_dir")"$'\n' + + while IFS= read -r -d '' theme_path; do + preview=$(find_preview "$theme_path") + theme_signature+="$theme_path:$(stat -Lc '%Y' "$theme_path")"$'\n' + + if [[ -n $preview ]]; then + theme_signature+="$preview:$(stat -Lc '%s:%Y' "$preview")"$'\n' + fi + done < <(find -L "$theme_dir" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) -print0 2>/dev/null | sort -z) + fi + done +fi + +if [[ ! -f $fast_signature_file ]] || ! cmp -s "$fast_signature_file" <(printf '%s' "$fast_signature"); then rm -rf "$preview_dir" mkdir -p "$preview_dir" @@ -79,6 +93,7 @@ if [[ ! -f $signature_file ]] || ! cmp -s "$signature_file" <(printf '%s' "$them done < <(find -L "$OMARCHY_THEMES_PATH" -mindepth 1 -maxdepth 1 -type d -print 2>/dev/null | sort) printf '%s' "$theme_signature" >"$signature_file" + printf '%s' "$fast_signature" >"$fast_signature_file" fi current_theme=$(cat "$HOME/.config/omarchy/current/theme.name" 2>/dev/null) diff --git a/default/quickshell/omarchy-shell/plugins/image-picker/ImagePicker.qml b/default/quickshell/omarchy-shell/plugins/image-picker/ImagePicker.qml index fe7a04ad..0723ba72 100644 --- a/default/quickshell/omarchy-shell/plugins/image-picker/ImagePicker.qml +++ b/default/quickshell/omarchy-shell/plugins/image-picker/ImagePicker.qml @@ -21,6 +21,7 @@ Item { property string imageDirs: Quickshell.env("OMARCHY_IMAGE_SELECTOR_DIRS") || Quickshell.env("OMARCHY_IMAGE_SELECTOR_DIR") || Quickshell.env("OMARCHY_STOCK_BACKGROUNDS_DIR") || (Quickshell.env("HOME") + "/.config/omarchy/current/theme/backgrounds") property string imageRows: "" + property string loadedImageRows: "" property string selectionFile: Quickshell.env("OMARCHY_IMAGE_SELECTOR_SELECTION_FILE") || Quickshell.env("OMARCHY_BACKGROUND_SELECTION_FILE") property string selectedImage: Quickshell.env("OMARCHY_IMAGE_SELECTOR_SELECTED") property int selectedIndex: 0 @@ -47,6 +48,8 @@ Item { property int skewOffset: 28 property int bottomChromeHeight: showLabels ? (filterable ? 104 : 74) : (filterable ? 60 : 30) + onOpenedChanged: if (opened && imagesLoaded) focusPicker() + function fileUrl(path) { return "file://" + path.split("/").map(encodeURIComponent).join("/") } @@ -61,8 +64,8 @@ Item { } function focusPicker() { - if (pickerWindowLoader.item && typeof pickerWindowLoader.item.focusCarousel === "function") - pickerWindowLoader.item.focusCarousel() + if (root.opened && root.imagesLoaded) + carousel.forceActiveFocus() } // Decode a base64-encoded UTF-8 string sent via IPC. Used for fields that @@ -255,6 +258,7 @@ Item { }) } + root.loadedImageRows = rows root.imageArray = newImages root.select(root.selectedImageIndex(), true) root.imagesLoaded = true @@ -277,16 +281,34 @@ Item { showLabels = nextShowLabels === true || nextShowLabels === "true" filterable = nextFilterable === true || nextFilterable === "true" filterText = "" + + if (imageRows && imageRows === loadedImageRows && imageArray.length > 0) { + root.select(root.selectedImageIndex(), true) + imagesLoaded = true + opened = true + root.focusPicker() + return + } + + if (imageRows) { + var rowsToLoad = imageRows + var rowsSerial = requestSerial + imagesLoaded = true + opened = true + root.focusPicker() + Qt.callLater(function() { + if (rowsSerial === root.requestSerial) + root.loadRows(rowsToLoad) + }) + return + } + imageArray = [] selectedIndex = 0 imagesLoaded = false opened = false - if (imageRows) { - loadRows(imageRows) - } else { - loadImagesProc.requestSerial = requestSerial - loadImagesProc.running = true - } + loadImagesProc.requestSerial = requestSerial + loadImagesProc.running = true } property var imageArray: [] @@ -379,38 +401,32 @@ Item { onExited: root.releaseNextDoneFile() } - LazyLoader { - id: pickerWindowLoader - active: root.opened && root.imagesLoaded - onItemChanged: root.focusPicker() + PanelWindow { + id: panel - PanelWindow { - id: panel + visible: true + anchors { top: true; bottom: true; left: true; right: true } + color: "transparent" + WlrLayershell.namespace: "omarchy-image-selector" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: root.opened && root.imagesLoaded ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + exclusionMode: ExclusionMode.Ignore - function focusCarousel() { - carousel.forceActiveFocus() - } + Rectangle { + anchors.fill: parent + visible: root.opened && root.imagesLoaded + color: root.withAlpha(root.background, 0.5) + } - visible: true - anchors { top: true; bottom: true; left: true; right: true } - color: "transparent" - WlrLayershell.namespace: "omarchy-image-selector" - WlrLayershell.layer: WlrLayer.Overlay - WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive - exclusionMode: ExclusionMode.Ignore + MouseArea { + anchors.fill: parent + enabled: root.opened && root.imagesLoaded + onClicked: root.cancel() + } - Rectangle { - anchors.fill: parent - color: root.withAlpha(root.background, 0.5) - } - - MouseArea { - anchors.fill: parent - onClicked: root.cancel() - } - - Item { - id: card + Item { + id: card + visible: root.opened && root.imagesLoaded width: Math.min(parent.width - 80, root.expandedWidth + 13 * (root.sliceWidth + root.sliceSpacing) + 40) height: root.expandedHeight + 30 + root.bottomChromeHeight anchors.centerIn: parent @@ -477,6 +493,8 @@ Item { 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 visible: nearby x: selected ? carousel.previewX : (relativeIndex < 0 ? carousel.previewX + relativeIndex * carousel.itemStep : carousel.previewX + root.expandedWidth + root.sliceSpacing + (relativeIndex - 1) * carousel.itemStep) @@ -527,14 +545,12 @@ Item { Image { id: image anchors.fill: parent - // Keep a stable source while delegates move in and out of the - // nearby window. Clearing/reassigning the source on every - // selection change makes Qt tear down and reload textures, - // which shows up as flicker in both the theme and background - // selectors. - source: item.thumbnailPath ? root.fileUrl(item.thumbnailPath) : "" + // 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 ? root.fileUrl(item.thumbnailPath) : "" fillMode: Image.PreserveAspectCrop - asynchronous: false + asynchronous: true cache: true smooth: true } @@ -602,7 +618,6 @@ Item { horizontalAlignment: Text.AlignHCenter elide: Text.ElideRight } - } } } }