diff --git a/docs/omarchy-shell.md b/docs/omarchy-shell.md index d62ff7436df..16c8ac8d49d 100644 --- a/docs/omarchy-shell.md +++ b/docs/omarchy-shell.md @@ -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 diff --git a/shell/plugins/background/Background.qml b/shell/plugins/background/Background.qml index dea93a4cf65..71d77afb172 100644 --- a/shell/plugins/background/Background.qml +++ b/shell/plugins/background/Background.qml @@ -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 @@ -69,6 +158,7 @@ Item { currentBackground = finalPath backgroundVersion += 1 revealStartedVersion = -1 + originScreenName = focusedScreenName() revealAnimation.stop() finishingTransition = false @@ -193,7 +283,7 @@ Item { property: "revealProgress" from: 0 to: 1 - duration: 420 + duration: root.revealDuration easing.type: Easing.InOutCubic onFinished: { if (root.incomingBackground) { @@ -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) }) @@ -296,7 +394,7 @@ Item { layer.smooth: true layer.effect: MultiEffect { maskEnabled: true - maskSource: revealMask + maskSource: revealMaskSource maskThresholdMin: 0.5 maskSpreadAtMin: 0.02 } @@ -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