389 lines
13 KiB
QML
389 lines
13 KiB
QML
import Quickshell
|
|
import Quickshell.Hyprland
|
|
import Quickshell.Io
|
|
import Quickshell.Wayland
|
|
import QtQuick
|
|
import QtQuick.Effects
|
|
import QtQuick.Shapes
|
|
import qs.Commons
|
|
import qs.Ui
|
|
|
|
Item {
|
|
id: root
|
|
|
|
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 int displayedReloads: 0
|
|
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
|
|
|
|
// Injected by the first-party service loader; used to reach the lock and idle
|
|
// services so playback can stop whenever nothing can see the wallpaper.
|
|
property var shell: null
|
|
|
|
// When the OWE wallpaper engine is running, it owns video backgrounds. This
|
|
// plugin keeps stills, which OWE hands back to it, and the lock screen keeps
|
|
// its own playback. The desktop video path and its pause policy stay as the
|
|
// fallback for systems without OWE.
|
|
property bool oweActive: false
|
|
|
|
Process {
|
|
id: oweStatusProc
|
|
command: ["bash", "-c", "test -S \"${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/owe/owed.sock\""]
|
|
onExited: root.oweActive = (exitCode === 0)
|
|
}
|
|
|
|
Timer {
|
|
id: oweStatusTimer
|
|
interval: 5000
|
|
repeat: true
|
|
running: true
|
|
onTriggered: if (!oweStatusProc.running) oweStatusProc.running = true
|
|
}
|
|
|
|
// Stop a video wallpaper's decoding whenever it is covered. Qt's FFmpeg
|
|
// engine drives its own clock, so an unseen player keeps decoding until it
|
|
// is told not to — a locked laptop would otherwise decode until it died.
|
|
readonly property var lockService: shell && shell.services ? shell.firstPartyServiceFor("omarchy.lock") : null
|
|
readonly property var idleService: shell && shell.services ? shell.firstPartyServiceFor("omarchy.idle") : null
|
|
readonly property var batteryService: shell && shell.services ? shell.firstPartyServiceFor("omarchy.battery") : null
|
|
readonly property bool lockActive: lockService ? lockService.locked : false
|
|
readonly property bool screensaverActive: idleService ? idleService.screensaverWindowCount > 0 : false
|
|
readonly property bool powerSaverActive: batteryService ? batteryService.powerSaverOnBattery : false
|
|
// A lock or a screensaver covers every output, so it is decided once here.
|
|
// Fullscreen is decided per output below, because it only covers its own.
|
|
readonly property bool sessionObscured: lockActive || screensaverActive
|
|
|
|
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 = ""
|
|
// A theme switch can replace the file behind an unchanged path, which
|
|
// an unchanged property would never pick up.
|
|
if (displayedBackground === finalPath) displayedReloads += 1
|
|
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() {
|
|
if (!themeSwitchProc.running) themeSwitchProc.running = true
|
|
}
|
|
|
|
Process {
|
|
id: bgSwitchProc
|
|
command: ["bash", "-c", "background=$(omarchy-theme-bg-switcher); [[ -n $background ]] && omarchy-theme-bg-set \"$background\""]
|
|
onExited: root.refreshBackground()
|
|
}
|
|
|
|
Process {
|
|
id: themeSwitchProc
|
|
command: ["bash", "-c", "theme=$(omarchy-theme-switcher); [[ -n $theme ]] && omarchy-theme-set \"$theme\" >/dev/null 2>&1 &"]
|
|
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: {
|
|
oweStatusProc.running = true
|
|
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, and a video one is throttled
|
|
// by pausing playback rather than by parking the layer.
|
|
updatesEnabled: true
|
|
|
|
// Pausing every wallpaper for one fullscreen window would freeze the one
|
|
// still on show next to it, which costs a viewer more than it saves. The
|
|
// workspace on show here knows whether a fullscreen window covers it,
|
|
// wherever focus happens to be.
|
|
readonly property var hyprlandMonitor: Hyprland.monitorFor(modelData)
|
|
readonly property var visibleWorkspace: hyprlandMonitor ? hyprlandMonitor.activeWorkspace : null
|
|
readonly property bool fullscreenHere: visibleWorkspace ? visibleWorkspace.hasFullscreen : false
|
|
|
|
// A sound track plays from one output only, or every monitor would
|
|
// layer its own copy of it.
|
|
readonly property bool firstScreen: Quickshell.screens.length > 0
|
|
&& String(Quickshell.screens[0].name || "") === String(modelData.name || "")
|
|
|
|
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
|
|
|
|
BackgroundMedia {
|
|
id: base
|
|
anchors.fill: parent
|
|
path: root.displayedBackground
|
|
reloads: root.displayedReloads
|
|
deferVideo: root.oweActive
|
|
playbackEnabled: !root.sessionObscured && !root.powerSaverActive && !panel.fullscreenHere
|
|
audioEnabled: panel.firstScreen
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|