import Quickshell import Quickshell.Io import Quickshell.Wayland import QtQuick import QtQuick.Effects import QtQuick.Shapes import qs.Commons import qs.Ui Item { id: root property var shell: null readonly property string home: Quickshell.env("HOME") readonly property string stateHome: home + "/.local/state" readonly property string currentBackgroundLink: stateHome + "/omarchy/current/background" property string currentBackground: "" 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 property int pendingThemeVersion: -1 property string pendingColorsRaw: "" property string pendingShellRaw: "" property real revealProgress: 1 function isVideo(path) { return Util.isVideoPath(path) } function imageUrl(path) { return Util.fileUrl(path) } function refreshBackground() { if (!readlinkProc.running) readlinkProc.running = true } function setBackground(path, instant) { transitionBackground("", path, path, instant, false) } function transitionBackground(fromPath, path, finalPath, instant, force) { path = String(path || "").trim() 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 revealAnimation.stop() finishingTransition = false // Video frames are not fed through the image-only reveal stack. Switching // instantly also avoids decoding two full videos during a transition. if (instant || !displayedBackground || isVideo(path) || isVideo(displayedBackground)) { oldBackground = "" incomingBackground = "" preparedBackground = "" displayedBackground = finalPath revealProgress = 1 return } oldBackground = fromPath || displayedBackground incomingBackground = path revealProgress = 0 } function setPendingTheme(colorsB64, shellB64) { pendingColorsRaw = Util.decodeBase64(colorsB64) pendingShellRaw = Util.decodeBase64(shellB64) pendingThemeVersion = backgroundVersion pendingThemeFallbackTimer.restart() } function applyPendingTheme() { // Background polling can advance backgroundVersion while a theme switch is // pending; the latest theme payload should still apply. if (pendingThemeVersion < 0) return pendingThemeFallbackTimer.stop() Color.loadColors(pendingColorsRaw) // Color.loadShell also refreshes Style so the type scale flips with the // background reveal instead of waiting for a separate reload path. Color.loadShell(pendingShellRaw) Style.scheduleRefresh() pendingThemeVersion = -1 pendingColorsRaw = "" pendingShellRaw = "" } function transitionBackgroundWithTheme(fromPath, path, finalPath, colorsB64, shellB64) { transitionBackground(fromPath, path, finalPath, false, true) setPendingTheme(colorsB64, shellB64) if (!incomingBackground || revealProgress >= 1) applyPendingTheme() } function startReveal(panel) { if (!incomingBackground) return panel.maskReady = true if (revealStartedVersion === backgroundVersion) return revealStartedVersion = backgroundVersion applyPendingTheme() 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 } function openThemeSwitcher() { var payload = JSON.stringify({ source: "themes" }) // A cloned background may not summon the picker in-process, so it takes // the IPC route instead. if (!root.shell || !root.shell.summon("omarchy.image-picker", payload)) Util.execArgv(["omarchy-shell", "shell", "summon", "omarchy.image-picker", payload]) } Process { id: bgSwitchProc command: ["bash", "-c", "background=$(omarchy-theme-bg-switcher); [[ -n $background ]] && omarchy-theme-bg-set \"$background\""] 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] stdout: StdioCollector { 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) } function transition(fromPath: string, path: string): void { root.transitionBackground(fromPath, path, path, false, false) } 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 { id: pendingThemeFallbackTimer interval: 300 repeat: false onTriggered: root.applyPendingTheme() } NumberAnimation { id: revealAnimation target: root property: "revealProgress" from: 0 to: 1 duration: 420 easing.type: Easing.InOutCubic onFinished: { if (root.incomingBackground) { root.displayedBackground = root.currentBackground || root.incomingBackground root.finishingTransition = true } root.revealProgress = 1 } } Component.onCompleted: refreshBackground() Variants { model: Quickshell.screens PanelWindow { id: panel required property var modelData screen: modelData visible: !remapGuard.remapping anchors { top: true; bottom: true; left: true; right: true } ScreenMoveRemap { id: remapGuard window: panel } color: "transparent" // Keep render updates enabled. The background layer has been observed to // lose its committed buffer while parked with updatesEnabled=false, // leaving a black desktop until omarchy-shell is restarted. A still // wallpaper costs nothing to keep enabled. OWE manages video layers. updatesEnabled: true 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 Qt.callLater(function() { if (!root.incomingBackground || root.revealProgress !== 0 || maskReady) return if (incomingFrame.status !== Image.Ready) return root.startReveal(panel) }) } WlrLayershell.namespace: "omarchy-background" WlrLayershell.layer: WlrLayer.Background WlrLayershell.keyboardFocus: WlrKeyboardFocus.None exclusionMode: ExclusionMode.Ignore // OWE owns video backgrounds. This layer draws stills, and stays empty // behind a video so OWE's own layer shows through. BackgroundMedia { 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() } } } Image { id: oldFrame anchors.fill: parent 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 smooth: true mipmap: true visible: root.oldBackground !== "" && root.revealProgress < 1 onStatusChanged: panel.maybeStartReveal() } Item { id: incomingLayer anchors.fill: parent visible: root.incomingBackground !== "" && incomingFrame.status === Image.Ready && (root.revealProgress >= 1 || panel.maskReady) layer.enabled: root.incomingBackground !== "" && root.revealProgress < 1 layer.smooth: true layer.effect: MultiEffect { maskEnabled: true maskSource: revealMask maskThresholdMin: 0.5 maskSpreadAtMin: 0.02 } Image { id: incomingFrame anchors.fill: parent // 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 smooth: true mipmap: true onStatusChanged: panel.maybeStartReveal() } } Item { id: revealMask anchors.fill: parent visible: false layer.enabled: true readonly property real slant: -0.18 readonly property real centerTop: width / 2 - slant * height / 2 readonly property real centerBottom: width / 2 + slant * height / 2 readonly property real reach: width / 2 + Math.abs(slant) * height / 2 + 4 readonly property real spread: reach * root.revealProgress Shape { anchors.fill: parent antialiasing: true preferredRendererType: Shape.CurveRenderer ShapePath { fillColor: "white" strokeColor: "transparent" startX: revealMask.centerTop - revealMask.spread; startY: 0 PathLine { x: revealMask.centerTop + revealMask.spread; y: 0 } PathLine { x: revealMask.centerBottom + revealMask.spread; y: revealMask.height } PathLine { x: revealMask.centerBottom - revealMask.spread; y: revealMask.height } PathLine { x: revealMask.centerTop - revealMask.spread; y: 0 } } } } Connections { target: root function onIncomingBackgroundChanged() { panel.maskReady = false panel.maybeStartReveal() } } MouseArea { anchors.fill: parent acceptedButtons: Qt.LeftButton | Qt.RightButton onDoubleClicked: function(mouse) { if (mouse.button === Qt.RightButton) root.openThemeSwitcher() else root.openSelector() mouse.accepted = true } } } } }