pragma ComponentBehavior: Bound import QtQuick import Quickshell import Quickshell.Hyprland import Quickshell.Wayland import ZShell.Config import ZShell import qs.Modules.Settings as Settings import qs.Components Item { id: root required property ScreenState screenState required property ShellScreen screen required property Item sidebar property string queuedMode property real offsetScale: shouldBeActive ? 0 : 1 readonly property string wantedState: { if (queuedMode.length > 0) return "detached"; if (screenState.sidebar && !sidebar.chatActive) return "docked"; return "hidden"; } readonly property bool isDetached: wantedState === "detached" readonly property bool shouldBeActive: wantedState !== "hidden" readonly property int dur: Tokens.anim.durations.expressiveDefaultSpatial readonly property int slowDur: Tokens.anim.durations.expressiveSlowSpatial // Sizes come from the specific Comp, never from "whichever child is current" readonly property real settingsW: settings.implicitWidth + (settings.item?.padding ?? 0) * 2 readonly property real settingsH: settings.implicitHeight + (settings.item?.padding ?? 0) * 2 readonly property real contentW: content.implicitWidth + (content.item?.padding ?? 0) * 2 readonly property real contentH: content.implicitHeight + (content.item?.padding ?? 0) * 2 // Slot positions readonly property real hiddenX: parent.width readonly property real hiddenY: parent.height - Config.bar.border + Tokens.padding.small * 2 readonly property real dockedX: parent.width - sidebar.width readonly property real dockedY: parent.height - contentH readonly property real centerX: (parent.screen.width - settingsW) / 2 readonly property real centerY: (parent.screen.height - settingsH) / 2 opacity: 1 - offsetScale visible: offsetScale < 1 clip: true function detach(mode: string): void { queuedMode = mode; focus = true; } function close(): void { queuedMode = ""; } // ---- State selection -------------------------------------------------- // `state` is only ever assigned `wantedState`, never an intermediate value. // The only thing sync() decides is WHEN, i.e. whether the Comp the target // state takes its size from is Ready yet. // Restarted whenever either Comp's status or size changes. While it is // running, a Comp's implicit size is still settling and must not be captured. Timer { id: settle interval: 12 // about two frames; raise it if the height is still read too early onTriggered: root.sync() } function stable(comp: Comp): bool { return comp.status === Loader.Ready && comp.implicitHeight > 0 && !settle.running; } function ready(): bool { switch (wantedState) { case "detached": return stable(settings); case "docked": return stable(content); default: // Closing from detached passes through the docked slot, so // content's final size is needed before the transition starts. return state !== "detached" || stable(content); } } function sync(): void { if (state !== wantedState && ready()) state = wantedState; } onWantedStateChanged: sync() Component.onCompleted: sync() Connections { target: settings function onStatusChanged(): void { settle.restart(); } function onImplicitHeightChanged(): void { settle.restart(); } function onImplicitWidthChanged(): void { settle.restart(); } } Connections { target: content function onStatusChanged(): void { settle.restart(); } function onImplicitHeightChanged(): void { settle.restart(); } function onImplicitWidthChanged(): void { settle.restart(); } } // ---- States: every position and size property lives here ------------ states: [ State { name: "hidden" PropertyChanges { root.x: root.hiddenX root.y: root.hiddenY root.implicitWidth: 0 root.implicitHeight: 0 } }, State { name: "docked" PropertyChanges { root.x: root.dockedX root.y: root.dockedY root.implicitWidth: root.contentW root.implicitHeight: root.contentH } }, State { name: "detached" PropertyChanges { root.x: root.centerX root.y: root.centerY root.implicitWidth: root.settingsW root.implicitHeight: root.settingsH } } ] // One explicit transition per state pair. No `reversible`, no wildcards. transitions: [ Transition { from: "hidden" to: "docked" Anim { properties: "x,y,implicitWidth,implicitHeight" duration: root.dur easing: Tokens.anim.expressiveDefaultSpatial } }, Transition { from: "docked" to: "hidden" Anim { properties: "x,y,implicitWidth,implicitHeight" duration: root.dur easing: Tokens.anim.expressiveDefaultSpatial } }, Transition { from: "docked" to: "detached" Anim { properties: "x,y,implicitWidth,implicitHeight" duration: root.slowDur easing: Tokens.anim.expressiveSlowSpatial } }, Transition { from: "hidden" to: "detached" Anim { properties: "x,y,implicitWidth,implicitHeight" duration: root.slowDur easing: Tokens.anim.expressiveSlowSpatial } }, Transition { from: "detached" to: "docked" Anim { properties: "x,y,implicitWidth,implicitHeight" duration: root.dur easing: Tokens.anim.expressiveDefaultSpatial } }, // 1) x, y and size to the docked slot, 2) only y drops, 3) snap the rest while invisible Transition { from: "detached" to: "hidden" ParallelAnimation { Anim { target: root property: "x" to: root.dockedX duration: root.dur easing: Tokens.anim.expressiveDefaultSpatial } Anim { target: root property: "y" to: root.dockedY duration: root.dur easing: Tokens.anim.expressiveDefaultSpatial } Anim { target: root property: "implicitWidth" to: root.contentW duration: root.dur easing: Tokens.anim.expressiveDefaultSpatial } Anim { target: root property: "implicitHeight" to: root.contentH duration: root.dur easing: Tokens.anim.expressiveDefaultSpatial } SequentialAnimation { PauseAnimation { duration: root.dur - 300 } Anim { target: root property: "y" from: root.dockedY to: root.hiddenY duration: root.dur easing: Tokens.anim.expressiveDefaultSpatial } PropertyAction { target: root properties: "x,implicitWidth,implicitHeight" } } } } ] // Fade only. Nothing that moves or sizes the item is a Behavior any more. Behavior on offsetScale { Anim { duration: root.dur easing: Tokens.anim.expressiveDefaultSpatial } } PopoutState { id: popoutState onDetachRequested: mode => root.detach(mode) } HyprlandFocusGrab { active: root.isDetached windows: [QsWindow.window] onCleared: root.close() } Binding { when: root.isDetached target: QsWindow.window property: "WlrLayershell.keyboardFocus" value: WlrKeyboardFocus.OnDemand } Comp { id: settings readonly property int padding: 0 shouldBeActive: root.isDetached anchors.centerIn: parent sourceComponent: Settings.Content { sState.screen: root.screen sState.animatingContainer: settings.opacity < 1 sState.currentPageIdx: ["appearance", "network", "bluetooth", "audio"].indexOf(root.queuedMode) onClose: root.close() } } Comp { id: content shouldBeActive: (root.shouldBeActive || root.visible) && !root.isDetached anchors.centerIn: parent sourceComponent: Content { popouts: popoutState implicitWidth: root.sidebar.width - Tokens.padding.small * 2 screenState: root.screenState } } component Comp: Loader { id: comp property bool shouldBeActive active: false opacity: 0 // Makes the loader load on the same frame shouldBeActive becomes true, which ensures size is set states: State { name: "active" when: comp.shouldBeActive PropertyChanges { comp.opacity: 1 comp.active: true } } transitions: [ Transition { from: "" to: "active" SequentialAnimation { PropertyAction { property: "active" } Anim { type: Anim.DefaultEffects property: "opacity" } } }, Transition { from: "active" to: "" SequentialAnimation { Anim { type: Anim.DefaultEffects property: "opacity" } PropertyAction { property: "active" } } } ] } }