From 4e50960c54369509a9e9815826f0838b909e7717 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 17 May 2026 16:41:42 +0200 Subject: [PATCH] Faster theme and bg changes --- bin/omarchy-menu-images | 14 +++- bin/omarchy-shell-ipc-fast | 61 ++++++++++++++++ bin/omarchy-theme-bg-set | 4 + bin/omarchy-theme-set | 57 +++++++++++++-- bin/omarchy-theme-switcher | 27 +++++-- .../omarchy-shell/Commons/Color.qml | 7 +- .../plugins/background/Background.qml | 29 ++++++-- .../plugins/image-picker/ImagePicker.qml | 73 +++++++++++++++---- default/quickshell/omarchy-shell/shell.qml | 15 ++++ 9 files changed, 251 insertions(+), 36 deletions(-) create mode 100755 bin/omarchy-shell-ipc-fast diff --git a/bin/omarchy-menu-images b/bin/omarchy-menu-images index c337347b..4c109582 100755 --- a/bin/omarchy-menu-images +++ b/bin/omarchy-menu-images @@ -1,7 +1,7 @@ #!/bin/bash # omarchy:summary=Open a generic image selector menu -# omarchy:args=[--selected ] [--print-name] [--show-labels] [--filterable] [--lazy-thumbnails] [--cache-only] ... +# omarchy:args=[--selected ] [--print-name] [--show-labels] [--filterable] [--lazy-thumbnails] [--preload] [--cache-only] ... OMARCHY_PATH=${OMARCHY_PATH:-$HOME/.local/share/omarchy} @@ -11,11 +11,12 @@ show_labels=false filterable=false lazy_thumbnails=false prepare_only=false +preload=false cache_only=false image_dirs=() usage() { - echo "Usage: omarchy-menu-images [--selected ] [--print-name] [--show-labels] [--filterable] [--lazy-thumbnails] [--cache-only] ..." + echo "Usage: omarchy-menu-images [--selected ] [--print-name] [--show-labels] [--filterable] [--lazy-thumbnails] [--preload] [--cache-only] ..." } while [[ $# -gt 0 ]]; do @@ -49,6 +50,10 @@ while [[ $# -gt 0 ]]; do prepare_only=true shift ;; + --preload) + preload=true + shift + ;; --cache-only) cache_only=true shift @@ -225,6 +230,11 @@ fi # ImagePicker plugin Qt.atob()s on the other side. rows_b64=$(printf '%s' "$rows" | base64 -w 0) +if [[ $preload == true ]]; then + omarchy-shell-ipc image-selector preload "$rows_b64" "$selected_list_image" "$show_labels" "$filterable" >/dev/null || true + exit 0 +fi + send_builtin_ipc_request() { perl -MCwd=abs_path -MEncode=encode,decode -MSocket \ -e ' diff --git a/bin/omarchy-shell-ipc-fast b/bin/omarchy-shell-ipc-fast new file mode 100755 index 00000000..d2f017a7 --- /dev/null +++ b/bin/omarchy-shell-ipc-fast @@ -0,0 +1,61 @@ +#!/bin/bash + +# omarchy:summary=Send a fire-and-forget IPC call to a running omarchy-shell +# omarchy:hidden=true + +OMARCHY_PATH=${OMARCHY_PATH:-$HOME/.local/share/omarchy} +SHELL_QML="$OMARCHY_PATH/default/quickshell/omarchy-shell/shell.qml" + +if (( $# < 2 )); then + echo "Usage: omarchy-shell-ipc-fast [args...]" >&2 + exit 1 +fi + +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 $target = shift @ARGV; + my $function = shift @ARGV; + my @args = @ARGV; + my $runtime_dir = $ENV{"XDG_RUNTIME_DIR"} || "/run/user/$<"; + my $payload = chr(3) . qstring($target) . qstring($function) . pack("N", scalar @args) . join("", map { qstring($_) } @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; + ' "$SHELL_QML" "$@" diff --git a/bin/omarchy-theme-bg-set b/bin/omarchy-theme-bg-set index 6eb03b98..427262ce 100755 --- a/bin/omarchy-theme-bg-set +++ b/bin/omarchy-theme-bg-set @@ -19,3 +19,7 @@ fi # Create symlink to the new background ln -nsf "$BACKGROUND" "$CURRENT_BACKGROUND_LINK" + +# Update the live shell background immediately when it is running. The +# background plugin also polls this symlink, but IPC avoids the visible delay. +omarchy-shell-ipc-fast background set "$BACKGROUND" >/dev/null 2>&1 || true diff --git a/bin/omarchy-theme-set b/bin/omarchy-theme-set index 64cc1e6a..6648cc48 100755 --- a/bin/omarchy-theme-set +++ b/bin/omarchy-theme-set @@ -11,6 +11,7 @@ fi CURRENT_THEME_PATH="$HOME/.config/omarchy/current/theme" NEXT_THEME_PATH="$HOME/.config/omarchy/current/next-theme" +CURRENT_BACKGROUND_LINK="$HOME/.config/omarchy/current/background" USER_THEMES_PATH="$HOME/.config/omarchy/themes" OMARCHY_THEMES_PATH="$OMARCHY_PATH/themes" @@ -28,6 +29,41 @@ run_parallel() { done } +set_theme_background() { + local backgrounds=() + local current_background index new_background next_index + + mapfile -d '' -t backgrounds < <( + find -L "$HOME/.config/omarchy/backgrounds/$THEME_NAME/" "$CURRENT_THEME_PATH/backgrounds/" -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 + ) + + if (( ${#backgrounds[@]} == 0 )); then + omarchy-notification-send "No background was found for theme" -t 2000 + return + fi + + current_background=$(readlink "$CURRENT_BACKGROUND_LINK" 2>/dev/null || true) + index=-1 + for i in "${!backgrounds[@]}"; do + if [[ ${backgrounds[$i]} == $current_background ]]; then + index=$i + break + fi + done + + if (( index == -1 )); then + new_background="${backgrounds[0]}" + else + next_index=$(((index + 1) % ${#backgrounds[@]})) + new_background="${backgrounds[$next_index]}" + fi + + ln -nsf "$new_background" "$CURRENT_BACKGROUND_LINK" + omarchy-shell-ipc-fast background setInstant "$new_background" >/dev/null 2>&1 || true +} + THEME_NAME=$(echo "$1" | sed -E 's/<[^>]+>//g' | tr '[:upper:]' '[:lower:]' | tr ' ' '-') if [[ ! -d $OMARCHY_THEMES_PATH/$THEME_NAME ]] && [[ ! -d $USER_THEMES_PATH/$THEME_NAME ]]; then @@ -58,8 +94,17 @@ mv "$NEXT_THEME_PATH" "$CURRENT_THEME_PATH" # Store theme name for reference echo "$THEME_NAME" >"$HOME/.config/omarchy/current/theme.name" +# Make the running shell pick up the new palette immediately instead of waiting +# for file-watch debounce while the rest of the theme hooks run. +colors_payload=$([[ -f $CURRENT_THEME_PATH/colors.toml ]] && base64 -w 0 "$CURRENT_THEME_PATH/colors.toml") +shell_payload=$([[ -f $CURRENT_THEME_PATH/shell.toml ]] && base64 -w 0 "$CURRENT_THEME_PATH/shell.toml") +omarchy-shell-ipc-fast shell applyTheme "$colors_payload" "$shell_payload" >/dev/null 2>&1 || true + +if [[ $OMARCHY_THEME_SKIP_BACKGROUND != "1" ]]; then + set_theme_background +fi + post_theme_commands=( - omarchy-restart-shell omarchy-restart-terminal omarchy-restart-hyprctl omarchy-restart-btop @@ -73,15 +118,13 @@ post_theme_commands=( omarchy-theme-set-keyboard ) -# Change background with theme while restarting/retheming independent components. -if [[ $OMARCHY_THEME_SKIP_BACKGROUND != "1" ]]; then - post_theme_commands+=(omarchy-theme-bg-next) -fi - run_parallel "${post_theme_commands[@]}" # Call hook on theme set omarchy-hook theme-set "$THEME_NAME" >/dev/null -# Warm the background selector cache after the theme is applied, off the critical path. +# Warm selector caches after the theme is applied. The shell hot-reloads theme +# colors/backgrounds, so keep the running instance alive and preload the picker +# rows/selection to avoid first-open carousel settling after a theme change. +omarchy-theme-switcher --preload >/dev/null 2>&1 omarchy-theme-bg-cache >/dev/null 2>&1 & diff --git a/bin/omarchy-theme-switcher b/bin/omarchy-theme-switcher index f1274cf0..e891f4c6 100755 --- a/bin/omarchy-theme-switcher +++ b/bin/omarchy-theme-switcher @@ -3,6 +3,13 @@ # omarchy:summary=Open the Omarchy theme switcher OMARCHY_PATH=${OMARCHY_PATH:-$HOME/.local/share/omarchy} +preload=false + +if [[ $1 == "--preload" ]]; then + preload=true + shift +fi + USER_THEMES_PATH="$HOME/.config/omarchy/themes" OMARCHY_THEMES_PATH="$OMARCHY_PATH/themes" CACHE_PATH="${XDG_CACHE_HOME:-$HOME/.cache}/omarchy/theme-selector" @@ -104,10 +111,16 @@ for extension in png jpg jpeg webp gif bmp; do break fi done -exec omarchy-menu-images \ - --print-name \ - --show-labels \ - --filterable \ - --lazy-thumbnails \ - --selected "$selected_preview" \ - "$preview_dir" +menu_args=( + --print-name + --show-labels + --filterable + --lazy-thumbnails + --selected "$selected_preview" +) + +if [[ $preload == true ]]; then + menu_args+=(--preload) +fi + +exec omarchy-menu-images "${menu_args[@]}" "$preview_dir" diff --git a/default/quickshell/omarchy-shell/Commons/Color.qml b/default/quickshell/omarchy-shell/Commons/Color.qml index a812eef6..6091603c 100644 --- a/default/quickshell/omarchy-shell/Commons/Color.qml +++ b/default/quickshell/omarchy-shell/Commons/Color.qml @@ -170,6 +170,11 @@ QtObject { shellValues = parsed } + function reloadTheme() { + colorsFile.reload() + shellFile.reload() + } + // `omarchy-theme-set` recreates the theme/ directory via rm+mv, which kills // the inotify watch on colors.toml. Use theme.name (overwritten in place) as // a tripwire that forces a fresh reload after each swap. @@ -194,6 +199,6 @@ QtObject { path: Quickshell.env("HOME") + "/.config/omarchy/current/theme.name" watchChanges: true printErrors: false - onFileChanged: { colorsFile.reload(); shellFile.reload() } + onFileChanged: root.reloadTheme() } } diff --git a/default/quickshell/omarchy-shell/plugins/background/Background.qml b/default/quickshell/omarchy-shell/plugins/background/Background.qml index c3c1ad7a..6d54dbf2 100644 --- a/default/quickshell/omarchy-shell/plugins/background/Background.qml +++ b/default/quickshell/omarchy-shell/plugins/background/Background.qml @@ -30,20 +30,23 @@ Item { if (!readlinkProc.running) readlinkProc.running = true } - function setBackground(path) { + function setBackground(path, instant) { path = String(path || "").trim() if (!path || path === currentBackground) return currentBackground = path backgroundVersion += 1 - if (!displayedBackground) { + revealAnimation.stop() + finishingTransition = false + + if (instant || !displayedBackground) { + oldBackground = "" + incomingBackground = "" displayedBackground = path revealProgress = 1 return } - revealAnimation.stop() - finishingTransition = false oldBackground = displayedBackground incomingBackground = path revealProgress = 0 @@ -73,7 +76,23 @@ Item { id: readlinkProc command: ["readlink", "-f", root.currentBackgroundLink] stdout: StdioCollector { - onStreamFinished: root.setBackground(String(text || "").trim()) + onStreamFinished: root.setBackground(String(text || "").trim(), false) + } + } + + IpcHandler { + target: "background" + + function refresh(): void { + root.refreshBackground() + } + + function set(path: string): void { + root.setBackground(path, false) + } + + function setInstant(path: string): void { + root.setBackground(path, true) } } diff --git a/default/quickshell/omarchy-shell/plugins/image-picker/ImagePicker.qml b/default/quickshell/omarchy-shell/plugins/image-picker/ImagePicker.qml index 27d10c83..56a5c790 100644 --- a/default/quickshell/omarchy-shell/plugins/image-picker/ImagePicker.qml +++ b/default/quickshell/omarchy-shell/plugins/image-picker/ImagePicker.qml @@ -29,6 +29,7 @@ Item { property bool opened: false property bool showLabels: false property bool filterable: false + property bool layoutSettled: false property bool requestActive: false property int requestSerial: 0 property int applySerial: 0 @@ -48,7 +49,7 @@ Item { property int skewOffset: 28 property int bottomChromeHeight: showLabels ? (filterable ? 104 : 74) : (filterable ? 60 : 30) - onOpenedChanged: if (opened && imagesLoaded) focusPicker() + onOpenedChanged: if (!opened) layoutSettled = false function fileUrl(path) { return "file://" + path.split("/").map(encodeURIComponent).join("/") @@ -64,10 +65,19 @@ Item { } function focusPicker() { - if (root.opened && root.imagesLoaded) + if (root.opened && root.imagesLoaded && root.layoutSettled) carousel.forceActiveFocus() } + function revealWhenSettled(serial) { + Qt.callLater(function() { + if (serial === root.requestSerial && root.opened && root.imagesLoaded && root.imageArray.length > 0) { + root.layoutSettled = true + root.focusPicker() + } + }) + } + // Decode a base64-encoded UTF-8 string sent via IPC. Used for fields that // would otherwise carry embedded newlines or tabs (image rows, raw colors // JSON) which bash IPC arguments can't reliably round-trip. @@ -237,7 +247,7 @@ Item { root.opened = false } - function loadRows(rows) { + function loadRows(rows, reveal) { var newImages = [] var seen = {} var paths = rows.split("\n") @@ -259,11 +269,14 @@ Item { } root.loadedImageRows = rows + root.selectedIndex = root.indexForSelectedImage(newImages) root.imageArray = newImages - root.select(root.selectedImageIndex(), true) root.imagesLoaded = true - root.opened = true - root.focusPicker() + + if (reveal !== false) { + root.opened = true + root.revealWhenSettled(root.requestSerial) + } } function openSelector(nextImageDirs, nextImageRows, nextSelectedImage, nextSelectionFile, nextDoneFile, nextShowLabels, nextFilterable) { @@ -281,24 +294,26 @@ Item { showLabels = nextShowLabels === true || nextShowLabels === "true" filterable = nextFilterable === true || nextFilterable === "true" filterText = "" + layoutSettled = false if (imageRows && imageRows === loadedImageRows && imageArray.length > 0) { root.select(root.selectedImageIndex(), true) imagesLoaded = true opened = true - root.focusPicker() + root.revealWhenSettled(requestSerial) return } if (imageRows) { var rowsToLoad = imageRows var rowsSerial = requestSerial + imageArray = [] + selectedIndex = 0 imagesLoaded = true opened = true - root.focusPicker() Qt.callLater(function() { if (rowsSerial === root.requestSerial) - root.loadRows(rowsToLoad) + root.loadRows(rowsToLoad, true) }) return } @@ -313,15 +328,19 @@ Item { property var imageArray: [] - function selectedImageIndex() { - for (var i = 0; i < imageArray.length; i++) { - if (imageArray[i].filePath === selectedImage) + function indexForSelectedImage(images) { + for (var i = 0; i < images.length; i++) { + if (images[i].filePath === selectedImage) return i } return 0 } + function selectedImageIndex() { + return indexForSelectedImage(imageArray) + } + Process { id: loadImagesProc property int requestSerial: 0 @@ -330,7 +349,7 @@ Item { waitForEnd: true onStreamFinished: { if (loadImagesProc.requestSerial === root.requestSerial) - root.loadRows(String(text || "")) + root.loadRows(String(text || ""), true) } } } @@ -359,6 +378,23 @@ Item { cancel() } + function preloadRows(nextImageRows, nextSelectedImage, nextShowLabels, nextFilterable) { + requestSerial += 1 + imageRows = nextImageRows + selectedImage = nextSelectedImage + showLabels = nextShowLabels === true || nextShowLabels === "true" + filterable = nextFilterable === true || nextFilterable === "true" + filterText = "" + layoutSettled = false + + if (imageRows && imageRows === loadedImageRows && imageArray.length > 0) { + selectedIndex = selectedImageIndex() + imagesLoaded = true + } else if (imageRows) { + loadRows(imageRows, false) + } + } + // IPC surface. All arguments are strings (Quickshell IPC marshalling). // imageRows can contain newlines/tabs, so the CLI caller base64-encodes // it; everything else passes through verbatim. The two boolean-like @@ -379,6 +415,15 @@ Item { return "ok" } + function preload(imageRowsB64: string, + selectedImage: string, + showLabels: string, + filterable: string): string { + var rows = root.decodeBase64(imageRowsB64) + root.preloadRows(rows, selectedImage, showLabels, filterable) + return "ok" + } + function cancel(doneFile: string): void { root.closeSelector(doneFile || "") } @@ -426,7 +471,7 @@ Item { Item { id: card - visible: root.opened && root.imagesLoaded + visible: root.opened && root.imagesLoaded && root.layoutSettled && root.imageArray.length > 0 width: Math.min(parent.width - 80, root.expandedWidth + 13 * (root.sliceWidth + root.sliceSpacing) + 40) height: root.expandedHeight + 30 + root.bottomChromeHeight anchors.centerIn: parent diff --git a/default/quickshell/omarchy-shell/shell.qml b/default/quickshell/omarchy-shell/shell.qml index ec542269..922912b6 100644 --- a/default/quickshell/omarchy-shell/shell.qml +++ b/default/quickshell/omarchy-shell/shell.qml @@ -787,6 +787,21 @@ ShellRoot { return "ok" } + function applyTheme(colorsB64: string, shellB64: string): string { + var colorsRaw = "" + var shellRaw = "" + try { colorsRaw = Qt.atob(String(colorsB64 || "")) } catch (e) { colorsRaw = "" } + try { shellRaw = Qt.atob(String(shellB64 || "")) } catch (e2) { shellRaw = "" } + NoctaliaCommons.Color.loadColors(colorsRaw) + NoctaliaCommons.Color.loadShell(shellRaw) + return "ok" + } + + function reloadTheme(): string { + NoctaliaCommons.Color.reloadTheme() + return "ok" + } + function rescanPlugins(): void { shell.pluginRegistry.rescan() }