diff --git a/bin/omarchy-theme-set b/bin/omarchy-theme-set index 86a04e1e..f2cd0410 100755 --- a/bin/omarchy-theme-set +++ b/bin/omarchy-theme-set @@ -158,6 +158,7 @@ choose_staged_theme_background() { local next_theme_backgrounds="$NEXT_THEME_PATH/backgrounds" choose_theme_background "$NEXT_THEME_PATH" || return 1 + STAGED_THEME_BACKGROUND="$CHOSEN_THEME_BACKGROUND" if [[ ${CHOSEN_THEME_BACKGROUND%/*} == $next_theme_backgrounds ]]; then CHOSEN_THEME_BACKGROUND="$CURRENT_THEME_PATH/backgrounds/${CHOSEN_THEME_BACKGROUND##*/}" @@ -182,8 +183,13 @@ set_theme_background() { new_background="$CHOSEN_THEME_BACKGROUND" new_background_snapshot="" - if [[ $BACKGROUND_TRANSITION_SNAPSHOTS == "true" ]]; then - new_background_snapshot=$(snapshot_background_path "$new_background" "next") + if [[ -n $PREPARED_BACKGROUND_SNAPSHOT && $PREPARED_BACKGROUND == "$new_background" ]]; then + new_background_snapshot="$PREPARED_BACKGROUND_SNAPSHOT" + else + [[ -z $PREPARED_BACKGROUND_SNAPSHOT ]] || rm -f "$PREPARED_BACKGROUND_SNAPSHOT" + if [[ $BACKGROUND_TRANSITION_SNAPSHOTS == "true" ]]; then + new_background_snapshot=$(snapshot_background_path "$new_background" "next") + fi fi if [[ -f $OLD_BACKGROUND_SNAPSHOT && -f $new_background_snapshot ]]; then @@ -353,11 +359,11 @@ if [[ ! -f $NEXT_THEME_PATH/colors.toml && -f $NEXT_THEME_PATH/alacritty.toml ]] omarchy-theme-colors-from-alacritty "$NEXT_THEME_PATH" fi -# Generate dynamic configs -omarchy-theme-set-templates - CHOSEN_THEME_BACKGROUND="" +STAGED_THEME_BACKGROUND="" OLD_BACKGROUND_SNAPSHOT="" +PREPARED_BACKGROUND="" +PREPARED_BACKGROUND_SNAPSHOT="" BACKGROUND_TRANSITION_SNAPSHOTS=true if [[ $THEME_HEADLESS != "1" && $OMARCHY_THEME_SKIP_BACKGROUND != "1" ]]; then # Resolve the staged choice while the old theme still exists. Video changes @@ -365,12 +371,24 @@ if [[ $THEME_HEADLESS != "1" && $OMARCHY_THEME_SKIP_BACKGROUND != "1" ]]; then if choose_staged_theme_background; then if background_transition_uses_snapshots "$CHOSEN_THEME_BACKGROUND"; then OLD_BACKGROUND_SNAPSHOT=$(snapshot_current_background) + + # Hand the shell the next background now, so it decodes while the + # templates render and the theme swaps in. The staged file is the same + # inode the swap moves into place, so its snapshot is the final image. + PREPARED_BACKGROUND="$CHOSEN_THEME_BACKGROUND" + PREPARED_BACKGROUND_SNAPSHOT=$(snapshot_background_path "$STAGED_THEME_BACKGROUND" "next") + if [[ -n $PREPARED_BACKGROUND_SNAPSHOT ]]; then + shell_ipc background prepare "$PREPARED_BACKGROUND_SNAPSHOT" 9>&- & + fi else BACKGROUND_TRANSITION_SNAPSHOTS=false fi fi fi +# Generate dynamic configs +omarchy-theme-set-templates + # Swap next theme in as current rm -rf "$CURRENT_THEME_PATH" mv "$NEXT_THEME_PATH" "$CURRENT_THEME_PATH" diff --git a/shell/Ui/BackgroundMedia.qml b/shell/Ui/BackgroundMedia.qml index aabde8d1..6ca02273 100644 --- a/shell/Ui/BackgroundMedia.qml +++ b/shell/Ui/BackgroundMedia.qml @@ -8,6 +8,11 @@ Item { property string path: "" property int version: 0 + // A caller that knows the size its screen needs, in physical pixels, sets + // constrainDecode and decodeSize. The image then waits for that size rather + // than decoding at the file's native size first. + property bool constrainDecode: false + property size decodeSize: Qt.size(0, 0) readonly property var current: imageLoader.item readonly property bool ready: current ? current.ready : false readonly property bool video: Util.isVideoPath(path) @@ -27,12 +32,12 @@ Item { Image { readonly property bool ready: status === Image.Ready - source: root.imageUrl + source: !root.constrainDecode || root.decodeSize.width > 0 ? root.imageUrl : "" fillMode: Image.PreserveAspectCrop asynchronous: true cache: root.version === 0 - sourceSize.width: root.version > 0 ? width : 0 - sourceSize.height: root.version > 0 ? height : 0 + sourceSize.width: root.constrainDecode ? root.decodeSize.width : (root.version > 0 ? width : 0) + sourceSize.height: root.constrainDecode ? root.decodeSize.height : (root.version > 0 ? height : 0) } } } diff --git a/shell/plugins/background/Background.qml b/shell/plugins/background/Background.qml index 7a02c686..958eb90b 100644 --- a/shell/plugins/background/Background.qml +++ b/shell/plugins/background/Background.qml @@ -19,6 +19,22 @@ Item { property string displayedBackground: "" property string incomingBackground: "" property string oldBackground: "" + // A theme switch names its next background before it has staged the rest of + // the theme, so the incoming frame can decode while that work runs. A large + // WebP takes ~130ms to decode at any sourceSize, which the reveal would + // otherwise wait out after the transition arrives. + property string preparedBackground: "" + // The prepare and transition calls travel as separate IPC clients, so a + // prepare can land after its transition. The path it names then must not be + // decoded again. + property string lastTransitionPath: "" + // Native pixel size per wallpaper path, read from the file header before + // the image loads. Decoding at screen size only saves memory for wallpapers + // at least as large as the screen: with PreserveAspectCrop Qt scales the + // decode up to cover sourceSize, so a smaller wallpaper would cost the + // screen's worth of pixels instead of its own. + property var nativeSizes: ({}) + property var sizeQueue: [] property bool finishingTransition: false property int backgroundVersion: 0 property int revealStartedVersion: -1 @@ -48,6 +64,13 @@ Item { finalPath = String(finalPath || path).trim() fromPath = String(fromPath || "").trim() if (!path || (!force && finalPath === currentBackground)) return + if (path !== preparedBackground) preparedBackground = "" + preparedBackgroundTimer.stop() + lastTransitionPath = path + // The incoming frame gates the reveal, so its size is read first. + requestNativeSize(path) + requestNativeSize(fromPath || displayedBackground) + requestNativeSize(finalPath) currentBackground = finalPath backgroundVersion += 1 revealStartedVersion = -1 @@ -60,6 +83,7 @@ Item { if (instant || !displayedBackground || isVideo(path) || isVideo(displayedBackground)) { oldBackground = "" incomingBackground = "" + preparedBackground = "" displayedBackground = finalPath revealProgress = 1 return @@ -107,6 +131,39 @@ Item { revealAnimation.restart() } + function prepareBackground(path) { + path = String(path || "").trim() + // Only a still that is not already on screen is worth decoding ahead. + if (!path || isVideo(path) || path === lastTransitionPath || path === displayedBackground) return + requestNativeSize(path) + preparedBackground = path + preparedBackgroundTimer.restart() + } + + function requestNativeSize(path) { + if (!path || isVideo(path) || nativeSizes[path] !== undefined || sizeQueue.indexOf(path) !== -1) return + sizeQueue = sizeQueue.concat([path]) + probeNextSize() + } + + function probeNextSize() { + if (sizeProbe.running || sizeQueue.length === 0) return + sizeProbe.path = sizeQueue[0] + sizeProbe.command = ["magick", "identify", "-ping", "-format", "%w %h", sizeProbe.path] + sizeProbe.running = true + } + + // Each theme switch names fresh snapshot paths, so keep only the sizes of + // the wallpapers still in play. + function pruneNativeSizes() { + var kept = {} + var paths = [displayedBackground, incomingBackground, oldBackground, preparedBackground] + for (var i = 0; i < paths.length; i++) { + if (paths[i] && nativeSizes[paths[i]] !== undefined) kept[paths[i]] = nativeSizes[paths[i]] + } + nativeSizes = kept + } + function openSelector() { if (!bgSwitchProc.running) bgSwitchProc.running = true } @@ -126,6 +183,23 @@ Item { onExited: root.refreshBackground() } + Process { + id: sizeProbe + property string path: "" + stdout: StdioCollector { id: sizeProbeOut } + onExited: function(exitCode) { + var parts = String(sizeProbeOut.text || "").trim().split(/\s+/) + var width = exitCode === 0 ? parseInt(parts[0], 10) : 0 + var height = exitCode === 0 ? parseInt(parts[1], 10) : 0 + var known = Object.assign({}, root.nativeSizes) + // An unreadable header records 0x0, which decodes at screen size. + known[path] = { width: width > 0 ? width : 0, height: height > 0 ? height : 0 } + root.nativeSizes = known + root.sizeQueue = root.sizeQueue.filter(function(queued) { return queued !== sizeProbe.path }) + root.probeNextSize() + } + } + Process { id: readlinkProc command: ["readlink", "-f", root.currentBackgroundLink] @@ -156,6 +230,19 @@ Item { function themeTransition(fromPath: string, path: string, finalPath: string, colorsB64: string, shellB64: string): void { root.transitionBackgroundWithTheme(fromPath, path, finalPath, colorsB64, shellB64) } + + function prepare(path: string): void { + root.prepareBackground(path) + } + } + + // A prepared frame that no transition claims, say from a theme switch that + // failed after naming it, must not hold its decoded texture indefinitely. + Timer { + id: preparedBackgroundTimer + interval: 5000 + repeat: false + onTriggered: root.preparedBackground = "" } Timer { @@ -208,6 +295,27 @@ Item { property bool maskReady: false + // Decode the wallpaper at the size this screen can show, not the size + // it was shipped at. With PreserveAspectCrop Qt takes sourceSize as the + // area to cover, so this is the smallest decode that still fills the + // screen. Stock wallpapers go up to 10456x3455 (144 MB as RGBA); a + // 1080p laptop paid all of that for the 8 MB it can display, and paid + // it up to three times over during a transition. The images wait for + // the window's size and the wallpaper's native size so nothing is ever + // decoded at native size first, and a wallpaper smaller than the screen + // is decoded at its own size rather than scaled up to cover the screen. + readonly property bool sized: width > 0 && height > 0 + readonly property int decodeWidth: sized ? Math.ceil(width * screen.devicePixelRatio) : 0 + readonly property int decodeHeight: sized ? Math.ceil(height * screen.devicePixelRatio) : 0 + + function decodeSize(path) { + if (!sized || !path) return Qt.size(0, 0) + var native = root.nativeSizes[path] + if (native === undefined) return Qt.size(0, 0) + if (native.width > 0 && (native.width < decodeWidth || native.height < decodeHeight)) return Qt.size(native.width, native.height) + return Qt.size(decodeWidth, decodeHeight) + } + function maybeStartReveal() { if (!root.incomingBackground || root.revealProgress !== 0 || maskReady) return if (incomingFrame.status !== Image.Ready) return @@ -229,11 +337,15 @@ Item { id: base anchors.fill: parent path: root.displayedBackground + constrainDecode: true + decodeSize: panel.decodeSize(root.displayedBackground) onReadyChanged: { if (ready && root.finishingTransition) { root.incomingBackground = "" root.oldBackground = "" + root.preparedBackground = "" root.finishingTransition = false + root.pruneNativeSizes() } } } @@ -241,7 +353,10 @@ Item { Image { id: oldFrame anchors.fill: parent - source: root.imageUrl(root.oldBackground) + readonly property size decode: panel.decodeSize(root.oldBackground) + source: decode.width > 0 ? root.imageUrl(root.oldBackground) : "" + sourceSize.width: decode.width + sourceSize.height: decode.height fillMode: Image.PreserveAspectCrop asynchronous: true cache: false @@ -267,7 +382,13 @@ Item { Image { id: incomingFrame anchors.fill: parent - source: root.imageUrl(root.incomingBackground) + // The same URL and size as a prepared frame keeps its decoded + // image, so a transition to it can reveal at once. + readonly property string framePath: root.incomingBackground || root.preparedBackground + readonly property size decode: panel.decodeSize(framePath) + source: decode.width > 0 ? root.imageUrl(framePath) : "" + sourceSize.width: decode.width + sourceSize.height: decode.height fillMode: Image.PreserveAspectCrop asynchronous: true cache: false diff --git a/test/shell.d/background-test.sh b/test/shell.d/background-test.sh index c448c34e..2a0d832d 100644 --- a/test/shell.d/background-test.sh +++ b/test/shell.d/background-test.sh @@ -19,4 +19,59 @@ assert( !backgroundQml.includes('pendingThemeVersion !== backgroundVersion'), 'background theme transition applies pending colors even if image reveal stalls' ) + +const themeSet = fs.readFileSync(path.join(root, 'bin/omarchy-theme-set'), 'utf8') + +// The next background decodes while the theme stages, rather than after the +// transition arrives: WebP decodes take as long at screen size as at native. +assert( + /function prepare\(path: string\): void \{\s*root\.prepareBackground\(path\)/.test(backgroundQml) && + backgroundQml.includes('readonly property string framePath: root.incomingBackground || root.preparedBackground'), + 'background decodes a prepared theme background in the hidden incoming frame' +) +assert( + /path === lastTransitionPath/.test(backgroundQml) && + /id: preparedBackgroundTimer[\s\S]*?onTriggered: root\.preparedBackground = ""/.test(backgroundQml), + 'background ignores a late prepare and drops an unclaimed one' +) +assert( + themeSet.indexOf('shell_ipc background prepare') !== -1 && + themeSet.indexOf('shell_ipc background prepare') < themeSet.indexOf('\nomarchy-theme-set-templates\n'), + 'theme set hands the shell its next background before rendering templates' +) +assert( + themeSet.includes('shell_ipc background prepare "$PREPARED_BACKGROUND_SNAPSHOT" 9>&- &'), + 'theme set sends the prepare without holding the theme lock or waiting on it' +) + +// The wallpaper is decoded at the screen's physical size, never at the size +// it was shipped at, unless it is smaller than the screen: then it is decoded +// at its own size instead of being scaled up to cover the screen. +const mediaQml = fs.readFileSync(path.join(root, 'shell/Ui/BackgroundMedia.qml'), 'utf8') +assert( + backgroundQml.includes('readonly property bool sized: width > 0 && height > 0') && + backgroundQml.includes('readonly property int decodeWidth: sized ? Math.ceil(width * screen.devicePixelRatio) : 0') && + backgroundQml.includes('readonly property int decodeHeight: sized ? Math.ceil(height * screen.devicePixelRatio) : 0'), + 'background derives its decode size from the screen in physical pixels' +) +assert( + backgroundQml.includes('["magick", "identify", "-ping", "-format", "%w %h", sizeProbe.path]') && + backgroundQml.includes('if (native.width > 0 && (native.width < decodeWidth || native.height < decodeHeight)) return Qt.size(native.width, native.height)'), + 'background reads the wallpaper header and never decodes larger than the native size' +) +const count = (needle) => backgroundQml.split(needle).length - 1 +assertEqual(count('sourceSize.width: decode.width'), 2, 'both transition frames bind their decode width') +assertEqual(count('sourceSize.height: decode.height'), 2, 'both transition frames bind their decode height') +assertEqual(count('source: decode.width > 0 ? root.imageUrl('), 2, 'both transition frames wait for the screen and native sizes before loading') +assert( + /constrainDecode: true\s*decodeSize: panel\.decodeSize\(root\.displayedBackground\)/.test(backgroundQml) && + mediaQml.includes('source: !root.constrainDecode || root.decodeSize.width > 0 ? root.imageUrl : ""') && + mediaQml.includes('sourceSize.width: root.constrainDecode ? root.decodeSize.width : (root.version > 0 ? width : 0)'), + 'the displayed wallpaper waits for and decodes at the same size' +) +assert( + /function requestNativeSize\(path\) \{\s*if \(!path \|\| isVideo\(path\)/.test(backgroundQml) && + /function prepareBackground[\s\S]*?requestNativeSize\(path\)/.test(backgroundQml), + 'background never probes videos and probes a prepared frame ahead of its transition' +) JS