diff --git a/Components/VerticalFadeFlickable.qml b/Components/VerticalFadeFlickable.qml index c3c8337..6225117 100644 --- a/Components/VerticalFadeFlickable.qml +++ b/Components/VerticalFadeFlickable.qml @@ -10,7 +10,6 @@ CustomFlickable { property real bottomFadeOpacity: fadeShouldBeActive(false) ? 0 : 1 property real fadeAmount: 0.1 property real topFadeOpacity: fadeShouldBeActive(true) ? 0 : 1 - readonly property real dpr: Math.ceil((QsWindow.window as QsWindow)?.devicePixelRatio ?? 1) function fadeShouldBeActive(isStart: bool): bool { // When content is smaller than flickable size, hide fade when rebound starts @@ -30,7 +29,6 @@ CustomFlickable { type: Anim.SlowEffects } } - layer.textureSize: Qt.size(width * dpr, height * dpr) layer.effect: Mask { maskSource: mask diff --git a/Components/VerticalFadeListView.qml b/Components/VerticalFadeListView.qml index 6fe91b6..4ade482 100644 --- a/Components/VerticalFadeListView.qml +++ b/Components/VerticalFadeListView.qml @@ -6,11 +6,12 @@ import qs.Effects CustomListView { id: root + readonly property bool horizontal: orientation === ListView.Horizontal + property real endFadeOpacity: fadeShouldBeActive(false) ? 0 : 1 property bool fadeEnabled: true property real fadeAmount: 0.1 property real fadeThreshold: 0.0 - readonly property bool horizontal: orientation === ListView.Horizontal property real startFadeOpacity: fadeShouldBeActive(true) ? 0 : 1 function contentSize(): real { diff --git a/Drawers/Panels.qml b/Drawers/Panels.qml index eb11cfe..6e25195 100644 --- a/Drawers/Panels.qml +++ b/Drawers/Panels.qml @@ -145,9 +145,8 @@ Item { Utils.Wrapper { id: utilities - anchors.bottom: parent.bottom - anchors.right: parent.right screenState: root.screenState + screen: root.screen sidebar: sidebar } diff --git a/Drawers/Regions.qml b/Drawers/Regions.qml index 590f92f..d2f870d 100644 --- a/Drawers/Regions.qml +++ b/Drawers/Regions.qml @@ -50,7 +50,6 @@ Region { R { height: panel.height * (1 - root.panels.utilities.offsetScale) + root.geometry.insetBottom(root.borderThickness) panel: root.panels.utilities - y: root.win.height - height } R { diff --git a/Drawers/Windows.qml b/Drawers/Windows.qml index d0a0212..8320f59 100644 --- a/Drawers/Windows.qml +++ b/Drawers/Windows.qml @@ -266,7 +266,7 @@ CustomWindow { deformAmount: 0.1 exclude: panels.sidebar.offsetScale > 0.08 ? [] : [sidebarBg] panel: panels.utilities - topLeftRadius: 0 + radius: panels.utilities.isDetached ? Tokens.rounding.largeIncreased + Tokens.padding.small : 0 } PanelBg { diff --git a/Modules/Sidebar/Utils/Cards/Toggles.qml b/Modules/Sidebar/Utils/Cards/Toggles.qml index 14d5785..72ac0dd 100644 --- a/Modules/Sidebar/Utils/Cards/Toggles.qml +++ b/Modules/Sidebar/Utils/Cards/Toggles.qml @@ -2,16 +2,15 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts -import Quickshell import Quickshell.Bluetooth import Quickshell.Networking as QSNetwork import ZShell.Components import ZShell.Config +import qs.Modules.Sidebar.Utils import qs.Components import qs.Services import qs.Helpers import qs.Daemons -import qs.Modules.Settings CustomRect { id: root @@ -38,16 +37,18 @@ CustomRect { return true; }); } - required property ScreenState screenState readonly property int splitIndex: Math.ceil(quickToggles.length / 2) + required property PopoutState popouts + required property ScreenState screenState + color: Colors.tPalette.m3surfaceContainer implicitHeight: layout.implicitHeight + Tokens.padding.extraLargeIncreased radius: Tokens.rounding.small SnapLayout { -orientation: Qt.Vertical id: layout + orientation: Qt.Vertical anchors.fill: parent anchors.margins: Tokens.padding.large @@ -167,8 +168,7 @@ orientation: Qt.Vertical isToggle: false onClicked: { - root.screenState.sidebar = false; - root.screenState.settings = true; + root.popouts.detachRequested("appearance"); } } } diff --git a/Modules/Sidebar/Utils/Content.qml b/Modules/Sidebar/Utils/Content.qml index 749adf2..a9a3c6e 100644 --- a/Modules/Sidebar/Utils/Content.qml +++ b/Modules/Sidebar/Utils/Content.qml @@ -10,7 +10,10 @@ import ZShell.Components Item { id: root + readonly property int padding: Tokens.padding.small + required property ScreenState screenState + required property PopoutState popouts implicitHeight: ZUtils.snap(layout.implicitHeight, root) implicitWidth: ZUtils.snap(layout.implicitWidth, root) @@ -33,6 +36,7 @@ Item { Toggles { Layout.fillWidth: true screenState: root.screenState + popouts: root.popouts } } } diff --git a/Modules/Sidebar/Utils/PopoutState.qml b/Modules/Sidebar/Utils/PopoutState.qml new file mode 100644 index 0000000..5f824e6 --- /dev/null +++ b/Modules/Sidebar/Utils/PopoutState.qml @@ -0,0 +1,8 @@ +import QtQuick + +QtObject { + property string currentName + property string hasCurrent + + signal detachRequested(mode: string) +} diff --git a/Modules/Sidebar/Utils/Wrapper.qml b/Modules/Sidebar/Utils/Wrapper.qml index c44850e..6af113a 100644 --- a/Modules/Sidebar/Utils/Wrapper.qml +++ b/Modules/Sidebar/Utils/Wrapper.qml @@ -2,41 +2,373 @@ 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 - property real offsetScale: shouldBeActive ? 0 : 1 required property ScreenState screenState - readonly property bool shouldBeActive: screenState.sidebar && !sidebar.chatActive + required property ShellScreen screen required property Item sidebar - anchors.bottomMargin: (-implicitHeight - 5) * offsetScale - implicitHeight: content.implicitHeight + Tokens.padding.small * 2 - implicitWidth: sidebar.width * (1 - sidebar.offsetScale) + 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: Tokens.anim.durations.expressiveDefaultSpatial + duration: root.dur easing: Tokens.anim.expressiveDefaultSpatial } } - Loader { + 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 - active: root.shouldBeActive || root.visible - anchors.left: parent.left - anchors.margins: 8 - anchors.top: parent.top + shouldBeActive: (root.shouldBeActive || root.visible) && !root.isDetached + anchors.centerIn: parent sourceComponent: Content { - implicitWidth: root.implicitWidth - 8 * 2 + 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" + } + } + } + ] + } }