A machine without a GPU, like most VMs, renders through llvmpipe on the CPU, where every animated frame and every translucent window costs. In a VM, opening and closing a terminal took ~5s of CPU; a panel ~3.4s. omarchy toggle animations (also under Toggle > Animations) places a Hyprland flag that turns off animations, blur and shadows and makes windows opaque. The shell follows Hyprland's animations:enabled, rereading it on every config reload: its one-shot animations run for Style.duration(ms), which is then 0, and its spinners, pulses and title marquee hold still. A new install in a VM starts with the flag in place. Measured in the ISO test VM (llvmpipe), CPU per interaction: terminal open+close 5000ms -> 481ms, workspace switch 1670ms -> 409ms, audio panel 3446ms -> 796ms, volume OSD 2324ms -> 584ms, menu 2028ms -> 1241ms. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
449 lines
16 KiB
QML
449 lines
16 KiB
QML
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)
|
|
}
|
|
}
|
|
|
|
ShellIpc {
|
|
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: Style.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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|