diff --git a/core/.pre-commit-config.yaml b/core/.pre-commit-config.yaml index f1f274ec..a2b5ddaf 100644 --- a/core/.pre-commit-config.yaml +++ b/core/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/golangci/golangci-lint - rev: v2.9.0 + rev: v2.10.1 hooks: - id: golangci-lint-fmt require_serial: true diff --git a/quickshell/Common/AnimVariants.qml b/quickshell/Common/AnimVariants.qml new file mode 100644 index 00000000..3b7d8b1c --- /dev/null +++ b/quickshell/Common/AnimVariants.qml @@ -0,0 +1,147 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import qs.Common + +// AnimVariants — Central tuning for animation and Motion Effects variants +// (Material/Fluent/Dynamic) (Standard/Directional/Depth) + +Singleton { + id: root + + readonly property list variantEnterCurve: { + if (typeof SettingsData === "undefined") + return Anims.expressiveDefaultSpatial; + switch (SettingsData.animationVariant) { + case 1: return Anims.standardDecel; + case 2: return Anims.expressiveFastSpatial; + default: return Anims.expressiveDefaultSpatial; + } + } + + readonly property list variantExitCurve: { + if (typeof SettingsData === "undefined") + return Anims.emphasized; + switch (SettingsData.animationVariant) { + case 1: return Anims.standard; + case 2: return Anims.emphasized; + default: return Anims.emphasized; + } + } + + // Modal-specific entry curve + readonly property list variantModalEnterCurve: { + if (typeof SettingsData === "undefined") + return Anims.expressiveDefaultSpatial; + if (isDirectionalEffect) { + if (SettingsData.animationVariant === 1) + return Anims.standardDecel; + if (SettingsData.animationVariant === 2) + return Anims.expressiveFastSpatial; + } + return variantEnterCurve; + } + + readonly property list variantModalExitCurve: { + if (typeof SettingsData === "undefined") + return Anims.emphasized; + if (isDirectionalEffect) { + if (SettingsData.animationVariant === 1) + return Anims.emphasizedAccel; + if (SettingsData.animationVariant === 2) + return Anims.emphasizedAccel; + } + return variantExitCurve; + } + + // Popout-specific entry curve + readonly property list variantPopoutEnterCurve: { + if (typeof SettingsData === "undefined") + return Anims.expressiveDefaultSpatial; + if (isDirectionalEffect) { + if (SettingsData.animationVariant === 1) + return Anims.standardDecel; + if (SettingsData.animationVariant === 2) + return Anims.standardDecel; + return Anims.standardDecel; + } + return variantEnterCurve; + } + + readonly property list variantPopoutExitCurve: { + if (typeof SettingsData === "undefined") + return Anims.emphasized; + if (isDirectionalEffect) { + if (SettingsData.animationVariant === 1) + return Anims.emphasizedAccel; + if (SettingsData.animationVariant === 2) + return Anims.emphasizedAccel; + } + return variantExitCurve; + } + + readonly property real variantEnterDurationFactor: { + if (typeof SettingsData === "undefined") return 1.0; + switch (SettingsData.animationVariant) { + case 1: return 0.9; + case 2: return 1.08; + default: return 1.0; + } + } + + readonly property real variantExitDurationFactor: { + if (typeof SettingsData === "undefined") return 1.0; + switch (SettingsData.animationVariant) { + case 1: return 0.85; + case 2: return 0.92; + default: return 1.0; + } + } + + // Fluent: opacity at ~55% of duration; Material/Dynamic: 1:1 with position + readonly property real variantOpacityDurationScale: { + if (typeof SettingsData === "undefined") return 1.0; + return SettingsData.animationVariant === 1 ? 0.55 : 1.0; + } + + function variantDuration(baseDuration, entering) { + const factor = entering ? variantEnterDurationFactor : variantExitDurationFactor; + return Math.max(0, Math.round(baseDuration * factor)); + } + + function variantExitCleanupPadding() { + if (typeof SettingsData === "undefined") return 50; + switch (SettingsData.motionEffect) { + case 1: return 8; + case 2: return 24; + default: return 50; + } + } + + function variantCloseInterval(baseDuration) { + return variantDuration(baseDuration, false) + variantExitCleanupPadding(); + } + + readonly property bool isDirectionalEffect: typeof SettingsData !== "undefined" && SettingsData.motionEffect === 1 + readonly property bool isDepthEffect: typeof SettingsData !== "undefined" && SettingsData.motionEffect === 2 + + readonly property real effectScaleCollapsed: { + if (typeof SettingsData === "undefined") return 0.96; + switch (SettingsData.motionEffect) { + case 1: return 1.0; + case 2: return 0.88; + default: return 0.96; + } + } + + readonly property real effectAnimOffset: { + if (typeof SettingsData === "undefined") return 16; + switch (SettingsData.motionEffect) { + case 1: return 144; + case 2: return 56; + default: return 16; + } + } +} diff --git a/quickshell/Common/Anims.qml b/quickshell/Common/Anims.qml index 349e9916..84ab95c6 100644 --- a/quickshell/Common/Anims.qml +++ b/quickshell/Common/Anims.qml @@ -22,4 +22,9 @@ Singleton { readonly property var standard: [0.20, 0.00, 0.00, 1.00, 1.00, 1.00] readonly property var standardDecel: [0.00, 0.00, 0.00, 1.00, 1.00, 1.00] readonly property var standardAccel: [0.30, 0.00, 1.00, 1.00, 1.00, 1.00] + + // Used by AnimVariants for variant/effect logic + readonly property var expressiveDefaultSpatial: [0.38, 1.21, 0.22, 1, 1, 1] + readonly property var expressiveFastSpatial: [0.42, 1.67, 0.21, 0.9, 1, 1] + readonly property var expressiveEffects: [0.34, 0.8, 0.34, 1, 1, 1] } diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index be24edee..547e6e92 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -37,6 +37,18 @@ Singleton { Custom } + enum AnimationVariant { + Material, + Fluent, + Dynamic + } + + enum AnimationEffect { + Standard, // 0 — M3: scale-in, rises from below + Directional, // 1 — pure large slide, no scale + Depth // 2 — medium slide with deep depth scale pop + } + enum SuspendBehavior { Suspend, Hibernate, @@ -166,6 +178,10 @@ Singleton { property int modalCustomAnimationDuration: 150 property bool enableRippleEffects: true onEnableRippleEffectsChanged: saveSettings() + property int animationVariant: SettingsData.AnimationVariant.Material + onAnimationVariantChanged: saveSettings() + property int motionEffect: SettingsData.AnimationEffect.Standard + onMotionEffectChanged: saveSettings() property bool m3ElevationEnabled: true onM3ElevationEnabledChanged: saveSettings() property int m3ElevationIntensity: 12 diff --git a/quickshell/Common/Theme.qml b/quickshell/Common/Theme.qml index 5d66e1dc..18e022c2 100644 --- a/quickshell/Common/Theme.qml +++ b/quickshell/Common/Theme.qml @@ -960,6 +960,24 @@ Singleton { "expressiveEffects": [0.34, 0.8, 0.34, 1, 1, 1] } + // Delegates to AnimVariants.qml for curves, timing, scale, and offsets. + readonly property list variantEnterCurve: AnimVariants.variantEnterCurve + readonly property list variantExitCurve: AnimVariants.variantExitCurve + readonly property list variantModalEnterCurve: AnimVariants.variantModalEnterCurve + readonly property list variantModalExitCurve: AnimVariants.variantModalExitCurve + readonly property list variantPopoutEnterCurve: AnimVariants.variantPopoutEnterCurve + readonly property list variantPopoutExitCurve: AnimVariants.variantPopoutExitCurve + readonly property real variantEnterDurationFactor: AnimVariants.variantEnterDurationFactor + readonly property real variantExitDurationFactor: AnimVariants.variantExitDurationFactor + readonly property real variantOpacityDurationScale: AnimVariants.variantOpacityDurationScale + readonly property bool isDirectionalEffect: AnimVariants.isDirectionalEffect + readonly property bool isDepthEffect: AnimVariants.isDepthEffect + readonly property real effectScaleCollapsed: AnimVariants.effectScaleCollapsed + readonly property real effectAnimOffset: AnimVariants.effectAnimOffset + function variantDuration(baseDuration, entering) { return AnimVariants.variantDuration(baseDuration, entering); } + function variantExitCleanupPadding() { return AnimVariants.variantExitCleanupPadding(); } + function variantCloseInterval(baseDuration) { return AnimVariants.variantCloseInterval(baseDuration); } + readonly property var animationPresetDurations: { "none": 0, "short": 250, diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 90b0bbaa..19c96f3d 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -47,6 +47,8 @@ var SPEC = { modalAnimationSpeed: { def: 1 }, modalCustomAnimationDuration: { def: 150 }, enableRippleEffects: { def: true }, + animationVariant: { def: 0 }, + motionEffect: { def: 0 }, m3ElevationEnabled: { def: true }, m3ElevationIntensity: { def: 12 }, m3ElevationOpacity: { def: 30 }, diff --git a/quickshell/Modals/Common/DankModal.qml b/quickshell/Modals/Common/DankModal.qml index f955ac64..2ee246ad 100644 --- a/quickshell/Modals/Common/DankModal.qml +++ b/quickshell/Modals/Common/DankModal.qml @@ -26,10 +26,10 @@ Item { property bool closeOnBackgroundClick: true property string animationType: "scale" property int animationDuration: Theme.modalAnimationDuration - property real animationScaleCollapsed: 0.96 - property real animationOffset: Theme.spacingL - property list animationEnterCurve: Theme.expressiveCurves.expressiveDefaultSpatial - property list animationExitCurve: Theme.expressiveCurves.emphasized + property real animationScaleCollapsed: Theme.effectScaleCollapsed + property real animationOffset: Theme.effectAnimOffset + property list animationEnterCurve: Theme.variantModalEnterCurve + property list animationExitCurve: Theme.variantModalExitCurve property color backgroundColor: Theme.surfaceContainer property color borderColor: Theme.outlineMedium property real borderWidth: 0 @@ -44,11 +44,14 @@ Item { property bool keepPopoutsOpen: false property var customKeyboardFocus: null property bool useOverlayLayer: false + property real frozenMotionOffsetX: 0 + property real frozenMotionOffsetY: 0 readonly property alias contentWindow: contentWindow readonly property alias clickCatcher: clickCatcher readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab readonly property bool useBackground: showBackground && SettingsData.modalDarkenBackground readonly property bool useSingleWindow: CompositorService.isHyprland || useBackground + readonly property bool _needsFullscreenMotion: !useSingleWindow && (Theme.isDirectionalEffect || Theme.isDepthEffect) signal opened signal dialogClosed @@ -58,19 +61,34 @@ Item { function open() { closeTimer.stop(); + animationsEnabled = false; + frozenMotionOffsetX = modalContainer ? modalContainer.offsetX : 0; + frozenMotionOffsetY = modalContainer ? modalContainer.offsetY : animationOffset; + const focusedScreen = CompositorService.getFocusedScreen(); if (focusedScreen) { contentWindow.screen = focusedScreen; - if (!useSingleWindow) + if (!useSingleWindow && !_needsFullscreenMotion) clickCatcher.screen = focusedScreen; } + + if (Theme.isDirectionalEffect) { + if (!useSingleWindow && !_needsFullscreenMotion) + clickCatcher.visible = true; + contentWindow.visible = true; + } ModalManager.openModal(root); - shouldBeVisible = true; - if (!useSingleWindow) - clickCatcher.visible = true; - contentWindow.visible = true; - shouldHaveFocus = false; - Qt.callLater(() => shouldHaveFocus = Qt.binding(() => shouldBeVisible)); + + Qt.callLater(() => { + animationsEnabled = true; + shouldBeVisible = true; + if (!useSingleWindow && !_needsFullscreenMotion && !clickCatcher.visible) + clickCatcher.visible = true; + if (!contentWindow.visible) + contentWindow.visible = true; + shouldHaveFocus = false; + Qt.callLater(() => shouldHaveFocus = Qt.binding(() => shouldBeVisible)); + }); } function close() { @@ -87,7 +105,7 @@ Item { ModalManager.closeModal(root); closeTimer.stop(); contentWindow.visible = false; - if (!useSingleWindow) + if (!useSingleWindow && !_needsFullscreenMotion) clickCatcher.visible = false; dialogClosed(); Qt.callLater(() => animationsEnabled = true); @@ -123,7 +141,7 @@ Item { const newScreen = CompositorService.getFocusedScreen(); if (newScreen) { contentWindow.screen = newScreen; - if (!useSingleWindow) + if (!useSingleWindow && !_needsFullscreenMotion) clickCatcher.screen = newScreen; } } @@ -131,12 +149,12 @@ Item { Timer { id: closeTimer - interval: animationDuration + 50 + interval: Theme.variantCloseInterval(animationDuration) onTriggered: { if (shouldBeVisible) return; contentWindow.visible = false; - if (!useSingleWindow) + if (!useSingleWindow && !_needsFullscreenMotion) clickCatcher.visible = false; dialogClosed(); } @@ -145,7 +163,17 @@ Item { readonly property var shadowLevel: Theme.elevationLevel3 readonly property real shadowFallbackOffset: 6 readonly property real shadowRenderPadding: (root.enableShadow && Theme.elevationEnabled && SettingsData.modalElevationEnabled) ? Theme.elevationRenderPadding(shadowLevel, Theme.elevationLightDirection, shadowFallbackOffset, 8, 16) : 0 - readonly property real shadowMotionPadding: animationType === "slide" ? 30 : Math.max(0, animationOffset) + readonly property real shadowMotionPadding: { + if (_needsFullscreenMotion) + return 0; + if (animationType === "slide") + return 30; + if (Theme.isDirectionalEffect) + return Math.max(Math.max(0, animationOffset), Math.max(alignedWidth, alignedHeight) * 0.9); + if (Theme.isDepthEffect) + return Math.max(Math.max(0, animationOffset), Math.max(alignedWidth, alignedHeight) * 0.35); + return Math.max(0, animationOffset); + } readonly property real shadowBuffer: Theme.snap(shadowRenderPadding + shadowMotionPadding, dpr) readonly property real alignedWidth: Theme.px(modalWidth, dpr) readonly property real alignedHeight: Theme.px(modalHeight, dpr) @@ -246,19 +274,22 @@ Item { anchors { left: true top: true - right: root.useSingleWindow - bottom: root.useSingleWindow + right: root.useSingleWindow || root._needsFullscreenMotion + bottom: root.useSingleWindow || root._needsFullscreenMotion } + readonly property real actualMarginLeft: (root.useSingleWindow || root._needsFullscreenMotion) ? 0 : Math.max(0, Theme.snap(root.alignedX - shadowBuffer, dpr)) + readonly property real actualMarginTop: (root.useSingleWindow || root._needsFullscreenMotion) ? 0 : Math.max(0, Theme.snap(root.alignedY - shadowBuffer, dpr)) + WlrLayershell.margins { - left: root.useSingleWindow ? 0 : Math.max(0, Theme.snap(root.alignedX - shadowBuffer, dpr)) - top: root.useSingleWindow ? 0 : Math.max(0, Theme.snap(root.alignedY - shadowBuffer, dpr)) + left: actualMarginLeft + top: actualMarginTop right: 0 bottom: 0 } - implicitWidth: root.useSingleWindow ? 0 : root.alignedWidth + (shadowBuffer * 2) - implicitHeight: root.useSingleWindow ? 0 : root.alignedHeight + (shadowBuffer * 2) + implicitWidth: (root.useSingleWindow || root._needsFullscreenMotion) ? 0 : root.alignedWidth + (shadowBuffer * 2) + implicitHeight: (root.useSingleWindow || root._needsFullscreenMotion) ? 0 : root.alignedHeight + (shadowBuffer * 2) onVisibleChanged: { if (visible) { @@ -273,7 +304,7 @@ Item { MouseArea { anchors.fill: parent - enabled: root.useSingleWindow && root.closeOnBackgroundClick && root.shouldBeVisible + enabled: (root.useSingleWindow || root._needsFullscreenMotion) && root.closeOnBackgroundClick && root.shouldBeVisible z: -2 onClicked: root.backgroundClicked() } @@ -286,9 +317,9 @@ Item { visible: root.useBackground Behavior on opacity { - enabled: root.animationsEnabled + enabled: root.animationsEnabled && !Theme.isDirectionalEffect DankAnim { - duration: root.animationDuration + duration: Math.round(Theme.variantDuration(root.animationDuration, root.shouldBeVisible) * Theme.variantOpacityDurationScale) easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve } } @@ -296,15 +327,15 @@ Item { Item { id: modalContainer - x: root.useSingleWindow ? root.alignedX : shadowBuffer - y: root.useSingleWindow ? root.alignedY : shadowBuffer + x: (root.useSingleWindow ? root.alignedX : (root.alignedX - contentWindow.actualMarginLeft)) + Theme.snap(animX, root.dpr) + y: (root.useSingleWindow ? root.alignedY : (root.alignedY - contentWindow.actualMarginTop)) + Theme.snap(animY, root.dpr) width: root.alignedWidth height: root.alignedHeight MouseArea { anchors.fill: parent - enabled: root.useSingleWindow && root.shouldBeVisible + enabled: (root.useSingleWindow || root._needsFullscreenMotion) && root.shouldBeVisible hoverEnabled: false acceptedButtons: Qt.AllButtons onPressed: mouse.accepted = true @@ -313,29 +344,92 @@ Item { } readonly property bool slide: root.animationType === "slide" - readonly property real offsetX: slide ? 15 : 0 - readonly property real offsetY: slide ? -30 : root.animationOffset - - property real animX: 0 - property real animY: 0 - property real scaleValue: root.animationScaleCollapsed - - onOffsetXChanged: animX = Theme.snap(root.shouldBeVisible ? 0 : offsetX, root.dpr) - onOffsetYChanged: animY = Theme.snap(root.shouldBeVisible ? 0 : offsetY, root.dpr) - - Connections { - target: root - function onShouldBeVisibleChanged() { - modalContainer.animX = Theme.snap(root.shouldBeVisible ? 0 : modalContainer.offsetX, root.dpr); - modalContainer.animY = Theme.snap(root.shouldBeVisible ? 0 : modalContainer.offsetY, root.dpr); - modalContainer.scaleValue = root.shouldBeVisible ? 1.0 : root.animationScaleCollapsed; + readonly property bool directionalEffect: Theme.isDirectionalEffect + readonly property bool depthEffect: Theme.isDepthEffect + readonly property real directionalTravel: Math.max(root.animationOffset, Math.max(root.alignedWidth, root.alignedHeight) * 0.8) + readonly property real depthTravel: Math.max(root.animationOffset * 0.8, 36) + readonly property real customAnchorX: root.alignedX + root.alignedWidth * 0.5 + readonly property real customAnchorY: root.alignedY + root.alignedHeight * 0.5 + readonly property real customDistLeft: customAnchorX + readonly property real customDistRight: root.screenWidth - customAnchorX + readonly property real customDistTop: customAnchorY + readonly property real customDistBottom: root.screenHeight - customAnchorY + readonly property real offsetX: { + if (slide && !directionalEffect && !depthEffect) + return 15; + if (directionalEffect) { + switch (root.positioning) { + case "top-right": + return 0; + case "custom": + if (customDistLeft <= customDistRight && customDistLeft <= customDistTop && customDistLeft <= customDistBottom) + return -directionalTravel; + if (customDistRight <= customDistTop && customDistRight <= customDistBottom) + return directionalTravel; + return 0; + default: + return 0; + } } + if (depthEffect) { + switch (root.positioning) { + case "top-right": + return 0; + case "custom": + if (customDistLeft <= customDistRight && customDistLeft <= customDistTop && customDistLeft <= customDistBottom) + return -depthTravel; + if (customDistRight <= customDistTop && customDistRight <= customDistBottom) + return depthTravel; + return 0; + default: + return 0; + } + } + return 0; } + readonly property real offsetY: { + if (slide && !directionalEffect && !depthEffect) + return -30; + if (directionalEffect) { + switch (root.positioning) { + case "top-right": + return -Math.max(directionalTravel * 0.65, 96); + case "custom": + if (customDistTop <= customDistBottom && customDistTop <= customDistLeft && customDistTop <= customDistRight) + return -directionalTravel; + if (customDistBottom <= customDistLeft && customDistBottom <= customDistRight) + return directionalTravel; + return 0; + default: + // Default to sliding down from top when centered + return -Math.max(directionalTravel, root.screenHeight * 0.24); + } + } + if (depthEffect) { + switch (root.positioning) { + case "top-right": + return -depthTravel * 0.75; + case "custom": + if (customDistTop <= customDistBottom && customDistTop <= customDistLeft && customDistTop <= customDistRight) + return -depthTravel; + if (customDistBottom <= customDistLeft && customDistBottom <= customDistRight) + return depthTravel; + return depthTravel * 0.45; + default: + return -depthTravel; + } + } + return root.animationOffset; + } + + property real animX: root.shouldBeVisible ? 0 : root.frozenMotionOffsetX + property real animY: root.shouldBeVisible ? 0 : root.frozenMotionOffsetY + property real scaleValue: root.shouldBeVisible ? 1.0 : root.animationScaleCollapsed Behavior on animX { enabled: root.animationsEnabled DankAnim { - duration: root.animationDuration + duration: Theme.variantDuration(root.animationDuration, root.shouldBeVisible) easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve } } @@ -343,7 +437,7 @@ Item { Behavior on animY { enabled: root.animationsEnabled DankAnim { - duration: root.animationDuration + duration: Theme.variantDuration(root.animationDuration, root.shouldBeVisible) easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve } } @@ -351,7 +445,7 @@ Item { Behavior on scaleValue { enabled: root.animationsEnabled DankAnim { - duration: root.animationDuration + duration: Theme.variantDuration(root.animationDuration, root.shouldBeVisible) easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve } } @@ -367,15 +461,14 @@ Item { id: animatedContent anchors.fill: parent clip: false - opacity: root.shouldBeVisible ? 1 : 0 + opacity: Theme.isDirectionalEffect ? 1 : (root.shouldBeVisible ? 1 : 0) scale: modalContainer.scaleValue - x: Theme.snap(modalContainer.animX, root.dpr) + (parent.width - width) * (1 - modalContainer.scaleValue) * 0.5 - y: Theme.snap(modalContainer.animY, root.dpr) + (parent.height - height) * (1 - modalContainer.scaleValue) * 0.5 + transformOrigin: Item.Center Behavior on opacity { - enabled: root.animationsEnabled + enabled: root.animationsEnabled && !Theme.isDirectionalEffect NumberAnimation { - duration: animationDuration + duration: Math.round(Theme.variantDuration(animationDuration, root.shouldBeVisible) * Theme.variantOpacityDurationScale) easing.type: Easing.BezierSpline easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve } diff --git a/quickshell/Modals/DankLauncherV2/DankLauncherV2Modal.qml b/quickshell/Modals/DankLauncherV2/DankLauncherV2Modal.qml index 9e60ff36..781875aa 100644 --- a/quickshell/Modals/DankLauncherV2/DankLauncherV2Modal.qml +++ b/quickshell/Modals/DankLauncherV2/DankLauncherV2Modal.qml @@ -14,6 +14,7 @@ Item { property bool spotlightOpen: false property bool keyboardActive: false property bool contentVisible: false + readonly property bool launcherMotionVisible: Theme.isDirectionalEffect ? spotlightOpen : _motionActive property var spotlightContent: launcherContentLoader.item property bool openedFromOverview: false property bool isClosing: false @@ -23,8 +24,14 @@ Item { property string _pendingMode: "" readonly property bool unloadContentOnClose: SettingsData.dankLauncherV2UnloadOnClose + // Animation state — matches DankPopout/DankModal pattern + property bool animationsEnabled: true + property bool _motionActive: false + property real _frozenMotionX: 0 + property real _frozenMotionY: 0 + readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab - readonly property var effectiveScreen: launcherWindow.screen + readonly property var effectiveScreen: contentWindow.screen readonly property real screenWidth: effectiveScreen?.width ?? 1920 readonly property real screenHeight: effectiveScreen?.height ?? 1080 readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1 @@ -78,6 +85,34 @@ Item { } readonly property int borderWidth: SettingsData.dankLauncherV2BorderEnabled ? SettingsData.dankLauncherV2BorderThickness : 0 + // Shadow padding for the content window (render padding only, no motion padding) + readonly property var shadowLevel: Theme.elevationLevel3 + readonly property real shadowFallbackOffset: 6 + readonly property real shadowRenderPadding: (Theme.elevationEnabled && SettingsData.modalElevationEnabled) ? Theme.elevationRenderPadding(shadowLevel, Theme.elevationLightDirection, shadowFallbackOffset, 8, 16) : 0 + readonly property real shadowPad: Theme.snap(shadowRenderPadding, dpr) + readonly property real alignedWidth: Theme.px(modalWidth, dpr) + readonly property real alignedHeight: Theme.px(modalHeight, dpr) + readonly property real alignedX: Theme.snap(modalX, dpr) + readonly property real alignedY: Theme.snap(modalY, dpr) + + // For directional/depth: window extends from screen top (content slides within) + // For standard: small window tightly around the modal + shadow padding + readonly property bool _needsExtendedWindow: Theme.isDirectionalEffect || Theme.isDepthEffect + // Content window geometry + readonly property real _cwMarginLeft: Theme.snap(alignedX - shadowPad, dpr) + readonly property real _cwMarginTop: _needsExtendedWindow ? 0 : Theme.snap(alignedY - shadowPad, dpr) + readonly property real _cwWidth: alignedWidth + shadowPad * 2 + readonly property real _cwHeight: { + if (Theme.isDirectionalEffect) + return screenHeight + shadowPad; + if (Theme.isDepthEffect) + return alignedY + alignedHeight + shadowPad; + return alignedHeight + shadowPad * 2; + } + // Where the content container sits inside the content window + readonly property real _ccX: shadowPad + readonly property real _ccY: _needsExtendedWindow ? alignedY : shadowPad + signal dialogClosed function _ensureContentLoadedAndInitialize(query, mode) { @@ -97,7 +132,8 @@ Item { if (!spotlightContent) return; contentVisible = true; - spotlightContent.searchField.forceActiveFocus(); + // NOTE: forceActiveFocus() is deliberately NOT called here. + // It is deferred to after animation starts to avoid compositor IPC stalls. if (spotlightContent.searchField) { spotlightContent.searchField.text = query; @@ -130,40 +166,59 @@ Item { } } - function show() { + function _openCommon(query, mode) { closeCleanupTimer.stop(); isClosing = false; openedFromOverview = false; - var focusedScreen = CompositorService.getFocusedScreen(); - if (focusedScreen) - launcherWindow.screen = focusedScreen; + // Disable animations so the snap is instant + animationsEnabled = false; - spotlightOpen = true; - keyboardActive = true; + // Freeze the collapsed offsets (they depend on height which could change) + _frozenMotionX = contentContainer ? contentContainer.collapsedMotionX : 0; + _frozenMotionY = contentContainer ? contentContainer.collapsedMotionY : (Theme.isDirectionalEffect ? Math.max(root.screenHeight - root._ccY + root.shadowPad, Theme.effectAnimOffset * 1.1) : -Theme.effectAnimOffset); + + var focusedScreen = CompositorService.getFocusedScreen(); + if (focusedScreen) { + backgroundWindow.screen = focusedScreen; + contentWindow.screen = focusedScreen; + } + + // _motionActive = false ensures motionX/Y snap to frozen collapsed position + _motionActive = false; + + // Make windows visible but do NOT request keyboard focus yet ModalManager.openModal(root); + spotlightOpen = true; + backgroundWindow.visible = true; + contentWindow.visible = true; if (useHyprlandFocusGrab) focusGrab.active = true; - _ensureContentLoadedAndInitialize("", ""); + // Load content and initialize (but no forceActiveFocus — that's deferred) + _ensureContentLoadedAndInitialize(query || "", mode || ""); + + // Frame 1: enable animations and trigger enter motion + Qt.callLater(() => { + root.animationsEnabled = true; + root._motionActive = true; + + // Frame 2: request keyboard focus + activate search field + // Double-deferred to avoid compositor IPC competing with animation frames + Qt.callLater(() => { + root.keyboardActive = true; + if (root.spotlightContent && root.spotlightContent.searchField) + root.spotlightContent.searchField.forceActiveFocus(); + }); + }); + } + + function show() { + _openCommon("", ""); } function showWithQuery(query) { - closeCleanupTimer.stop(); - isClosing = false; - openedFromOverview = false; - - var focusedScreen = CompositorService.getFocusedScreen(); - if (focusedScreen) - launcherWindow.screen = focusedScreen; - - spotlightOpen = true; - keyboardActive = true; - ModalManager.openModal(root); - if (useHyprlandFocusGrab) - focusGrab.active = true; - - _ensureContentLoadedAndInitialize(query, ""); + _openCommon(query, ""); } function hide() { @@ -171,13 +226,17 @@ Item { return; openedFromOverview = false; isClosing = true; - contentVisible = false; + // For directional effects, defer contentVisible=false so content stays rendered during exit slide + if (!Theme.isDirectionalEffect) + contentVisible = false; + + // Trigger exit animation — Behaviors will animate motionX/Y to frozen collapsed position + _motionActive = false; keyboardActive = false; spotlightOpen = false; focusGrab.active = false; ModalManager.closeModal(root); - closeCleanupTimer.start(); } @@ -186,21 +245,7 @@ Item { } function showWithMode(mode) { - closeCleanupTimer.stop(); - isClosing = false; - openedFromOverview = false; - - var focusedScreen = CompositorService.getFocusedScreen(); - if (focusedScreen) - launcherWindow.screen = focusedScreen; - - spotlightOpen = true; - keyboardActive = true; - ModalManager.openModal(root); - if (useHyprlandFocusGrab) - focusGrab.active = true; - - _ensureContentLoadedAndInitialize("", mode); + _openCommon("", mode); } function toggleWithMode(mode) { @@ -221,10 +266,13 @@ Item { Timer { id: closeCleanupTimer - interval: Theme.modalAnimationDuration + 50 + interval: Theme.variantCloseInterval(Theme.modalAnimationDuration) repeat: false onTriggered: { isClosing = false; + contentVisible = false; + contentWindow.visible = false; + backgroundWindow.visible = false; if (root.unloadContentOnClose) launcherContentLoader.active = false; dialogClosed(); @@ -242,7 +290,7 @@ Item { HyprlandFocusGrab { id: focusGrab - windows: [launcherWindow] + windows: [contentWindow] active: false onCleared: { @@ -267,7 +315,7 @@ Item { if (Quickshell.screens.length === 0) return; - const screen = launcherWindow.screen; + const screen = contentWindow.screen; const screenName = screen?.name; let needsReset = !screen || !screenName; @@ -289,35 +337,24 @@ Item { return; root._windowEnabled = false; - launcherWindow.screen = newScreen; + backgroundWindow.screen = newScreen; + contentWindow.screen = newScreen; Qt.callLater(() => { root._windowEnabled = true; }); } } + // ── Background window: fullscreen, handles darkening + click-to-dismiss ── PanelWindow { - id: launcherWindow - visible: root._windowEnabled && (spotlightOpen || isClosing) + id: backgroundWindow + visible: false color: "transparent" - exclusionMode: ExclusionMode.Ignore - WlrLayershell.namespace: "dms:spotlight" - WlrLayershell.layer: { - switch (Quickshell.env("DMS_MODAL_LAYER")) { - case "bottom": - console.error("DankModal: 'bottom' layer is not valid for modals. Defaulting to 'top' layer."); - return WlrLayershell.Top; - case "background": - console.error("DankModal: 'background' layer is not valid for modals. Defaulting to 'top' layer."); - return WlrLayershell.Top; - case "overlay": - return WlrLayershell.Overlay; - default: - return WlrLayershell.Top; - } - } - WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None + WlrLayershell.namespace: "dms:spotlight:bg" + WlrLayershell.layer: WlrLayershell.Top + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None anchors { top: true @@ -327,11 +364,11 @@ Item { } mask: Region { - item: spotlightOpen ? fullScreenMask : null + item: (spotlightOpen || isClosing) ? bgFullScreenMask : null } Item { - id: fullScreenMask + id: bgFullScreenMask anchors.fill: parent } @@ -339,13 +376,14 @@ Item { id: backgroundDarken anchors.fill: parent color: "black" - opacity: contentVisible && SettingsData.modalDarkenBackground ? 0.5 : 0 - visible: contentVisible || opacity > 0 + opacity: launcherMotionVisible && SettingsData.modalDarkenBackground ? 0.5 : 0 + visible: launcherMotionVisible || opacity > 0 Behavior on opacity { + enabled: root.animationsEnabled && !Theme.isDirectionalEffect DankAnim { - duration: Theme.modalAnimationDuration - easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized + duration: Math.round(Theme.variantDuration(Theme.modalAnimationDuration, launcherMotionVisible) * Theme.variantOpacityDurationScale) + easing.bezierCurve: launcherMotionVisible ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve } } } @@ -353,49 +391,136 @@ Item { MouseArea { anchors.fill: parent enabled: spotlightOpen - onClicked: mouse => { - var contentX = modalContainer.x; - var contentY = modalContainer.y; - var contentW = modalContainer.width; - var contentH = modalContainer.height; + onClicked: root.hide() + } + } - if (mouse.x < contentX || mouse.x > contentX + contentW || mouse.y < contentY || mouse.y > contentY + contentH) { - root.hide(); - } + // ── Content window: SMALL, positioned with margins — only renders the modal area ── + PanelWindow { + id: contentWindow + visible: false + color: "transparent" + + WlrLayershell.namespace: "dms:spotlight" + WlrLayershell.layer: { + switch (Quickshell.env("DMS_MODAL_LAYER")) { + case "bottom": + console.error("DankLauncherV2Modal: 'bottom' layer is not valid for modals. Defaulting to 'top' layer."); + return WlrLayershell.Top; + case "background": + console.error("DankLauncherV2Modal: 'background' layer is not valid for modals. Defaulting to 'top' layer."); + return WlrLayershell.Top; + case "overlay": + return WlrLayershell.Overlay; + default: + return WlrLayershell.Top; } } + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None + + anchors { + left: true + top: true + } + + WlrLayershell.margins { + left: root._cwMarginLeft + top: root._cwMarginTop + } + + implicitWidth: root._cwWidth + implicitHeight: root._cwHeight + + mask: Region { + item: contentInputMask + } Item { - id: modalContainer - x: root.modalX - y: root.modalY - width: root.modalWidth - height: root.modalHeight - visible: contentVisible || opacity > 0 + id: contentInputMask + visible: false + x: contentContainer.x + contentWrapper.x + y: contentContainer.y + contentWrapper.y + width: root.alignedWidth + height: root.alignedHeight + } - opacity: contentVisible ? 1 : 0 - scale: contentVisible ? 1 : 0.96 - transformOrigin: Item.Center + Item { + id: contentContainer - Behavior on opacity { - DankAnim { - duration: Theme.modalAnimationDuration - easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized + // For directional/depth: contentContainer is at alignedY from window top (window starts at screen top) + // For standard: contentContainer is at shadowPad from window top (window starts near modal) + x: root._ccX + y: root._ccY + width: root.alignedWidth + height: root.alignedHeight + + readonly property bool directionalEffect: Theme.isDirectionalEffect + readonly property bool depthEffect: Theme.isDepthEffect + readonly property real collapsedMotionX: depthEffect ? Theme.effectAnimOffset * 0.25 : 0 + readonly property real collapsedMotionY: { + if (directionalEffect) + return Math.max(root.screenHeight - root._ccY + root.shadowPad, Theme.effectAnimOffset * 1.1); + if (depthEffect) + return -Math.max(Theme.effectAnimOffset * 0.85, 34); + return 0; + } + + // animX/animY are Behavior-animated — DankPopout pattern + property real animX: 0 + property real animY: 0 + property real scaleValue: Theme.isDirectionalEffect ? 1 : Theme.effectScaleCollapsed + + Component.onCompleted: { + animX = Theme.snap(root._motionActive ? 0 : collapsedMotionX, root.dpr); + animY = Theme.snap(root._motionActive ? 0 : collapsedMotionY, root.dpr); + scaleValue = root._motionActive ? 1.0 : (Theme.isDirectionalEffect ? 1 : Theme.effectScaleCollapsed); + } + + Connections { + target: root + function on_MotionActiveChanged() { + contentContainer.animX = Theme.snap(root._motionActive ? 0 : root._frozenMotionX, root.dpr); + contentContainer.animY = Theme.snap(root._motionActive ? 0 : root._frozenMotionY, root.dpr); + contentContainer.scaleValue = root._motionActive ? 1.0 : (Theme.isDirectionalEffect ? 1 : Theme.effectScaleCollapsed); } } - Behavior on scale { + Behavior on animX { + enabled: root.animationsEnabled DankAnim { - duration: Theme.modalAnimationDuration - easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized + duration: Theme.variantDuration(Theme.modalAnimationDuration, root._motionActive) + easing.bezierCurve: root._motionActive ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve } } + Behavior on animY { + enabled: root.animationsEnabled + DankAnim { + duration: Theme.variantDuration(Theme.modalAnimationDuration, root._motionActive) + easing.bezierCurve: root._motionActive ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve + } + } + + Behavior on scaleValue { + enabled: root.animationsEnabled && !Theme.isDirectionalEffect + DankAnim { + duration: Theme.variantDuration(Theme.modalAnimationDuration, root._motionActive) + easing.bezierCurve: root._motionActive ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve + } + } + + // Shadow mirrors contentWrapper position/scale/opacity ElevationShadow { id: launcherShadowLayer - anchors.fill: parent - level: Theme.elevationLevel3 - fallbackOffset: 6 + width: parent.width + height: parent.height + opacity: contentWrapper.opacity + scale: contentWrapper.scale + x: contentWrapper.x + y: contentWrapper.y + level: root.shadowLevel + fallbackOffset: root.shadowFallbackOffset targetColor: root.backgroundColor borderColor: root.borderColor borderWidth: root.borderWidth @@ -403,36 +528,56 @@ Item { shadowEnabled: Theme.elevationEnabled && SettingsData.modalElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1" } - MouseArea { - anchors.fill: parent - onPressed: mouse => mouse.accepted = true - } + // contentWrapper moves inside static contentContainer — DankPopout pattern + Item { + id: contentWrapper + width: parent.width + height: parent.height + opacity: Theme.isDirectionalEffect ? 1 : (launcherMotionVisible ? 1 : 0) + visible: opacity > 0 + scale: contentContainer.scaleValue + x: Theme.snap(contentContainer.animX + (parent.width - width) * (1 - contentContainer.scaleValue) * 0.5, root.dpr) + y: Theme.snap(contentContainer.animY + (parent.height - height) * (1 - contentContainer.scaleValue) * 0.5, root.dpr) - FocusScope { - anchors.fill: parent - focus: keyboardActive - - Loader { - id: launcherContentLoader - anchors.fill: parent - active: !root.unloadContentOnClose || root.spotlightOpen || root.isClosing || root.contentVisible || root._pendingInitialize - asynchronous: false - sourceComponent: LauncherContent { - focus: true - parentModal: root - } - - onLoaded: { - if (root._pendingInitialize) { - root._initializeAndShow(root._pendingQuery, root._pendingMode); - root._pendingInitialize = false; - } + Behavior on opacity { + enabled: root.animationsEnabled && !Theme.isDirectionalEffect + DankAnim { + duration: Math.round(Theme.variantDuration(Theme.modalAnimationDuration, launcherMotionVisible) * Theme.variantOpacityDurationScale) + easing.bezierCurve: launcherMotionVisible ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve } } - Keys.onEscapePressed: event => { - root.hide(); - event.accepted = true; + MouseArea { + anchors.fill: parent + onPressed: mouse => mouse.accepted = true + } + + FocusScope { + anchors.fill: parent + focus: keyboardActive + + Loader { + id: launcherContentLoader + anchors.fill: parent + active: !root.unloadContentOnClose || root.spotlightOpen || root.isClosing || root.contentVisible || root._pendingInitialize + asynchronous: false + sourceComponent: LauncherContent { + focus: true + parentModal: root + } + + onLoaded: { + if (root._pendingInitialize) { + root._initializeAndShow(root._pendingQuery, root._pendingMode); + root._pendingInitialize = false; + } + } + } + + Keys.onEscapePressed: event => { + root.hide(); + event.accepted = true; + } } } } diff --git a/quickshell/Modals/DankLauncherV2/LauncherContent.qml b/quickshell/Modals/DankLauncherV2/LauncherContent.qml index 799c209c..18034ad3 100644 --- a/quickshell/Modals/DankLauncherV2/LauncherContent.qml +++ b/quickshell/Modals/DankLauncherV2/LauncherContent.qml @@ -86,7 +86,7 @@ FocusScope { Controller { id: controller - active: root.parentModal?.spotlightOpen ?? true + active: root.parentModal ? (root.parentModal.spotlightOpen || root.parentModal.isClosing) : true viewModeContext: root.viewModeContext onItemExecuted: { @@ -462,7 +462,7 @@ FocusScope { showClearButton: true textColor: Theme.surfaceText font.pixelSize: Theme.fontSizeLarge - enabled: root.parentModal ? root.parentModal.spotlightOpen : true + enabled: root.parentModal ? (root.parentModal.spotlightOpen || root.parentModal.isClosing) : true placeholderText: "" ignoreUpDownKeys: true ignoreTabKeys: true @@ -548,7 +548,6 @@ FocusScope { } } } - } Item { @@ -697,7 +696,13 @@ FocusScope { Item { width: parent.width height: parent.height - searchField.height - categoryRow.height - fileFilterRow.height - actionPanel.height - Theme.spacingXS * ((categoryRow.visible ? 1 : 0) + (fileFilterRow.visible ? 1 : 0) + 2) - opacity: root.parentModal?.isClosing ? 0 : 1 + opacity: { + if (!root.parentModal) + return 1; + if (Theme.isDirectionalEffect && root.parentModal.isClosing) + return 1; + return root.parentModal.isClosing ? 0 : 1; + } ResultsList { id: resultsList diff --git a/quickshell/Modules/AppDrawer/AppDrawerPopout.qml b/quickshell/Modules/AppDrawer/AppDrawerPopout.qml index 2fc982c5..21c30870 100644 --- a/quickshell/Modules/AppDrawer/AppDrawerPopout.qml +++ b/quickshell/Modules/AppDrawer/AppDrawerPopout.qml @@ -106,7 +106,7 @@ DankPopout { QtObject { id: modalAdapter property bool spotlightOpen: appDrawerPopout.shouldBeVisible - property bool isClosing: false + property bool isClosing: appDrawerPopout.isClosing function hide() { appDrawerPopout.close(); diff --git a/quickshell/Modules/ControlCenter/ControlCenterPopout.qml b/quickshell/Modules/ControlCenter/ControlCenterPopout.qml index 0b9aa9ea..cb4486b4 100644 --- a/quickshell/Modules/ControlCenter/ControlCenterPopout.qml +++ b/quickshell/Modules/ControlCenter/ControlCenterPopout.qml @@ -126,9 +126,11 @@ DankPopout { z: 5000 Behavior on opacity { + enabled: !Theme.isDirectionalEffect NumberAnimation { - duration: 200 - easing.type: Easing.OutCubic + duration: Theme.shortDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: root.shouldBeVisible ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve } } } diff --git a/quickshell/Modules/DankBar/DankBarContent.qml b/quickshell/Modules/DankBar/DankBarContent.qml index fb0db4d9..9ed03eb2 100644 --- a/quickshell/Modules/DankBar/DankBarContent.qml +++ b/quickshell/Modules/DankBar/DankBarContent.qml @@ -1117,6 +1117,7 @@ Item { if (!notificationCenterLoader.item) { return; } + notificationCenterLoader.item.triggerScreen = barWindow.screen; const effectiveBarConfig = topBarContent.barConfig; const barPosition = barWindow.axis?.edge === "left" ? 2 : (barWindow.axis?.edge === "right" ? 3 : (barWindow.axis?.edge === "top" ? 0 : 1)); if (notificationCenterLoader.item.setBarContext) { diff --git a/quickshell/Modules/DankDash/DankDashPopout.qml b/quickshell/Modules/DankDash/DankDashPopout.qml index 03600971..ce1fcaa9 100644 --- a/quickshell/Modules/DankDash/DankDashPopout.qml +++ b/quickshell/Modules/DankDash/DankDashPopout.qml @@ -16,7 +16,6 @@ DankPopout { popupHeight: contentLoader.item ? contentLoader.item.implicitHeight : 500 triggerWidth: 80 screen: triggerScreen - shouldBeVisible: dashVisible property bool __focusArmed: false property bool __contentReady: false diff --git a/quickshell/Modules/DankDash/MediaDropdownOverlay.qml b/quickshell/Modules/DankDash/MediaDropdownOverlay.qml index 35dfe974..f04d6562 100644 --- a/quickshell/Modules/DankDash/MediaDropdownOverlay.qml +++ b/quickshell/Modules/DankDash/MediaDropdownOverlay.qml @@ -44,6 +44,43 @@ Item { property int __volumeHoverCount: 0 + readonly property bool directionalEffect: Theme.isDirectionalEffect + readonly property bool depthEffect: Theme.isDepthEffect + + function panelMotionX(panelWidth, active) { + if (active) + return 0; + if (directionalEffect) { + const travel = Math.max(Theme.effectAnimOffset, panelWidth * 0.85); + return isRightEdge ? -travel : travel; + } + if (depthEffect) { + const travel = Math.max(Theme.effectAnimOffset * 0.7, panelWidth * 0.32); + return isRightEdge ? -travel : travel; + } + return 0; + } + + function panelMotionY(panelType, panelHeight, active) { + if (active) + return 0; + if (directionalEffect) { + if (panelType === 2) + return panelHeight * 0.08; + if (panelType === 3) + return -panelHeight * 0.08; + return 0; + } + if (depthEffect) { + if (panelType === 2) + return panelHeight * 0.04; + if (panelType === 3) + return -panelHeight * 0.04; + return 0; + } + return 0; + } + function volumeAreaEntered() { __volumeHoverCount++; panelEntered(); @@ -62,30 +99,47 @@ Item { visible: dropdownType === 1 && volumeAvailable width: 60 height: 180 - x: isRightEdge ? anchorPos.x : anchorPos.x - width - y: anchorPos.y - height / 2 + x: (isRightEdge ? anchorPos.x : anchorPos.x - width) + panelMotionX(width, dropdownType === 1) + y: anchorPos.y - height / 2 + panelMotionY(1, height, dropdownType === 1) radius: Theme.cornerRadius * 2 color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.95) border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.3) border.width: 1 - opacity: dropdownType === 1 ? 1 : 0 - scale: dropdownType === 1 ? 1 : 0.96 + opacity: Theme.isDirectionalEffect ? 1 : (dropdownType === 1 ? 1 : 0) + scale: Theme.isDirectionalEffect ? 1 : (dropdownType === 1 ? 1 : Theme.effectScaleCollapsed) transformOrigin: isRightEdge ? Item.Left : Item.Right Behavior on opacity { - NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial - easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.expressiveDefaultSpatial + enabled: !Theme.isDirectionalEffect + DankAnim { + duration: Math.round(Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, dropdownType === 1) * Theme.variantOpacityDurationScale) + easing.bezierCurve: dropdownType === 1 ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve } } Behavior on scale { + enabled: !Theme.isDirectionalEffect NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, dropdownType === 1) easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.expressiveDefaultSpatial + easing.bezierCurve: dropdownType === 1 ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve + } + } + + Behavior on x { + NumberAnimation { + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, dropdownType === 1) + easing.type: Easing.BezierSpline + easing.bezierCurve: dropdownType === 1 ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve + } + } + + Behavior on y { + NumberAnimation { + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, dropdownType === 1) + easing.type: Easing.BezierSpline + easing.bezierCurve: dropdownType === 1 ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve } } @@ -197,33 +251,50 @@ Item { Rectangle { id: audioDevicesPanel - visible: dropdownType === 2 + visible: dropdownType === 2 && activePlayer !== null width: 280 height: Math.max(200, Math.min(280, availableDevices.length * 50 + 100)) - x: isRightEdge ? anchorPos.x : anchorPos.x - width - y: anchorPos.y - height / 2 + x: (isRightEdge ? anchorPos.x : anchorPos.x - width) + panelMotionX(width, dropdownType === 2) + y: anchorPos.y - height / 2 + panelMotionY(2, height, dropdownType === 2) radius: Theme.cornerRadius * 2 color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.98) border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.6) border.width: 2 - opacity: dropdownType === 2 ? 1 : 0 - scale: dropdownType === 2 ? 1 : 0.96 + opacity: Theme.isDirectionalEffect ? 1 : (dropdownType === 2 ? 1 : 0) + scale: Theme.isDirectionalEffect ? 1 : (dropdownType === 2 ? 1 : Theme.effectScaleCollapsed) transformOrigin: isRightEdge ? Item.Left : Item.Right Behavior on opacity { - NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial - easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.expressiveDefaultSpatial + enabled: !Theme.isDirectionalEffect + DankAnim { + duration: Math.round(Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, dropdownType === 2) * Theme.variantOpacityDurationScale) + easing.bezierCurve: dropdownType === 2 ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve } } Behavior on scale { + enabled: !Theme.isDirectionalEffect NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, dropdownType === 2) easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.expressiveDefaultSpatial + easing.bezierCurve: dropdownType === 2 ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve + } + } + + Behavior on x { + NumberAnimation { + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, dropdownType === 2) + easing.type: Easing.BezierSpline + easing.bezierCurve: dropdownType === 2 ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve + } + } + + Behavior on y { + NumberAnimation { + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, dropdownType === 2) + easing.type: Easing.BezierSpline + easing.bezierCurve: dropdownType === 2 ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve } } @@ -354,30 +425,47 @@ Item { visible: dropdownType === 3 width: 240 height: Math.max(180, Math.min(240, (allPlayers?.length || 0) * 50 + 80)) - x: isRightEdge ? anchorPos.x : anchorPos.x - width - y: anchorPos.y - height / 2 + x: (isRightEdge ? anchorPos.x : anchorPos.x - width) + panelMotionX(width, dropdownType === 3) + y: anchorPos.y - height / 2 + panelMotionY(3, height, dropdownType === 3) radius: Theme.cornerRadius * 2 color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.98) border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.6) border.width: 2 - opacity: dropdownType === 3 ? 1 : 0 - scale: dropdownType === 3 ? 1 : 0.96 + opacity: Theme.isDirectionalEffect ? 1 : (dropdownType === 3 ? 1 : 0) + scale: Theme.isDirectionalEffect ? 1 : (dropdownType === 3 ? 1 : Theme.effectScaleCollapsed) transformOrigin: isRightEdge ? Item.Left : Item.Right Behavior on opacity { - NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial - easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.expressiveDefaultSpatial + enabled: !Theme.isDirectionalEffect + DankAnim { + duration: Math.round(Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, dropdownType === 3) * Theme.variantOpacityDurationScale) + easing.bezierCurve: dropdownType === 3 ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve } } Behavior on scale { + enabled: !Theme.isDirectionalEffect NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, dropdownType === 3) easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.expressiveDefaultSpatial + easing.bezierCurve: dropdownType === 3 ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve + } + } + + Behavior on x { + NumberAnimation { + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, dropdownType === 3) + easing.type: Easing.BezierSpline + easing.bezierCurve: dropdownType === 3 ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve + } + } + + Behavior on y { + NumberAnimation { + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, dropdownType === 3) + easing.type: Easing.BezierSpline + easing.bezierCurve: dropdownType === 3 ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve } } diff --git a/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml b/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml index 3f9c2f80..ad11e529 100644 --- a/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml +++ b/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml @@ -39,11 +39,9 @@ DankPopout { } } - popupWidth: triggerScreen ? Math.min(500, Math.max(380, triggerScreen.width - 48)) : 400 + popupWidth: 400 popupHeight: stablePopupHeight positioning: "" - animationScaleCollapsed: 0.94 - animationOffset: 0 suspendShadowWhileResizing: false screen: triggerScreen diff --git a/quickshell/Modules/Notifications/Popup/NotificationPopup.qml b/quickshell/Modules/Notifications/Popup/NotificationPopup.qml index 823106f1..50ef0013 100644 --- a/quickshell/Modules/Notifications/Popup/NotificationPopup.qml +++ b/quickshell/Modules/Notifications/Popup/NotificationPopup.qml @@ -24,6 +24,29 @@ PanelWindow { property real _lastReportedAlignedHeight: -1 property real _storedTopMargin: 0 property real _storedBottomMargin: 0 + readonly property bool directionalEffect: Theme.isDirectionalEffect + readonly property bool depthEffect: Theme.isDepthEffect + readonly property real entryTravel: { + const base = Math.abs(Theme.effectAnimOffset); + if (directionalEffect) { + if (isCenterPosition) + return Math.max(base, Math.round(content.height * 1.1)); + return Math.max(base, Math.round(content.width * 0.95)); + } + if (depthEffect) + return Math.max(base, 44); + return base; + } + readonly property real exitTravel: { + if (directionalEffect) { + if (isCenterPosition) + return content.height + entryTravel; + return content.width + entryTravel; + } + if (depthEffect) + return Math.round(entryTravel * 1.35); + return Anims.slidePx; + } readonly property string clearText: I18n.tr("Dismiss") property bool descriptionExpanded: false readonly property bool hasExpandableBody: (notificationData?.htmlBody || "").replace(/<[^>]*>/g, "").trim().length > 0 @@ -137,9 +160,9 @@ PanelWindow { enabled: !exiting && !_isDestroying NumberAnimation { id: implicitHeightAnim - duration: descriptionExpanded ? Theme.notificationExpandDuration : Theme.notificationCollapseDuration + duration: Theme.variantDuration(descriptionExpanded ? Theme.notificationExpandDuration : Theme.notificationCollapseDuration, descriptionExpanded) easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.emphasized + easing.bezierCurve: descriptionExpanded ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve } } @@ -911,9 +934,9 @@ PanelWindow { if (isCenterPosition) return 0; const isLeft = SettingsData.notificationPopupPosition === SettingsData.Position.Left || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom; - return isLeft ? -Anims.slidePx : Anims.slidePx; + return isLeft ? -entryTravel : entryTravel; } - y: isTopCenter ? -Anims.slidePx : isBottomCenter ? Anims.slidePx : 0 + y: isTopCenter ? -entryTravel : isBottomCenter ? entryTravel : 0 } ] } @@ -925,16 +948,16 @@ PanelWindow { property: isCenterPosition ? "y" : "x" from: { if (isTopCenter) - return -Anims.slidePx; + return -entryTravel; if (isBottomCenter) - return Anims.slidePx; + return entryTravel; const isLeft = SettingsData.notificationPopupPosition === SettingsData.Position.Left || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom; - return isLeft ? -Anims.slidePx : Anims.slidePx; + return isLeft ? -entryTravel : entryTravel; } to: 0 - duration: Theme.notificationEnterDuration + duration: Theme.variantDuration(Theme.notificationEnterDuration, true) easing.type: Easing.BezierSpline - easing.bezierCurve: isCenterPosition ? Theme.expressiveCurves.standardDecel : Theme.expressiveCurves.emphasizedDecel + easing.bezierCurve: Theme.variantPopoutEnterCurve onStopped: { if (!win.exiting && !win._isDestroying) { if (isCenterPosition) { @@ -959,35 +982,35 @@ PanelWindow { from: 0 to: { if (isTopCenter) - return -Anims.slidePx; + return -exitTravel; if (isBottomCenter) - return Anims.slidePx; + return exitTravel; const isLeft = SettingsData.notificationPopupPosition === SettingsData.Position.Left || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom; - return isLeft ? -Anims.slidePx : Anims.slidePx; + return isLeft ? -exitTravel : exitTravel; } - duration: Theme.notificationExitDuration + duration: Theme.variantDuration(Theme.notificationExitDuration, false) easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.emphasizedAccel + easing.bezierCurve: Theme.variantPopoutExitCurve } NumberAnimation { target: content property: "opacity" from: 1 - to: 0 - duration: Theme.notificationExitDuration + to: Theme.isDirectionalEffect ? 1 : 0 + duration: Theme.variantDuration(Theme.notificationExitDuration, false) easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.standardAccel + easing.bezierCurve: Theme.variantPopoutExitCurve } NumberAnimation { target: content property: "scale" from: 1 - to: 0.98 - duration: Theme.notificationExitDuration + to: Theme.isDirectionalEffect ? 1 : Theme.effectScaleCollapsed + duration: Theme.variantDuration(Theme.notificationExitDuration, false) easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.emphasizedAccel + easing.bezierCurve: Theme.variantPopoutExitCurve } } diff --git a/quickshell/Modules/Settings/TypographyMotionTab.qml b/quickshell/Modules/Settings/TypographyMotionTab.qml index 8648acb4..6cbd361e 100644 --- a/quickshell/Modules/Settings/TypographyMotionTab.qml +++ b/quickshell/Modules/Settings/TypographyMotionTab.qml @@ -55,6 +55,144 @@ Item { anchors.horizontalCenter: parent.horizontalCenter spacing: Theme.spacingXL + SettingsCard { + tab: "typography" + tags: ["animation", "variant", "style", "slide", "fluent", "dynamic", "motion"] + title: I18n.tr("Animation Style") + settingKey: "animationVariant" + iconName: "auto_awesome_motion" + + Item { + width: parent.width + height: animVariantGroup.implicitHeight + clip: true + + DankButtonGroup { + id: animVariantGroup + anchors.horizontalCenter: parent.horizontalCenter + buttonPadding: parent.width < 480 ? Theme.spacingS : Theme.spacingL + minButtonWidth: parent.width < 480 ? 64 : 96 + textSize: parent.width < 480 ? Theme.fontSizeSmall : Theme.fontSizeMedium + model: [I18n.tr("Material"), I18n.tr("Fluent"), I18n.tr("Dynamic")] + selectionMode: "single" + currentIndex: SettingsData.animationVariant + onSelectionChanged: (index, selected) => { + if (!selected) + return; + SettingsData.set("animationVariant", index); + } + + Connections { + target: SettingsData + function onAnimationVariantChanged() { + animVariantGroup.currentIndex = SettingsData.animationVariant; + } + } + } + } + + Rectangle { + width: parent.width + height: 1 + color: Theme.outline + opacity: 0.15 + } + + Item { + width: parent.width + height: variantDescription.implicitHeight + Theme.spacingS * 2 + + StyledText { + id: variantDescription + x: Theme.spacingM + y: Theme.spacingS + width: parent.width - Theme.spacingM * 2 + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + text: { + switch (SettingsData.animationVariant) { + case 1: + return I18n.tr("Fluent: Smooth cubic deceleration in, quick snap out — clean, elegant curves."); + case 2: + return I18n.tr("Dynamic: Spring bezier with overshoot — entry briefly exceeds its target then settles. Expressive and alive."); + default: + return I18n.tr("Material: Material Design 3 Expressive bezier curves. The DMS default feel."); + } + } + } + } + } + + SettingsCard { + tab: "typography" + tags: ["animation", "motion", "effect", "slide", "directional", "depth", "spring", "physics"] + title: I18n.tr("Motion Effects") + settingKey: "motionEffect" + iconName: "motion_photos_on" + + Item { + width: parent.width + height: motionEffectGroup.implicitHeight + clip: true + + DankButtonGroup { + id: motionEffectGroup + anchors.horizontalCenter: parent.horizontalCenter + buttonPadding: parent.width < 480 ? Theme.spacingS : Theme.spacingL + minButtonWidth: parent.width < 480 ? 64 : 96 + textSize: parent.width < 480 ? Theme.fontSizeSmall : Theme.fontSizeMedium + model: [I18n.tr("Standard"), I18n.tr("Directional"), I18n.tr("Depth")] + selectionMode: "single" + currentIndex: SettingsData.motionEffect + onSelectionChanged: (index, selected) => { + if (!selected) + return; + SettingsData.set("motionEffect", index); + } + + Connections { + target: SettingsData + function onMotionEffectChanged() { + motionEffectGroup.currentIndex = SettingsData.motionEffect; + } + } + } + } + + Rectangle { + width: parent.width + height: 1 + color: Theme.outline + opacity: 0.15 + } + + Item { + width: parent.width + height: motionEffectDescription.implicitHeight + Theme.spacingS * 2 + + StyledText { + id: motionEffectDescription + x: Theme.spacingM + y: Theme.spacingS + width: parent.width - Theme.spacingM * 2 + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + text: { + switch (SettingsData.motionEffect) { + case 1: + return I18n.tr("Directional: Panels glide in from a larger distance at full size — no scale change, pure clean motion."); + case 2: + return I18n.tr("Depth: Panels scale up from small as they slide in — a dramatic pop-forward depth effect."); + default: + return I18n.tr("Standard: Classic Material Design 3 — panels rise from below with a subtle scale. The DMS default."); + } + } + } + } + } + SettingsCard { tab: "typography" tags: ["font", "family", "text", "typography"] diff --git a/quickshell/Modules/WorkspaceOverlays/HyprlandOverview.qml b/quickshell/Modules/WorkspaceOverlays/HyprlandOverview.qml index 19330352..bc32e853 100644 --- a/quickshell/Modules/WorkspaceOverlays/HyprlandOverview.qml +++ b/quickshell/Modules/WorkspaceOverlays/HyprlandOverview.qml @@ -121,9 +121,9 @@ Scope { Behavior on opacity { NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewScope.overviewOpen) easing.type: Easing.BezierSpline - easing.bezierCurve: overviewScope.overviewOpen ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized + easing.bezierCurve: overviewScope.overviewOpen ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve } } @@ -154,45 +154,69 @@ Scope { id: scaleTransform origin.x: contentContainer.width / 2 origin.y: contentContainer.height / 2 - xScale: overviewScope.overviewOpen ? 1 : 0.96 - yScale: overviewScope.overviewOpen ? 1 : 0.96 + xScale: overviewScope.overviewOpen ? 1 : Theme.effectScaleCollapsed + yScale: overviewScope.overviewOpen ? 1 : Theme.effectScaleCollapsed Behavior on xScale { NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewScope.overviewOpen) easing.type: Easing.BezierSpline - easing.bezierCurve: overviewScope.overviewOpen ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized + easing.bezierCurve: overviewScope.overviewOpen ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve } } Behavior on yScale { NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewScope.overviewOpen) easing.type: Easing.BezierSpline - easing.bezierCurve: overviewScope.overviewOpen ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized + easing.bezierCurve: overviewScope.overviewOpen ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve } } } Translate { id: motionTransform - x: 0 - y: overviewScope.overviewOpen ? 0 : Theme.spacingL + x: { + if (overviewScope.overviewOpen) + return 0; + if (Theme.isDirectionalEffect) + return 0; + if (Theme.isDepthEffect) + return Theme.effectAnimOffset * 0.25; + return 0; + } + y: { + if (overviewScope.overviewOpen) + return 0; + if (Theme.isDirectionalEffect) + return -Math.max(contentContainer.height * 0.8, Theme.effectAnimOffset * 1.1); + if (Theme.isDepthEffect) + return Math.max(Theme.effectAnimOffset * 0.85, 28); + return Theme.effectAnimOffset; + } + + Behavior on x { + NumberAnimation { + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewScope.overviewOpen) + easing.type: Easing.BezierSpline + easing.bezierCurve: overviewScope.overviewOpen ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve + } + } Behavior on y { NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewScope.overviewOpen) easing.type: Easing.BezierSpline - easing.bezierCurve: overviewScope.overviewOpen ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized + easing.bezierCurve: overviewScope.overviewOpen ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve } } } Behavior on opacity { NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewScope.overviewOpen) easing.type: Easing.BezierSpline - easing.bezierCurve: overviewScope.overviewOpen ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized + easing.bezierCurve: overviewScope.overviewOpen ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve } } diff --git a/quickshell/Modules/WorkspaceOverlays/NiriOverviewOverlay.qml b/quickshell/Modules/WorkspaceOverlays/NiriOverviewOverlay.qml index c7b1bf79..f72c1106 100644 --- a/quickshell/Modules/WorkspaceOverlays/NiriOverviewOverlay.qml +++ b/quickshell/Modules/WorkspaceOverlays/NiriOverviewOverlay.qml @@ -202,8 +202,18 @@ Scope { Item { id: spotlightContainer - x: Theme.snap((parent.width - width) / 2, overlayWindow.dpr) - y: Theme.snap((parent.height - height) / 2, overlayWindow.dpr) + readonly property bool directionalEffect: Theme.isDirectionalEffect + readonly property bool depthEffect: Theme.isDepthEffect + readonly property real collapsedMotionX: depthEffect ? Theme.effectAnimOffset * 0.25 : 0 + readonly property real collapsedMotionY: { + if (directionalEffect) + return Math.max(height * 0.85, Theme.effectAnimOffset * 1.1); + if (depthEffect) + return Math.max(Theme.effectAnimOffset * 0.8, 30); + return 0; + } + x: Theme.snap((parent.width - width) / 2 + (overlayWindow.shouldShowSpotlight ? 0 : collapsedMotionX), overlayWindow.dpr) + y: Theme.snap((parent.height - height) / 2 + (overlayWindow.shouldShowSpotlight ? 0 : collapsedMotionY), overlayWindow.dpr) readonly property int baseWidth: { switch (SettingsData.dankLauncherV2Size) { @@ -234,8 +244,8 @@ Scope { readonly property bool animatingOut: niriOverviewScope.isClosing && overlayWindow.isSpotlightScreen - scale: overlayWindow.shouldShowSpotlight ? 1.0 : 0.96 - opacity: overlayWindow.shouldShowSpotlight ? 1 : 0 + scale: Theme.isDirectionalEffect ? 1 : (overlayWindow.shouldShowSpotlight ? 1.0 : Theme.effectScaleCollapsed) + opacity: Theme.isDirectionalEffect ? 1 : (overlayWindow.shouldShowSpotlight ? 1 : 0) visible: overlayWindow.shouldShowSpotlight || animatingOut enabled: overlayWindow.shouldShowSpotlight @@ -245,10 +255,11 @@ Scope { Behavior on scale { id: scaleAnimation + enabled: !Theme.isDirectionalEffect NumberAnimation { - duration: Theme.expressiveDurations.fast + duration: Theme.variantDuration(Theme.expressiveDurations.fast, overlayWindow.shouldShowSpotlight) easing.type: Easing.BezierSpline - easing.bezierCurve: spotlightContainer.visible ? Theme.expressiveCurves.expressiveFastSpatial : Theme.expressiveCurves.standardAccel + easing.bezierCurve: spotlightContainer.visible ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve onRunningChanged: { if (running || !spotlightContainer.animatingOut) return; @@ -258,10 +269,27 @@ Scope { } Behavior on opacity { + enabled: !Theme.isDirectionalEffect NumberAnimation { - duration: Theme.expressiveDurations.fast + duration: Theme.variantDuration(Theme.expressiveDurations.fast, overlayWindow.shouldShowSpotlight) easing.type: Easing.BezierSpline - easing.bezierCurve: spotlightContainer.visible ? Theme.expressiveCurves.expressiveFastSpatial : Theme.expressiveCurves.standardAccel + easing.bezierCurve: spotlightContainer.visible ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve + } + } + + Behavior on x { + NumberAnimation { + duration: Theme.variantDuration(Theme.expressiveDurations.fast, overlayWindow.shouldShowSpotlight) + easing.type: Easing.BezierSpline + easing.bezierCurve: spotlightContainer.visible ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve + } + } + + Behavior on y { + NumberAnimation { + duration: Theme.variantDuration(Theme.expressiveDurations.fast, overlayWindow.shouldShowSpotlight) + easing.type: Easing.BezierSpline + easing.bezierCurve: spotlightContainer.visible ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve } } diff --git a/quickshell/Modules/WorkspaceOverlays/OverviewWindow.qml b/quickshell/Modules/WorkspaceOverlays/OverviewWindow.qml index 43899714..2a4d7f52 100644 --- a/quickshell/Modules/WorkspaceOverlays/OverviewWindow.qml +++ b/quickshell/Modules/WorkspaceOverlays/OverviewWindow.qml @@ -62,30 +62,30 @@ Item { Behavior on x { NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen) easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel + easing.bezierCurve: Theme.variantModalEnterCurve } } Behavior on y { NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen) easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel + easing.bezierCurve: Theme.variantModalEnterCurve } } Behavior on width { NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen) easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel + easing.bezierCurve: Theme.variantModalEnterCurve } } Behavior on height { NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen) easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel + easing.bezierCurve: Theme.variantModalEnterCurve } } @@ -124,16 +124,16 @@ Item { Behavior on width { NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen) easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel + easing.bezierCurve: Theme.variantModalEnterCurve } } Behavior on height { NumberAnimation { - duration: Theme.expressiveDurations.expressiveDefaultSpatial + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen) easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel + easing.bezierCurve: Theme.variantModalEnterCurve } } } diff --git a/quickshell/Widgets/DankPopout.qml b/quickshell/Widgets/DankPopout.qml index 3bd301f0..0ef2778e 100644 --- a/quickshell/Widgets/DankPopout.qml +++ b/quickshell/Widgets/DankPopout.qml @@ -20,10 +20,10 @@ Item { property string triggerSection: "" property string positioning: "center" property int animationDuration: Theme.popoutAnimationDuration - property real animationScaleCollapsed: 0.96 - property real animationOffset: Theme.spacingL - property list animationEnterCurve: Theme.expressiveCurves.expressiveDefaultSpatial - property list animationExitCurve: Theme.expressiveCurves.emphasized + property real animationScaleCollapsed: Theme.effectScaleCollapsed + property real animationOffset: Theme.effectAnimOffset + property list animationEnterCurve: Theme.variantPopoutEnterCurve + property list animationExitCurve: Theme.variantPopoutExitCurve property bool suspendShadowWhileResizing: false property bool shouldBeVisible: false property var customKeyboardFocus: null @@ -74,6 +74,7 @@ Item { signal backgroundClicked property var _lastOpenedScreen: null + property bool isClosing: false property int effectiveBarPosition: 0 property real effectiveBarBottomGap: 0 @@ -156,10 +157,14 @@ Item { } } + property bool animationsEnabled: true + function open() { if (!screen) return; closeTimer.stop(); + isClosing = false; + animationsEnabled = false; // Snapshot mask geometry _frozenMaskX = maskX; @@ -174,12 +179,22 @@ Item { } _lastOpenedScreen = screen; - shouldBeVisible = true; + if (contentContainer) { + contentContainer.animX = Theme.snap(contentContainer.offsetX, root.dpr); + contentContainer.animY = Theme.snap(contentContainer.offsetY, root.dpr); + contentContainer.scaleValue = root.animationScaleCollapsed; + } + if (useBackgroundWindow) { _surfaceMarginLeft = alignedX - shadowBuffer; _surfaceW = alignedWidth + shadowBuffer * 2; + backgroundWindow.visible = true; } + contentWindow.visible = true; + Qt.callLater(() => { + animationsEnabled = true; + shouldBeVisible = true; if (shouldBeVisible && screen) { if (useBackgroundWindow) backgroundWindow.visible = true; @@ -191,6 +206,7 @@ Item { } function close() { + isClosing = true; shouldBeVisible = false; _primeContent = false; PopoutManager.popoutChanged(); @@ -222,9 +238,10 @@ Item { Timer { id: closeTimer - interval: animationDuration + interval: Theme.variantCloseInterval(animationDuration) onTriggered: { if (!shouldBeVisible) { + isClosing = false; contentWindow.visible = false; if (useBackgroundWindow) backgroundWindow.visible = false; @@ -241,7 +258,13 @@ Item { readonly property var shadowLevel: Theme.elevationLevel3 readonly property real shadowFallbackOffset: 6 readonly property real shadowRenderPadding: (Theme.elevationEnabled && SettingsData.popoutElevationEnabled) ? Theme.elevationRenderPadding(shadowLevel, effectiveShadowDirection, shadowFallbackOffset, 8, 16) : 0 - readonly property real shadowMotionPadding: Math.max(0, animationOffset) + readonly property real shadowMotionPadding: { + if (Theme.isDirectionalEffect) + return Math.max(0, animationOffset) + 16; + if (Theme.isDepthEffect) + return Math.max(0, animationOffset) + 8; + return Math.max(0, animationOffset); + } readonly property real shadowBuffer: Theme.snap(shadowRenderPadding + shadowMotionPadding, dpr) readonly property real alignedWidth: Theme.px(popupWidth, dpr) readonly property real alignedHeight: Theme.px(popupHeight, dpr) @@ -353,6 +376,10 @@ Item { mask: Region { item: maskRect + Region { + item: contentExclusionRect + intersection: Intersection.Subtract + } } Rectangle { @@ -361,26 +388,70 @@ Item { color: "transparent" x: root._frozenMaskX y: root._frozenMaskY - width: (shouldBeVisible && backgroundInteractive) ? root._frozenMaskWidth : 0 - height: (shouldBeVisible && backgroundInteractive) ? root._frozenMaskHeight : 0 + width: (backgroundWindow.visible && backgroundInteractive) ? root._frozenMaskWidth : 0 + height: (backgroundWindow.visible && backgroundInteractive) ? root._frozenMaskHeight : 0 } - MouseArea { + Item { + id: contentExclusionRect + visible: false + x: root.alignedX + y: root.alignedY + width: root.alignedWidth + height: root.alignedHeight + } + + Item { + id: outsideClickCatcher x: root._frozenMaskX y: root._frozenMaskY width: root._frozenMaskWidth height: root._frozenMaskHeight - hoverEnabled: false - enabled: shouldBeVisible && backgroundInteractive - acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton - onClicked: mouse => { - const clickX = mouse.x + root._frozenMaskX; - const clickY = mouse.y + root._frozenMaskY; - const outsideContent = clickX < root.alignedX || clickX > root.alignedX + root.alignedWidth || clickY < root.alignedY || clickY > root.alignedY + root.alignedHeight; + enabled: root.shouldBeVisible && root.backgroundInteractive - if (!outsideContent) - return; - backgroundClicked(); + readonly property real contentLeft: Math.max(0, root.alignedX - x) + readonly property real contentTop: Math.max(0, root.alignedY - y) + readonly property real contentRight: Math.min(width, contentLeft + root.alignedWidth) + readonly property real contentBottom: Math.min(height, contentTop + root.alignedHeight) + + MouseArea { + x: 0 + y: 0 + width: outsideClickCatcher.width + height: Math.max(0, outsideClickCatcher.contentTop) + enabled: parent.enabled + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + onClicked: root.backgroundClicked() + } + + MouseArea { + x: 0 + y: outsideClickCatcher.contentBottom + width: outsideClickCatcher.width + height: Math.max(0, outsideClickCatcher.height - outsideClickCatcher.contentBottom) + enabled: parent.enabled + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + onClicked: root.backgroundClicked() + } + + MouseArea { + x: 0 + y: outsideClickCatcher.contentTop + width: Math.max(0, outsideClickCatcher.contentLeft) + height: Math.max(0, outsideClickCatcher.contentBottom - outsideClickCatcher.contentTop) + enabled: parent.enabled + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + onClicked: root.backgroundClicked() + } + + MouseArea { + x: outsideClickCatcher.contentRight + y: outsideClickCatcher.contentTop + width: Math.max(0, outsideClickCatcher.width - outsideClickCatcher.contentRight) + height: Math.max(0, outsideClickCatcher.contentBottom - outsideClickCatcher.contentTop) + enabled: parent.enabled + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + onClicked: root.backgroundClicked() } } @@ -425,7 +496,6 @@ Item { } readonly property bool _fullHeight: useBackgroundWindow && root.fullHeightSurface - anchors { left: true top: true @@ -483,13 +553,65 @@ Item { readonly property bool barBottom: effectiveBarPosition === SettingsData.Position.Bottom readonly property bool barLeft: effectiveBarPosition === SettingsData.Position.Left readonly property bool barRight: effectiveBarPosition === SettingsData.Position.Right - readonly property real offsetX: barLeft ? root.animationOffset : (barRight ? -root.animationOffset : 0) - readonly property real offsetY: barBottom ? -root.animationOffset : (barTop ? root.animationOffset : 0) + readonly property bool directionalEffect: Theme.isDirectionalEffect + readonly property bool depthEffect: Theme.isDepthEffect + readonly property real directionalTravelX: Math.max(root.animationOffset, root.alignedWidth + Theme.spacingL) + readonly property real directionalTravelY: Math.max(root.animationOffset, root.alignedHeight + Theme.spacingL) + readonly property real depthTravel: Math.max(root.animationOffset * 0.7, 28) + readonly property real sectionTilt: (triggerSection === "left" ? -1 : (triggerSection === "right" ? 1 : 0)) + readonly property real offsetX: { + if (directionalEffect) { + if (barLeft) + return -directionalTravelX; + if (barRight) + return directionalTravelX; + if (barTop || barBottom) + return 0; + return sectionTilt * directionalTravelX * 0.2; + } + if (depthEffect) { + if (barLeft) + return -depthTravel; + if (barRight) + return depthTravel; + if (barTop || barBottom) + return 0; + return sectionTilt * depthTravel * 0.2; + } + return barLeft ? root.animationOffset : (barRight ? -root.animationOffset : 0); + } + readonly property real offsetY: { + if (directionalEffect) { + if (barBottom) + return directionalTravelY; + if (barTop) + return -directionalTravelY; + if (barLeft || barRight) + return 0; + return directionalTravelY; + } + if (depthEffect) { + if (barBottom) + return depthTravel; + if (barTop) + return -depthTravel; + if (barLeft || barRight) + return 0; + return depthTravel; + } + return barBottom ? -root.animationOffset : (barTop ? root.animationOffset : 0); + } property real animX: 0 property real animY: 0 property real scaleValue: root.animationScaleCollapsed + Component.onCompleted: { + animX = Theme.snap(root.shouldBeVisible ? 0 : offsetX, root.dpr); + animY = Theme.snap(root.shouldBeVisible ? 0 : offsetY, root.dpr); + scaleValue = root.shouldBeVisible ? 1.0 : root.animationScaleCollapsed; + } + onOffsetXChanged: animX = Theme.snap(root.shouldBeVisible ? 0 : offsetX, root.dpr) onOffsetYChanged: animY = Theme.snap(root.shouldBeVisible ? 0 : offsetY, root.dpr) @@ -503,24 +625,27 @@ Item { } Behavior on animX { + enabled: root.animationsEnabled NumberAnimation { - duration: root.animationDuration + duration: Theme.variantDuration(root.animationDuration, root.shouldBeVisible) easing.type: Easing.BezierSpline easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve } } Behavior on animY { + enabled: root.animationsEnabled NumberAnimation { - duration: root.animationDuration + duration: Theme.variantDuration(root.animationDuration, root.shouldBeVisible) easing.type: Easing.BezierSpline easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve } } Behavior on scaleValue { + enabled: root.animationsEnabled NumberAnimation { - duration: root.animationDuration + duration: Theme.variantDuration(root.animationDuration, root.shouldBeVisible) easing.type: Easing.BezierSpline easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve } @@ -544,10 +669,9 @@ Item { Item { id: contentWrapper - anchors.centerIn: parent width: parent.width height: parent.height - opacity: shouldBeVisible ? 1 : 0 + opacity: Theme.isDirectionalEffect ? 1 : (shouldBeVisible ? 1 : 0) visible: opacity > 0 scale: contentContainer.scaleValue x: Theme.snap(contentContainer.animX + (parent.width - width) * (1 - contentContainer.scaleValue) * 0.5, root.dpr) @@ -558,8 +682,9 @@ Item { layer.textureSize: root.dpr > 1 ? Qt.size(Math.ceil(width * root.dpr), Math.ceil(height * root.dpr)) : Qt.size(0, 0) Behavior on opacity { + enabled: !Theme.isDirectionalEffect NumberAnimation { - duration: animationDuration + duration: Math.round(Theme.variantDuration(animationDuration, shouldBeVisible) * Theme.variantOpacityDurationScale) easing.type: Easing.BezierSpline easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve }