Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/omarchy-shell.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,11 @@ Rules:
First-party non-bar plugins are enabled unless listed in `disabledPlugins[]`.
6. `barWidget.allowMultiple: true` in the manifest permits multiple instances.
7. `idle.screensaver` and `idle.lock` are seconds since user idle began.
8. `version: 1` is required.
8. `background.transition` is `layout` (one wipe front across every
output, the default) or `output` (a wipe per output, each from its own
centre); `background.transitionDuration` pins the reveal in
milliseconds, and is derived from the output layout when unset.
9. `version: 1` is required.

`config/omarchy/shell.json` describes the fresh-install state. When no
user `shell.json` exists, defaults are used verbatim. Once the user
Expand Down
146 changes: 135 additions & 11 deletions shell/plugins/background/Background.qml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,95 @@ Item {
property string pendingShellRaw: ""
property real revealProgress: 1

readonly property real slant: -0.18

readonly property var backgroundConfig: shell && shell.shellConfig && shell.shellConfig.background
? shell.shellConfig.background : ({})

// "layout" carries one front across every output; "output" restores a wipe
// per output, each opening at its own centre. Anything unset means "layout".
readonly property bool wipeAcrossLayout: String(backgroundConfig.transition || "layout") !== "output"

// A positive transitionDuration pins the reveal; anything else derives it.
readonly property int configuredDuration: Number(backgroundConfig.transitionDuration) > 0
? Math.round(Number(backgroundConfig.transitionDuration))
: 0

// The wipe is one front travelling across the whole output layout rather
// than an independent wipe per output. It starts at the centre of the output
// the change was made on and continues onto the others according to where
// Hyprland places them, so a layout with a vertical offset or a gap between
// outputs is followed rather than ignored. originScreenName is snapshotted
// when a transition begins, so moving focus mid-wipe cannot drag the origin
// along with it.
property string originScreenName: ""

readonly property var originScreen: screenByName(originScreenName)
readonly property real originX: originScreen
? originScreen.x + originScreen.width / 2
: layoutCenter(true)
readonly property real originY: originScreen
? originScreen.y + originScreen.height / 2
: layoutCenter(false)

// How far the front must travel to clear every output, and how far it would
// have travelled to clear the origin output alone. Scaling the duration by
// the ratio keeps the edge moving at the speed it has on a single screen
// instead of racing across the whole layout in the same 420ms; the cap stops
// a wide layout from turning the wipe into a crawl.
readonly property real globalReach: reachOver(Quickshell.screens, originX, originY)
readonly property real originReach: reachOver(
originScreen ? [originScreen] : Quickshell.screens, originX, originY)
readonly property int revealDuration: {
if (configuredDuration > 0) return configuredDuration
if (!wipeAcrossLayout || originReach <= 0) return 420
return Math.min(900, Math.round(420 * (globalReach / originReach)))
}

function screenByName(name) {
if (!name) return null
var list = Quickshell.screens
for (var i = 0; i < list.length; i++) {
if (String(list[i].name || "") === name) return list[i]
}
return null
}

function layoutCenter(horizontal) {
var list = Quickshell.screens
if (!list.length) return 0
var low = horizontal ? list[0].x : list[0].y
var high = low + (horizontal ? list[0].width : list[0].height)
for (var i = 1; i < list.length; i++) {
var start = horizontal ? list[i].x : list[i].y
low = Math.min(low, start)
high = Math.max(high, start + (horizontal ? list[i].width : list[i].height))
}
return (low + high) / 2
}

// Largest horizontal distance from the slanted front line to any corner of
// the given outputs: how far spread has to grow to cover all of them.
function reachOver(screens, ox, oy) {
var furthest = 0
for (var i = 0; i < screens.length; i++) {
var s = screens[i]
var xs = [s.x, s.x + s.width]
var ys = [s.y, s.y + s.height]
for (var a = 0; a < 2; a++) {
for (var b = 0; b < 2; b++) {
furthest = Math.max(furthest, Math.abs(xs[a] - (ox + slant * (ys[b] - oy))))
}
}
}
return furthest + 4
}

function focusedScreenName() {
var monitor = Hyprland.focusedMonitor
return monitor ? String(monitor.name || "") : ""
}

// 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
Expand Down Expand Up @@ -69,6 +158,7 @@ Item {
currentBackground = finalPath
backgroundVersion += 1
revealStartedVersion = -1
originScreenName = focusedScreenName()

revealAnimation.stop()
finishingTransition = false
Expand Down Expand Up @@ -193,7 +283,7 @@ Item {
property: "revealProgress"
from: 0
to: 1
duration: 420
duration: root.revealDuration
easing.type: Easing.InOutCubic
onFinished: {
if (root.incomingBackground) {
Expand Down Expand Up @@ -244,11 +334,19 @@ Item {

property bool maskReady: false

// Every output that has decoded the incoming image joins the wipe, even
// one that gets there after the animation has started. Each output
// decodes its own copy, so requiring revealProgress to still be 0 let
// whichever output decoded first claim the reveal and locked the rest
// out of it: they kept the old wallpaper and jumped to the new one when
// the transition ended. startReveal still restarts the animation only
// once per backgroundVersion, so a late output picks up the front where
// it already is.
function maybeStartReveal() {
if (!root.incomingBackground || root.revealProgress !== 0 || maskReady) return
if (!root.incomingBackground || maskReady) return
if (incomingFrame.status !== Image.Ready) return
Qt.callLater(function() {
if (!root.incomingBackground || root.revealProgress !== 0 || maskReady) return
if (!root.incomingBackground || maskReady) return
if (incomingFrame.status !== Image.Ready) return
root.startReveal(panel)
})
Expand Down Expand Up @@ -296,7 +394,7 @@ Item {
layer.smooth: true
layer.effect: MultiEffect {
maskEnabled: true
maskSource: revealMask
maskSource: revealMaskSource
maskThresholdMin: 0.5
maskSpreadAtMin: 0.02
}
Expand All @@ -314,17 +412,43 @@ Item {
}
}

// The mask has to stay in the render tree for the wipe to animate. An
// item kept out of it with visible: false can change its geometry
// without dirtying the window, so an output whose scene is otherwise
// static never schedules a frame: it held the old wallpaper for the
// whole transition and jumped when the reveal ended. hideSource keeps
// the mask off the screen while leaving it live, and the effect samples
// it from here instead of from the item's own layer.
ShaderEffectSource {
id: revealMaskSource
anchors.fill: parent
sourceItem: revealMask
live: true
hideSource: true
visible: false
}

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
// Local coordinates of the global front. The front at global y sits at
// originX + slant * (y - originY); dx and originDy carry this panel's
// offset within the layout, so an output away from the origin sees only
// the leading edge sweep in, at the y offset its position implies. For
// the origin output this reduces to the single-screen centre-out wipe.
readonly property real slant: root.slant
readonly property real dx: root.originX - (panel.screen ? panel.screen.x : 0)
readonly property real originDy: (panel.screen ? panel.screen.y : 0) - root.originY
readonly property real localReach: width / 2 + Math.abs(slant) * height / 2 + 4
readonly property real centerTop: root.wipeAcrossLayout
? dx + slant * originDy
: width / 2 - slant * height / 2
readonly property real centerBottom: root.wipeAcrossLayout
? dx + slant * (originDy + height)
: width / 2 + slant * height / 2
readonly property real spread: (root.wipeAcrossLayout ? root.globalReach : localReach)
* root.revealProgress

Shape {
anchors.fill: parent
Expand Down