Files
omarchy/shell/plugins/background/Background.qml
T
David Heinemeier HanssonandClaude Opus 5.5 24f1243120 Make the theme picker open instantly (#13403)
* Run menu summon actions in-process

A menu action that only summons another shell plugin spawned bash and a
qs ipc client to ask this same shell to do it, about 60ms of the path.
Call shell.summon directly instead, and fall back to bash when the call
is refused or the action is anything more than a bare summon.

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

* Open the theme picker from rows held in the shell

Opening the theme picker ran omarchy-theme-switcher to rebuild its index
and then made a second IPC call, about 170ms before the picker mapped.
The picker now holds the theme rows itself, opens from them at once, and
refreshes them behind the open via omarchy-theme-switcher --print-rows.
It applies the chosen theme with omarchy-theme-set directly, so
omarchy-theme-set no longer preloads the picker.

From the keybinding to the overlay mapped drops from ~245ms to ~83ms.

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

* Keep the image picker surface mapped between opens

Each open mapped a fresh surface, which rendered its first frames before
Hyprland sent its fractional scale: the pixel ratio stepped 2, 1, then
1.6, so the picker flashed blurry for ~130ms and re-uploaded every
thumbnail texture. Keep the surface and park it transparent and
input-less on the bottom layer while closed, since anything on the
overlay layer blocks direct scanout for fullscreen apps. It follows the
focused monitor on each open.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:10:49 +02:00

328 lines
10 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: ""
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
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 = ""
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 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: 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)
}
}
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
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
onReadyChanged: {
if (ready && root.finishingTransition) {
root.incomingBackground = ""
root.oldBackground = ""
root.finishingTransition = false
}
}
}
Image {
id: oldFrame
anchors.fill: parent
source: root.imageUrl(root.oldBackground)
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
source: root.imageUrl(root.incomingBackground)
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
}
}
}
}
}