Decode theme backgrounds earlier, and at screen size (#13408)

* Decode the next theme background while the theme stages

The wipe waited 125-290ms after the transition arrived, decoding the new
wallpaper. Most stock wallpapers are WebP, which Qt decodes at full size
and scales afterwards, so a screen-sized sourceSize does not shorten it.

Start the decode earlier instead. omarchy-theme-set chooses the next
background and snapshots it before rendering templates, then sends a new
background prepare call in the background. The shell loads it into the
hidden incoming frame, so the transition finds it decoded. A prepare that
arrives after its transition is ignored, and one no transition claims is
dropped after five seconds.

The wipe now starts ~255ms after omarchy-theme-set begins instead of
~345-490ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Decode the wallpaper at screen size instead of shipped size

Wallpapers decoded at the resolution they were shipped at: a 5120x2880
stock wallpaper took 5120x2880 of RGBA on a 1920x1200 panel, and a
transition held up to three such frames. Bind sourceSize on the
displayed wallpaper and both transition frames to the screen's physical
size. PreserveAspectCrop treats it as the area to cover, so the image
still fills the screen.

Qt scales a decode up as well as down to cover sourceSize, so the native
size is read from the file header first with magick identify, and a
wallpaper smaller than the screen decodes at its own size. The images
wait for both sizes, so nothing decodes at native size first.

Ported from #8324 onto BackgroundMedia and the prepared incoming frame.
Measured with a 5120x2880 wallpaper, the shell's GPU memory at rest
drops from 264 MiB to ~148 MiB. The size probe delays the reveal by
~30ms, which the earlier prepare still more than covers.

Co-authored-by: Ryan Yogan <ryanyogan@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Ryan Yogan <ryanyogan@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-09-27 16:11:36 +02:00
1 parent 7e8d35efb3
commit f3dbc464fc
4 files changed
+209 -10

No files matched your search

+23 -5
View File
@@ -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"
+8 -3
View File
@@ -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)
}
}
}
+123 -2
View File
@@ -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
+55
View File
@@ -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