From a297611bb4402d311fef3e492e0a080ab18c1570 Mon Sep 17 00:00:00 2001 From: bbedward Date: Fri, 17 Apr 2026 10:55:07 -0400 Subject: [PATCH] refactor connected/standalone architecture --- quickshell/Modals/Common/DankModal.qml | 858 ++---------- .../Modals/Common/DankModalConnected.qml | 818 ++++++++++++ .../Modals/Common/DankModalStandalone.qml | 502 +++++++ .../DankLauncherV2/DankLauncherV2Modal.qml | 844 +----------- .../DankLauncherV2ModalConnected.qml | 844 ++++++++++++ .../DankLauncherV2ModalStandalone.qml | 470 +++++++ quickshell/Widgets/DankPopout.qml | 1174 ++--------------- quickshell/Widgets/DankPopoutConnected.qml | 1155 ++++++++++++++++ quickshell/Widgets/DankPopoutStandalone.qml | 625 +++++++++ 9 files changed, 4686 insertions(+), 2604 deletions(-) create mode 100644 quickshell/Modals/Common/DankModalConnected.qml create mode 100644 quickshell/Modals/Common/DankModalStandalone.qml create mode 100644 quickshell/Modals/DankLauncherV2/DankLauncherV2ModalConnected.qml create mode 100644 quickshell/Modals/DankLauncherV2/DankLauncherV2ModalStandalone.qml create mode 100644 quickshell/Widgets/DankPopoutConnected.qml create mode 100644 quickshell/Widgets/DankPopoutStandalone.qml diff --git a/quickshell/Modals/Common/DankModal.qml b/quickshell/Modals/Common/DankModal.qml index 1ab72da6..69b9030a 100644 --- a/quickshell/Modals/Common/DankModal.qml +++ b/quickshell/Modals/Common/DankModal.qml @@ -1,24 +1,15 @@ import QtQuick -import Quickshell -import Quickshell.Wayland import qs.Common -import qs.Services -import qs.Widgets Item { id: root property string layerNamespace: "dms:modal" - property alias content: contentLoader.sourceComponent - property alias contentLoader: contentLoader + property Component content: null property Item directContent: null property real modalWidth: 400 property real modalHeight: 300 property var targetScreen - readonly property var effectiveScreen: contentWindow.screen ?? targetScreen - readonly property real screenWidth: effectiveScreen?.width ?? 1920 - readonly property real screenHeight: effectiveScreen?.height ?? 1080 - readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1 property bool showBackground: true property real backgroundOpacity: 0.5 property string positioning: "center" @@ -26,51 +17,16 @@ Item { property bool closeOnEscapeKey: true property bool closeOnBackgroundClick: true property string animationType: "scale" - - // Opposite side from the launcher by default; subclasses may override - property string preferredConnectedBarSide: SettingsData.frameModalEmergeSide - - readonly property bool frameConnectedMode: SettingsData.frameEnabled - && Theme.isConnectedEffect - && !!effectiveScreen - && SettingsData.isScreenInPreferences(effectiveScreen, SettingsData.frameScreenPreferences) - - readonly property string resolvedConnectedBarSide: frameConnectedMode ? preferredConnectedBarSide : "" - - readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" - - function _dockOccupiesSide(side) { - if (!SettingsData.showDock) return false; - switch (side) { - case "top": return SettingsData.dockPosition === SettingsData.Position.Top; - case "bottom": return SettingsData.dockPosition === SettingsData.Position.Bottom; - case "left": return SettingsData.dockPosition === SettingsData.Position.Left; - case "right": return SettingsData.dockPosition === SettingsData.Position.Right; - } - return false; - } - - readonly property bool _dockBlocksEmergence: frameOwnsConnectedChrome - && _dockOccupiesSide(resolvedConnectedBarSide) - - readonly property bool connectedMotionParity: Theme.isConnectedEffect - property int animationDuration: connectedMotionParity ? Theme.popoutAnimationDuration : Theme.modalAnimationDuration - property real animationScaleCollapsed: Theme.effectScaleCollapsed - property real animationOffset: Theme.effectAnimOffset - property list animationEnterCurve: connectedMotionParity ? Theme.variantPopoutEnterCurve : Theme.variantModalEnterCurve - property list animationExitCurve: connectedMotionParity ? Theme.variantPopoutExitCurve : Theme.variantModalExitCurve - property color backgroundColor: Theme.surfaceContainer + 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 color backgroundColor: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) property color borderColor: Theme.outlineMedium property real borderWidth: 0 property real cornerRadius: Theme.cornerRadius - readonly property bool connectedSurfaceOverride: Theme.isConnectedEffect - readonly property color effectiveBackgroundColor: connectedSurfaceOverride ? Theme.connectedSurfaceColor : backgroundColor - readonly property color effectiveBorderColor: connectedSurfaceOverride ? "transparent" : borderColor - readonly property real effectiveBorderWidth: connectedSurfaceOverride ? 0 : borderWidth - readonly property real effectiveCornerRadius: connectedSurfaceOverride ? Theme.connectedSurfaceRadius : cornerRadius - readonly property bool effectiveBlurEnabled: Theme.connectedSurfaceBlurEnabled property bool enableShadow: true - property alias modalFocusScope: focusScope property bool shouldBeVisible: false property bool shouldHaveFocus: shouldBeVisible property bool allowFocusOverride: false @@ -79,723 +35,133 @@ 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 signal opened signal dialogClosed signal backgroundClicked - property bool animationsEnabled: true + readonly property var contentLoader: impl.item ? impl.item.contentLoader : null + readonly property alias modalFocusScope: _modalFocusScope - // ─── Connected chrome sync ──────────────────────────────────────────────── - property string _chromeClaimId: "" - property bool _fullSyncPending: false - - function _nextChromeClaimId() { - return layerNamespace + ":modal:" + (new Date()).getTime() + ":" + Math.floor(Math.random() * 1000); - } - - function _currentScreenName() { - return effectiveScreen ? effectiveScreen.name : ""; - } - - function _publishModalChromeState() { - const screenName = _currentScreenName(); - if (!screenName) return; - ConnectedModeState.setModalState(screenName, { - "visible": shouldBeVisible || contentWindow.visible, - "barSide": resolvedConnectedBarSide, - "bodyX": alignedX, - "bodyY": alignedY, - "bodyW": alignedWidth, - "bodyH": alignedHeight, - "animX": modalContainer ? modalContainer.animX : 0, - "animY": modalContainer ? modalContainer.animY : 0, - "omitStartConnector": false, - "omitEndConnector": false - }); - } - - function _syncModalChromeState() { - if (!frameOwnsConnectedChrome) { - _releaseModalChrome(); - return; - } - if (!_chromeClaimId) - _chromeClaimId = _nextChromeClaimId(); - _publishModalChromeState(); - if (_dockBlocksEmergence && (shouldBeVisible || contentWindow.visible)) - ConnectedModeState.requestDockRetract(_chromeClaimId, _currentScreenName(), resolvedConnectedBarSide); - else - ConnectedModeState.releaseDockRetract(_chromeClaimId); - } - - function _flushFullSync() { - _fullSyncPending = false; - _syncModalChromeState(); - } - - function _queueFullSync() { - if (_fullSyncPending) return; - _fullSyncPending = true; - Qt.callLater(() => { - if (root && typeof root._flushFullSync === "function") - root._flushFullSync(); - }); - } - - function _syncModalAnim() { - if (!frameOwnsConnectedChrome || !_chromeClaimId) return; - const screenName = _currentScreenName(); - if (!screenName || !modalContainer) return; - ConnectedModeState.setModalAnim(screenName, modalContainer.animX, modalContainer.animY); - } - - function _syncModalBody() { - if (!frameOwnsConnectedChrome || !_chromeClaimId) return; - const screenName = _currentScreenName(); - if (!screenName) return; - ConnectedModeState.setModalBody(screenName, alignedX, alignedY, alignedWidth, alignedHeight); - } - - function _releaseModalChrome() { - if (_chromeClaimId) { - ConnectedModeState.releaseDockRetract(_chromeClaimId); - _chromeClaimId = ""; - } - const screenName = _currentScreenName(); - if (screenName) - ConnectedModeState.clearModalState(screenName); - } - - onFrameOwnsConnectedChromeChanged: _syncModalChromeState() - onResolvedConnectedBarSideChanged: _queueFullSync() - onShouldBeVisibleChanged: _queueFullSync() - onAlignedXChanged: _syncModalBody() - onAlignedYChanged: _syncModalBody() - onAlignedWidthChanged: _syncModalBody() - onAlignedHeightChanged: _syncModalBody() - - Component.onDestruction: _releaseModalChrome() - - Connections { - target: contentWindow - function onVisibleChanged() { - if (contentWindow.visible) - root._syncModalChromeState(); - else - root._releaseModalChrome(); - } + FocusScope { + id: _modalFocusScope + objectName: "modalFocusScope" + focus: true + anchors.fill: parent } + readonly property var contentWindow: impl.item ? impl.item.contentWindow : null + readonly property var clickCatcher: impl.item ? impl.item.clickCatcher : null + readonly property var effectiveScreen: impl.item ? impl.item.effectiveScreen : null + readonly property real screenWidth: impl.item ? impl.item.screenWidth : 1920 + readonly property real screenHeight: impl.item ? impl.item.screenHeight : 1080 + readonly property real dpr: impl.item ? impl.item.dpr : 1 + readonly property bool isClosing: impl.item ? (impl.item.isClosing ?? false) : false + readonly property real alignedX: impl.item ? impl.item.alignedX : 0 + readonly property real alignedY: impl.item ? impl.item.alignedY : 0 + readonly property real alignedWidth: impl.item ? impl.item.alignedWidth : 0 + readonly property real alignedHeight: impl.item ? impl.item.alignedHeight : 0 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) - clickCatcher.screen = focusedScreen; - } - - if (Theme.isDirectionalEffect || root.useBackground) { - if (!useSingleWindow) - clickCatcher.visible = true; - contentWindow.visible = true; - } - ModalManager.openModal(root); - - Qt.callLater(() => { - animationsEnabled = true; - shouldBeVisible = true; - if (!useSingleWindow && !clickCatcher.visible) - clickCatcher.visible = true; - if (!contentWindow.visible) - contentWindow.visible = true; - shouldHaveFocus = false; - Qt.callLater(() => shouldHaveFocus = Qt.binding(() => shouldBeVisible)); - }); + if (impl.item) + impl.item.open(); } function close() { - shouldBeVisible = false; - shouldHaveFocus = false; - ModalManager.closeModal(root); - closeTimer.restart(); + if (impl.item) + impl.item.close(); } function instantClose() { - animationsEnabled = false; - shouldBeVisible = false; - shouldHaveFocus = false; - ModalManager.closeModal(root); - closeTimer.stop(); - contentWindow.visible = false; - if (!useSingleWindow) - clickCatcher.visible = false; - dialogClosed(); - Qt.callLater(() => animationsEnabled = true); + if (impl.item && typeof impl.item.instantClose === "function") + impl.item.instantClose(); } function toggle() { - shouldBeVisible ? close() : open(); + if (impl.item) + impl.item.toggle(); + } + + Loader { + id: impl + sourceComponent: SettingsData.connectedFrameModeActive ? connectedComp : standaloneComp + onItemChanged: if (item) + root._wireBackend(item) + } + + Component { + id: standaloneComp + DankModalStandalone {} + } + + Component { + id: connectedComp + DankModalConnected {} + } + + function _wireBackend(it) { + if (!it) + return; + + it.modalHandle = root; + it.layerNamespace = Qt.binding(() => root.layerNamespace); + it.content = Qt.binding(() => root.content); + it.directContent = Qt.binding(() => root.directContent); + it.modalWidth = Qt.binding(() => root.modalWidth); + it.modalHeight = Qt.binding(() => root.modalHeight); + it.targetScreen = Qt.binding(() => root.targetScreen); + it.showBackground = Qt.binding(() => root.showBackground); + it.backgroundOpacity = Qt.binding(() => root.backgroundOpacity); + it.positioning = Qt.binding(() => root.positioning); + it.customPosition = Qt.binding(() => root.customPosition); + it.closeOnEscapeKey = Qt.binding(() => root.closeOnEscapeKey); + it.closeOnBackgroundClick = Qt.binding(() => root.closeOnBackgroundClick); + it.animationType = Qt.binding(() => root.animationType); + it.animationDuration = Qt.binding(() => root.animationDuration); + it.animationScaleCollapsed = Qt.binding(() => root.animationScaleCollapsed); + it.animationOffset = Qt.binding(() => root.animationOffset); + it.animationEnterCurve = Qt.binding(() => root.animationEnterCurve); + it.animationExitCurve = Qt.binding(() => root.animationExitCurve); + it.backgroundColor = Qt.binding(() => root.backgroundColor); + it.borderColor = Qt.binding(() => root.borderColor); + it.borderWidth = Qt.binding(() => root.borderWidth); + it.cornerRadius = Qt.binding(() => root.cornerRadius); + it.enableShadow = Qt.binding(() => root.enableShadow); + it.allowFocusOverride = Qt.binding(() => root.allowFocusOverride); + it.allowStacking = Qt.binding(() => root.allowStacking); + it.keepContentLoaded = Qt.binding(() => root.keepContentLoaded); + it.keepPopoutsOpen = Qt.binding(() => root.keepPopoutsOpen); + it.customKeyboardFocus = Qt.binding(() => root.customKeyboardFocus); + it.useOverlayLayer = Qt.binding(() => root.useOverlayLayer); + + it.shouldBeVisible = root.shouldBeVisible; + it.shouldBeVisibleChanged.connect(function () { + if (root.shouldBeVisible !== it.shouldBeVisible) + root.shouldBeVisible = it.shouldBeVisible; + }); + + it.shouldHaveFocus = root.shouldHaveFocus; + it.shouldHaveFocusChanged.connect(function () { + if (root.shouldHaveFocus !== it.shouldHaveFocus) + root.shouldHaveFocus = it.shouldHaveFocus; + }); + + it.opened.connect(root.opened); + it.dialogClosed.connect(root.dialogClosed); + it.backgroundClicked.connect(root.backgroundClicked); + + if (it.modalFocusScope) + _modalFocusScope.parent = it.modalFocusScope; } Connections { - target: ModalManager - function onCloseAllModalsExcept(excludedModal) { - if (excludedModal !== root && !allowStacking && shouldBeVisible) - close(); + target: root + function onShouldBeVisibleChanged() { + if (impl.item && impl.item.shouldBeVisible !== root.shouldBeVisible) + impl.item.shouldBeVisible = root.shouldBeVisible; } - } - - Connections { - target: Quickshell - function onScreensChanged() { - if (!contentWindow.screen) - return; - const currentScreenName = contentWindow.screen.name; - let screenStillExists = false; - for (let i = 0; i < Quickshell.screens.length; i++) { - if (Quickshell.screens[i].name === currentScreenName) { - screenStillExists = true; - break; - } - } - if (screenStillExists) - return; - const newScreen = CompositorService.getFocusedScreen(); - if (newScreen) { - contentWindow.screen = newScreen; - if (!useSingleWindow) - clickCatcher.screen = newScreen; - } - } - } - - Timer { - id: closeTimer - interval: Theme.variantCloseInterval(animationDuration) - onTriggered: { - if (shouldBeVisible) - return; - contentWindow.visible = false; - if (!useSingleWindow) - clickCatcher.visible = false; - dialogClosed(); - } - } - - // shadowRenderPadding is zeroed when frame owns the chrome - // Wayland then clips any content translating past - readonly property var shadowLevel: Theme.elevationLevel3 - readonly property real shadowFallbackOffset: 6 - readonly property real shadowRenderPadding: (!frameOwnsConnectedChrome && root.enableShadow && Theme.elevationEnabled && SettingsData.modalElevationEnabled) ? Theme.elevationRenderPadding(shadowLevel, Theme.elevationLightDirection, shadowFallbackOffset, 8, 16) : 0 - readonly property real shadowMotionPadding: { - if (Theme.isConnectedEffect) - return 0; - if (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode > 0 && Theme.isDirectionalEffect) - return 0; // Wayland native overlap mask - 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) - - function _frameEdgeInset(side) { - if (!effectiveScreen) return 0; - return SettingsData.frameEdgeInsetForSide(effectiveScreen, side); - } - - // frameEdgeInsetForSide is the full inset; do not add frameBarSize - readonly property real _connectedAlignedX: { - switch (resolvedConnectedBarSide) { - case "top": - case "bottom": { - const insetL = _frameEdgeInset("left"); - const insetR = _frameEdgeInset("right"); - const usable = Math.max(0, screenWidth - insetL - insetR); - return insetL + Math.max(0, (usable - alignedWidth) / 2); - } - case "left": - return _frameEdgeInset("left"); - case "right": - return screenWidth - alignedWidth - _frameEdgeInset("right"); - } - return 0; - } - - readonly property real _connectedAlignedY: { - switch (resolvedConnectedBarSide) { - case "top": - return _frameEdgeInset("top"); - case "bottom": - return screenHeight - alignedHeight - _frameEdgeInset("bottom"); - case "left": - case "right": { - const insetT = _frameEdgeInset("top"); - const insetB = _frameEdgeInset("bottom"); - const usable = Math.max(0, screenHeight - insetT - insetB); - return insetT + Math.max(0, (usable - alignedHeight) / 2); - } - } - return 0; - } - - readonly property real alignedX: Theme.snap(frameOwnsConnectedChrome ? _connectedAlignedX : (() => { - switch (positioning) { - case "center": - return (screenWidth - alignedWidth) / 2; - case "top-right": - return Math.max(Theme.spacingL, screenWidth - alignedWidth - Theme.spacingL); - case "custom": - return customPosition.x; - default: - return 0; - } - })(), dpr) - - readonly property real alignedY: Theme.snap(frameOwnsConnectedChrome ? _connectedAlignedY : (() => { - switch (positioning) { - case "center": - return (screenHeight - alignedHeight) / 2; - case "top-right": - return Theme.barHeight + Theme.spacingXS; - case "custom": - return customPosition.y; - default: - return 0; - } - })(), dpr) - - PanelWindow { - id: clickCatcher - visible: false - color: "transparent" - - WlrLayershell.namespace: root.layerNamespace + ":clickcatcher" - WlrLayershell.layer: WlrLayershell.Top - WlrLayershell.exclusiveZone: -1 - WlrLayershell.keyboardFocus: WlrKeyboardFocus.None - - anchors { - top: true - left: true - right: true - bottom: true - } - - mask: Region { - item: Rectangle { - x: root.alignedX - y: root.alignedY - width: root.alignedWidth - height: root.alignedHeight - } - intersection: Intersection.Xor - } - - MouseArea { - anchors.fill: parent - enabled: !root.useSingleWindow && root.closeOnBackgroundClick && root.shouldBeVisible - onClicked: root.backgroundClicked() - } - - Rectangle { - anchors.fill: parent - z: -1 - color: "black" - opacity: (!root.useSingleWindow && root.useBackground) ? (root.shouldBeVisible ? root.backgroundOpacity : 0) : 0 - visible: opacity > 0 - - Behavior on opacity { - enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || Theme.isConnectedEffect) - NumberAnimation { - duration: Math.round(Theme.variantDuration(root.animationDuration, root.shouldBeVisible) * Theme.variantOpacityDurationScale) - easing.type: Easing.BezierSpline - easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve - } - } - } - } - - PanelWindow { - id: contentWindow - visible: false - color: "transparent" - - WindowBlur { - targetWindow: contentWindow - blurEnabled: root.effectiveBlurEnabled && !root.frameOwnsConnectedChrome - readonly property real s: Math.min(1, modalContainer.scaleValue) - blurX: modalContainer.x + modalContainer.width * (1 - s) * 0.5 + Theme.snap(modalContainer.animX, root.dpr) - blurY: modalContainer.y + modalContainer.height * (1 - s) * 0.5 + Theme.snap(modalContainer.animY, root.dpr) - blurWidth: (root.shouldBeVisible && animatedContent.opacity > 0 && !root.frameOwnsConnectedChrome) ? modalContainer.width * s : 0 - blurHeight: (root.shouldBeVisible && animatedContent.opacity > 0 && !root.frameOwnsConnectedChrome) ? modalContainer.height * s : 0 - blurRadius: root.effectiveCornerRadius - } - - WlrLayershell.namespace: root.layerNamespace - WlrLayershell.layer: { - if (root.useOverlayLayer) - return WlrLayershell.Overlay; - 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.exclusiveZone: -1 - WlrLayershell.keyboardFocus: { - if (customKeyboardFocus !== null) - return customKeyboardFocus; - if (!shouldHaveFocus) - return WlrKeyboardFocus.None; - if (root.useHyprlandFocusGrab) - return WlrKeyboardFocus.OnDemand; - return WlrKeyboardFocus.Exclusive; - } - - anchors { - left: true - top: true - right: root.useSingleWindow - bottom: root.useSingleWindow - } - - readonly property real actualMarginLeft: root.useSingleWindow ? 0 : Math.max(0, Theme.snap(root.alignedX - shadowBuffer, dpr)) - readonly property real actualMarginTop: root.useSingleWindow ? 0 : Math.max(0, Theme.snap(root.alignedY - shadowBuffer, dpr)) - - WlrLayershell.margins { - left: actualMarginLeft - top: actualMarginTop - right: 0 - bottom: 0 - } - - implicitWidth: root.useSingleWindow ? 0 : root.alignedWidth + (shadowBuffer * 2) - implicitHeight: root.useSingleWindow ? 0 : root.alignedHeight + (shadowBuffer * 2) - - onVisibleChanged: { - if (visible) { - opened(); - } else { - if (Qt.inputMethod) { - Qt.inputMethod.hide(); - Qt.inputMethod.reset(); - } - } - } - - MouseArea { - anchors.fill: parent - enabled: root.useSingleWindow && root.closeOnBackgroundClick && root.shouldBeVisible - z: -2 - onClicked: root.backgroundClicked() - } - - Rectangle { - anchors.fill: parent - z: -1 - color: "black" - opacity: (root.useSingleWindow && root.useBackground) ? (root.shouldBeVisible ? root.backgroundOpacity : 0) : 0 - visible: opacity > 0 - - Behavior on opacity { - enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || Theme.isConnectedEffect) - NumberAnimation { - duration: Math.round(Theme.variantDuration(root.animationDuration, root.shouldBeVisible) * Theme.variantOpacityDurationScale) - easing.type: Easing.BezierSpline - easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve - } - } - } - - Item { - id: modalContainer - 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 - hoverEnabled: false - acceptedButtons: Qt.AllButtons - onPressed: mouse.accepted = true - onClicked: mouse.accepted = true - z: -1 - } - - readonly property bool slide: root.animationType === "slide" - 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 - // Connected emergence: travel from the resolved bar edge, matching DankPopout cadence. - readonly property real connectedEmergenceTravelX: Math.max(root.animationOffset, root.alignedWidth + Theme.spacingL) - readonly property real connectedEmergenceTravelY: Math.max(root.animationOffset, root.alignedHeight + Theme.spacingL) - readonly property real offsetX: { - if (root.frameOwnsConnectedChrome) { - switch (root.resolvedConnectedBarSide) { - case "left": return -connectedEmergenceTravelX; - case "right": return connectedEmergenceTravelX; - } - return 0; - } - if (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2 && Theme.isDirectionalEffect) - return 0; - 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 (root.frameOwnsConnectedChrome) { - switch (root.resolvedConnectedBarSide) { - case "top": return -connectedEmergenceTravelY; - case "bottom": return connectedEmergenceTravelY; - } - return 0; - } - if (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2 && Theme.isDirectionalEffect) - return 0; - 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 - - onAnimXChanged: if (root.frameOwnsConnectedChrome) root._syncModalAnim() - onAnimYChanged: if (root.frameOwnsConnectedChrome) root._syncModalAnim() - - readonly property real computedScaleCollapsed: (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2 && Theme.isDirectionalEffect) ? 0.0 : root.animationScaleCollapsed - property real scaleValue: root.shouldBeVisible ? 1.0 : computedScaleCollapsed - - Behavior on animX { - enabled: root.animationsEnabled - NumberAnimation { - 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: 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: Theme.variantDuration(root.animationDuration, root.shouldBeVisible) - easing.type: Easing.BezierSpline - easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve - } - } - - Item { - id: contentContainer - anchors.centerIn: parent - width: parent.width - height: parent.height - clip: false - - Item { - id: animatedContent - anchors.fill: parent - clip: false - opacity: (Theme.isDirectionalEffect && !Theme.isConnectedEffect) ? 1 : (root.shouldBeVisible ? 1 : 0) - scale: modalContainer.scaleValue - transformOrigin: Item.Center - - Behavior on opacity { - enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || Theme.isConnectedEffect) - NumberAnimation { - duration: Math.round(Theme.variantDuration(animationDuration, root.shouldBeVisible) * Theme.variantOpacityDurationScale) - easing.type: Easing.BezierSpline - easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve - } - } - - ElevationShadow { - id: modalShadowLayer - anchors.fill: parent - level: root.shadowLevel - fallbackOffset: root.shadowFallbackOffset - targetRadius: root.effectiveCornerRadius - targetColor: root.frameOwnsConnectedChrome ? "transparent" : root.effectiveBackgroundColor - borderColor: root.frameOwnsConnectedChrome ? "transparent" : root.effectiveBorderColor - borderWidth: root.frameOwnsConnectedChrome ? 0 : root.effectiveBorderWidth - shadowEnabled: !root.frameOwnsConnectedChrome && root.enableShadow && Theme.elevationEnabled && SettingsData.modalElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1" - } - - Rectangle { - anchors.fill: parent - radius: root.effectiveCornerRadius - color: "transparent" - border.color: (root.connectedSurfaceOverride || root.frameOwnsConnectedChrome) ? "transparent" : BlurService.borderColor - border.width: (root.connectedSurfaceOverride || root.frameOwnsConnectedChrome) ? 0 : BlurService.borderWidth - z: 100 - } - - FocusScope { - anchors.fill: parent - focus: root.shouldBeVisible - clip: false - - Item { - id: directContentWrapper - anchors.fill: parent - visible: root.directContent !== null - focus: true - clip: false - - Component.onCompleted: { - if (root.directContent) { - root.directContent.parent = directContentWrapper; - root.directContent.anchors.fill = directContentWrapper; - Qt.callLater(() => root.directContent.forceActiveFocus()); - } - } - - Connections { - target: root - function onDirectContentChanged() { - if (root.directContent) { - root.directContent.parent = directContentWrapper; - root.directContent.anchors.fill = directContentWrapper; - Qt.callLater(() => root.directContent.forceActiveFocus()); - } - } - } - } - - Loader { - id: contentLoader - anchors.fill: parent - active: root.directContent === null && (root.keepContentLoaded || root.shouldBeVisible || contentWindow.visible) - asynchronous: false - focus: true - clip: false - visible: root.directContent === null - - onLoaded: { - if (item) { - Qt.callLater(() => item.forceActiveFocus()); - } - } - } - } - } - } - } - - FocusScope { - id: focusScope - objectName: "modalFocusScope" - anchors.fill: parent - visible: root.shouldBeVisible || contentWindow.visible - focus: root.shouldBeVisible - Keys.onEscapePressed: event => { - if (root.closeOnEscapeKey && shouldHaveFocus) { - root.close(); - event.accepted = true; - } - } + function onShouldHaveFocusChanged() { + if (impl.item && impl.item.shouldHaveFocus !== root.shouldHaveFocus) + impl.item.shouldHaveFocus = root.shouldHaveFocus; } } } diff --git a/quickshell/Modals/Common/DankModalConnected.qml b/quickshell/Modals/Common/DankModalConnected.qml new file mode 100644 index 00000000..7cb5ccff --- /dev/null +++ b/quickshell/Modals/Common/DankModalConnected.qml @@ -0,0 +1,818 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import qs.Common +import qs.Services +import qs.Widgets + +Item { + id: root + + property var modalHandle: root + property string layerNamespace: "dms:modal" + property alias content: contentLoader.sourceComponent + property alias contentLoader: contentLoader + property Item directContent: null + property real modalWidth: 400 + property real modalHeight: 300 + property var targetScreen + readonly property var effectiveScreen: contentWindow.screen ?? targetScreen + readonly property real screenWidth: effectiveScreen?.width ?? 1920 + readonly property real screenHeight: effectiveScreen?.height ?? 1080 + readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1 + property bool showBackground: true + property real backgroundOpacity: 0.5 + property string positioning: "center" + property point customPosition: Qt.point(0, 0) + property bool closeOnEscapeKey: true + property bool closeOnBackgroundClick: true + property string animationType: "scale" + + // Opposite side from the launcher by default; subclasses may override + property string preferredConnectedBarSide: SettingsData.frameModalEmergeSide + + readonly property bool frameConnectedMode: SettingsData.frameEnabled && Theme.isConnectedEffect && !!effectiveScreen && SettingsData.isScreenInPreferences(effectiveScreen, SettingsData.frameScreenPreferences) + + readonly property string resolvedConnectedBarSide: frameConnectedMode ? preferredConnectedBarSide : "" + + readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" + + function _dockOccupiesSide(side) { + if (!SettingsData.showDock) + return false; + switch (side) { + case "top": + return SettingsData.dockPosition === SettingsData.Position.Top; + case "bottom": + return SettingsData.dockPosition === SettingsData.Position.Bottom; + case "left": + return SettingsData.dockPosition === SettingsData.Position.Left; + case "right": + return SettingsData.dockPosition === SettingsData.Position.Right; + } + return false; + } + + readonly property bool _dockBlocksEmergence: frameOwnsConnectedChrome && _dockOccupiesSide(resolvedConnectedBarSide) + + readonly property bool connectedMotionParity: Theme.isConnectedEffect + property int animationDuration: connectedMotionParity ? Theme.popoutAnimationDuration : Theme.modalAnimationDuration + property real animationScaleCollapsed: Theme.effectScaleCollapsed + property real animationOffset: Theme.effectAnimOffset + property list animationEnterCurve: connectedMotionParity ? Theme.variantPopoutEnterCurve : Theme.variantModalEnterCurve + property list animationExitCurve: connectedMotionParity ? Theme.variantPopoutExitCurve : Theme.variantModalExitCurve + property color backgroundColor: Theme.surfaceContainer + property color borderColor: Theme.outlineMedium + property real borderWidth: 0 + property real cornerRadius: Theme.cornerRadius + readonly property bool connectedSurfaceOverride: Theme.isConnectedEffect + readonly property color effectiveBackgroundColor: connectedSurfaceOverride ? Theme.connectedSurfaceColor : backgroundColor + readonly property color effectiveBorderColor: connectedSurfaceOverride ? "transparent" : borderColor + readonly property real effectiveBorderWidth: connectedSurfaceOverride ? 0 : borderWidth + readonly property real effectiveCornerRadius: connectedSurfaceOverride ? Theme.connectedSurfaceRadius : cornerRadius + readonly property bool effectiveBlurEnabled: Theme.connectedSurfaceBlurEnabled + property bool enableShadow: true + property alias modalFocusScope: focusScope + property bool shouldBeVisible: false + property bool shouldHaveFocus: shouldBeVisible + property bool allowFocusOverride: false + property bool allowStacking: false + property bool keepContentLoaded: false + 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 + + signal opened + signal dialogClosed + signal backgroundClicked + + property bool animationsEnabled: true + + // ─── Connected chrome sync ──────────────────────────────────────────────── + property string _chromeClaimId: "" + property bool _fullSyncPending: false + + function _nextChromeClaimId() { + return layerNamespace + ":modal:" + (new Date()).getTime() + ":" + Math.floor(Math.random() * 1000); + } + + function _currentScreenName() { + return effectiveScreen ? effectiveScreen.name : ""; + } + + function _publishModalChromeState() { + const screenName = _currentScreenName(); + if (!screenName) + return; + ConnectedModeState.setModalState(screenName, { + "visible": shouldBeVisible || contentWindow.visible, + "barSide": resolvedConnectedBarSide, + "bodyX": alignedX, + "bodyY": alignedY, + "bodyW": alignedWidth, + "bodyH": alignedHeight, + "animX": modalContainer ? modalContainer.animX : 0, + "animY": modalContainer ? modalContainer.animY : 0, + "omitStartConnector": false, + "omitEndConnector": false + }); + } + + function _syncModalChromeState() { + if (!frameOwnsConnectedChrome) { + _releaseModalChrome(); + return; + } + if (!_chromeClaimId) + _chromeClaimId = _nextChromeClaimId(); + _publishModalChromeState(); + if (_dockBlocksEmergence && (shouldBeVisible || contentWindow.visible)) + ConnectedModeState.requestDockRetract(_chromeClaimId, _currentScreenName(), resolvedConnectedBarSide); + else + ConnectedModeState.releaseDockRetract(_chromeClaimId); + } + + function _flushFullSync() { + _fullSyncPending = false; + _syncModalChromeState(); + } + + function _queueFullSync() { + if (_fullSyncPending) + return; + _fullSyncPending = true; + Qt.callLater(() => { + if (root && typeof root._flushFullSync === "function") + root._flushFullSync(); + }); + } + + function _syncModalAnim() { + if (!frameOwnsConnectedChrome || !_chromeClaimId) + return; + const screenName = _currentScreenName(); + if (!screenName || !modalContainer) + return; + ConnectedModeState.setModalAnim(screenName, modalContainer.animX, modalContainer.animY); + } + + function _syncModalBody() { + if (!frameOwnsConnectedChrome || !_chromeClaimId) + return; + const screenName = _currentScreenName(); + if (!screenName) + return; + ConnectedModeState.setModalBody(screenName, alignedX, alignedY, alignedWidth, alignedHeight); + } + + function _releaseModalChrome() { + if (_chromeClaimId) { + ConnectedModeState.releaseDockRetract(_chromeClaimId); + _chromeClaimId = ""; + } + const screenName = _currentScreenName(); + if (screenName) + ConnectedModeState.clearModalState(screenName); + } + + onFrameOwnsConnectedChromeChanged: _syncModalChromeState() + onResolvedConnectedBarSideChanged: _queueFullSync() + onShouldBeVisibleChanged: _queueFullSync() + onAlignedXChanged: _syncModalBody() + onAlignedYChanged: _syncModalBody() + onAlignedWidthChanged: _syncModalBody() + onAlignedHeightChanged: _syncModalBody() + + Component.onDestruction: _releaseModalChrome() + + Connections { + target: contentWindow + function onVisibleChanged() { + if (contentWindow.visible) + root._syncModalChromeState(); + else + root._releaseModalChrome(); + } + } + + 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) + clickCatcher.screen = focusedScreen; + } + + if (Theme.isDirectionalEffect || root.useBackground) { + if (!useSingleWindow) + clickCatcher.visible = true; + contentWindow.visible = true; + } + ModalManager.openModal(modalHandle); + + Qt.callLater(() => { + animationsEnabled = true; + shouldBeVisible = true; + if (!useSingleWindow && !clickCatcher.visible) + clickCatcher.visible = true; + if (!contentWindow.visible) + contentWindow.visible = true; + shouldHaveFocus = false; + Qt.callLater(() => shouldHaveFocus = Qt.binding(() => shouldBeVisible)); + }); + } + + function close() { + shouldBeVisible = false; + shouldHaveFocus = false; + ModalManager.closeModal(modalHandle); + closeTimer.restart(); + } + + function instantClose() { + animationsEnabled = false; + shouldBeVisible = false; + shouldHaveFocus = false; + ModalManager.closeModal(modalHandle); + closeTimer.stop(); + contentWindow.visible = false; + if (!useSingleWindow) + clickCatcher.visible = false; + dialogClosed(); + Qt.callLater(() => animationsEnabled = true); + } + + function toggle() { + shouldBeVisible ? close() : open(); + } + + Connections { + target: ModalManager + function onCloseAllModalsExcept(excludedModal) { + if (excludedModal !== modalHandle && !allowStacking && shouldBeVisible) + close(); + } + } + + Connections { + target: Quickshell + function onScreensChanged() { + if (!contentWindow.screen) + return; + const currentScreenName = contentWindow.screen.name; + let screenStillExists = false; + for (let i = 0; i < Quickshell.screens.length; i++) { + if (Quickshell.screens[i].name === currentScreenName) { + screenStillExists = true; + break; + } + } + if (screenStillExists) + return; + const newScreen = CompositorService.getFocusedScreen(); + if (newScreen) { + contentWindow.screen = newScreen; + if (!useSingleWindow) + clickCatcher.screen = newScreen; + } + } + } + + Timer { + id: closeTimer + interval: Theme.variantCloseInterval(animationDuration) + onTriggered: { + if (shouldBeVisible) + return; + contentWindow.visible = false; + if (!useSingleWindow) + clickCatcher.visible = false; + dialogClosed(); + } + } + + // shadowRenderPadding is zeroed when frame owns the chrome + // Wayland then clips any content translating past + readonly property var shadowLevel: Theme.elevationLevel3 + readonly property real shadowFallbackOffset: 6 + readonly property real shadowRenderPadding: (!frameOwnsConnectedChrome && root.enableShadow && Theme.elevationEnabled && SettingsData.modalElevationEnabled) ? Theme.elevationRenderPadding(shadowLevel, Theme.elevationLightDirection, shadowFallbackOffset, 8, 16) : 0 + readonly property real shadowMotionPadding: { + if (Theme.isConnectedEffect) + return 0; + if (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode > 0 && Theme.isDirectionalEffect) + return 0; // Wayland native overlap mask + 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) + + function _frameEdgeInset(side) { + if (!effectiveScreen) + return 0; + return SettingsData.frameEdgeInsetForSide(effectiveScreen, side); + } + + // frameEdgeInsetForSide is the full inset; do not add frameBarSize + readonly property real _connectedAlignedX: { + switch (resolvedConnectedBarSide) { + case "top": + case "bottom": + { + const insetL = _frameEdgeInset("left"); + const insetR = _frameEdgeInset("right"); + const usable = Math.max(0, screenWidth - insetL - insetR); + return insetL + Math.max(0, (usable - alignedWidth) / 2); + } + case "left": + return _frameEdgeInset("left"); + case "right": + return screenWidth - alignedWidth - _frameEdgeInset("right"); + } + return 0; + } + + readonly property real _connectedAlignedY: { + switch (resolvedConnectedBarSide) { + case "top": + return _frameEdgeInset("top"); + case "bottom": + return screenHeight - alignedHeight - _frameEdgeInset("bottom"); + case "left": + case "right": + { + const insetT = _frameEdgeInset("top"); + const insetB = _frameEdgeInset("bottom"); + const usable = Math.max(0, screenHeight - insetT - insetB); + return insetT + Math.max(0, (usable - alignedHeight) / 2); + } + } + return 0; + } + + readonly property real alignedX: Theme.snap(frameOwnsConnectedChrome ? _connectedAlignedX : (() => { + switch (positioning) { + case "center": + return (screenWidth - alignedWidth) / 2; + case "top-right": + return Math.max(Theme.spacingL, screenWidth - alignedWidth - Theme.spacingL); + case "custom": + return customPosition.x; + default: + return 0; + } + })(), dpr) + + readonly property real alignedY: Theme.snap(frameOwnsConnectedChrome ? _connectedAlignedY : (() => { + switch (positioning) { + case "center": + return (screenHeight - alignedHeight) / 2; + case "top-right": + return Theme.barHeight + Theme.spacingXS; + case "custom": + return customPosition.y; + default: + return 0; + } + })(), dpr) + + PanelWindow { + id: clickCatcher + visible: false + color: "transparent" + + WlrLayershell.namespace: root.layerNamespace + ":clickcatcher" + WlrLayershell.layer: WlrLayershell.Top + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + anchors { + top: true + left: true + right: true + bottom: true + } + + mask: Region { + item: Rectangle { + x: root.alignedX + y: root.alignedY + width: root.alignedWidth + height: root.alignedHeight + } + intersection: Intersection.Xor + } + + MouseArea { + anchors.fill: parent + enabled: !root.useSingleWindow && root.closeOnBackgroundClick && root.shouldBeVisible + onClicked: root.backgroundClicked() + } + + Rectangle { + anchors.fill: parent + z: -1 + color: "black" + opacity: (!root.useSingleWindow && root.useBackground) ? (root.shouldBeVisible ? root.backgroundOpacity : 0) : 0 + visible: opacity > 0 + + Behavior on opacity { + enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || Theme.isConnectedEffect) + NumberAnimation { + duration: Math.round(Theme.variantDuration(root.animationDuration, root.shouldBeVisible) * Theme.variantOpacityDurationScale) + easing.type: Easing.BezierSpline + easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve + } + } + } + } + + PanelWindow { + id: contentWindow + visible: false + color: "transparent" + + WindowBlur { + targetWindow: contentWindow + blurEnabled: root.effectiveBlurEnabled && !root.frameOwnsConnectedChrome + readonly property real s: Math.min(1, modalContainer.scaleValue) + blurX: modalContainer.x + modalContainer.width * (1 - s) * 0.5 + Theme.snap(modalContainer.animX, root.dpr) + blurY: modalContainer.y + modalContainer.height * (1 - s) * 0.5 + Theme.snap(modalContainer.animY, root.dpr) + blurWidth: (root.shouldBeVisible && animatedContent.opacity > 0 && !root.frameOwnsConnectedChrome) ? modalContainer.width * s : 0 + blurHeight: (root.shouldBeVisible && animatedContent.opacity > 0 && !root.frameOwnsConnectedChrome) ? modalContainer.height * s : 0 + blurRadius: root.effectiveCornerRadius + } + + WlrLayershell.namespace: root.layerNamespace + WlrLayershell.layer: { + if (root.useOverlayLayer) + return WlrLayershell.Overlay; + 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.exclusiveZone: -1 + WlrLayershell.keyboardFocus: { + if (customKeyboardFocus !== null) + return customKeyboardFocus; + if (!shouldHaveFocus) + return WlrKeyboardFocus.None; + if (root.useHyprlandFocusGrab) + return WlrKeyboardFocus.OnDemand; + return WlrKeyboardFocus.Exclusive; + } + + anchors { + left: true + top: true + right: root.useSingleWindow + bottom: root.useSingleWindow + } + + readonly property real actualMarginLeft: root.useSingleWindow ? 0 : Math.max(0, Theme.snap(root.alignedX - shadowBuffer, dpr)) + readonly property real actualMarginTop: root.useSingleWindow ? 0 : Math.max(0, Theme.snap(root.alignedY - shadowBuffer, dpr)) + + WlrLayershell.margins { + left: actualMarginLeft + top: actualMarginTop + right: 0 + bottom: 0 + } + + implicitWidth: root.useSingleWindow ? 0 : root.alignedWidth + (shadowBuffer * 2) + implicitHeight: root.useSingleWindow ? 0 : root.alignedHeight + (shadowBuffer * 2) + + onVisibleChanged: { + if (visible) { + opened(); + } else { + if (Qt.inputMethod) { + Qt.inputMethod.hide(); + Qt.inputMethod.reset(); + } + } + } + + MouseArea { + anchors.fill: parent + enabled: root.useSingleWindow && root.closeOnBackgroundClick && root.shouldBeVisible + z: -2 + onClicked: root.backgroundClicked() + } + + Rectangle { + anchors.fill: parent + z: -1 + color: "black" + opacity: (root.useSingleWindow && root.useBackground) ? (root.shouldBeVisible ? root.backgroundOpacity : 0) : 0 + visible: opacity > 0 + + Behavior on opacity { + enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || Theme.isConnectedEffect) + NumberAnimation { + duration: Math.round(Theme.variantDuration(root.animationDuration, root.shouldBeVisible) * Theme.variantOpacityDurationScale) + easing.type: Easing.BezierSpline + easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve + } + } + } + + Item { + id: modalContainer + 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 + hoverEnabled: false + acceptedButtons: Qt.AllButtons + onPressed: mouse.accepted = true + onClicked: mouse.accepted = true + z: -1 + } + + readonly property bool slide: root.animationType === "slide" + 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 + // Connected emergence: travel from the resolved bar edge, matching DankPopout cadence. + readonly property real connectedEmergenceTravelX: Math.max(root.animationOffset, root.alignedWidth + Theme.spacingL) + readonly property real connectedEmergenceTravelY: Math.max(root.animationOffset, root.alignedHeight + Theme.spacingL) + readonly property real offsetX: { + if (root.frameOwnsConnectedChrome) { + switch (root.resolvedConnectedBarSide) { + case "left": + return -connectedEmergenceTravelX; + case "right": + return connectedEmergenceTravelX; + } + return 0; + } + if (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2 && Theme.isDirectionalEffect) + return 0; + 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 (root.frameOwnsConnectedChrome) { + switch (root.resolvedConnectedBarSide) { + case "top": + return -connectedEmergenceTravelY; + case "bottom": + return connectedEmergenceTravelY; + } + return 0; + } + if (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2 && Theme.isDirectionalEffect) + return 0; + 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 + + onAnimXChanged: if (root.frameOwnsConnectedChrome) + root._syncModalAnim() + onAnimYChanged: if (root.frameOwnsConnectedChrome) + root._syncModalAnim() + + readonly property real computedScaleCollapsed: (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2 && Theme.isDirectionalEffect) ? 0.0 : root.animationScaleCollapsed + property real scaleValue: root.shouldBeVisible ? 1.0 : computedScaleCollapsed + + Behavior on animX { + enabled: root.animationsEnabled + NumberAnimation { + 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: 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: Theme.variantDuration(root.animationDuration, root.shouldBeVisible) + easing.type: Easing.BezierSpline + easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve + } + } + + Item { + id: contentContainer + anchors.centerIn: parent + width: parent.width + height: parent.height + clip: false + + Item { + id: animatedContent + anchors.fill: parent + clip: false + opacity: (Theme.isDirectionalEffect && !Theme.isConnectedEffect) ? 1 : (root.shouldBeVisible ? 1 : 0) + scale: modalContainer.scaleValue + transformOrigin: Item.Center + + Behavior on opacity { + enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || Theme.isConnectedEffect) + NumberAnimation { + duration: Math.round(Theme.variantDuration(animationDuration, root.shouldBeVisible) * Theme.variantOpacityDurationScale) + easing.type: Easing.BezierSpline + easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve + } + } + + ElevationShadow { + id: modalShadowLayer + anchors.fill: parent + level: root.shadowLevel + fallbackOffset: root.shadowFallbackOffset + targetRadius: root.effectiveCornerRadius + targetColor: root.frameOwnsConnectedChrome ? "transparent" : root.effectiveBackgroundColor + borderColor: root.frameOwnsConnectedChrome ? "transparent" : root.effectiveBorderColor + borderWidth: root.frameOwnsConnectedChrome ? 0 : root.effectiveBorderWidth + shadowEnabled: !root.frameOwnsConnectedChrome && root.enableShadow && Theme.elevationEnabled && SettingsData.modalElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1" + } + + Rectangle { + anchors.fill: parent + radius: root.effectiveCornerRadius + color: "transparent" + border.color: (root.connectedSurfaceOverride || root.frameOwnsConnectedChrome) ? "transparent" : BlurService.borderColor + border.width: (root.connectedSurfaceOverride || root.frameOwnsConnectedChrome) ? 0 : BlurService.borderWidth + z: 100 + } + + FocusScope { + anchors.fill: parent + focus: root.shouldBeVisible + clip: false + + Item { + id: directContentWrapper + anchors.fill: parent + visible: root.directContent !== null + focus: true + clip: false + + Component.onCompleted: { + if (root.directContent) { + root.directContent.parent = directContentWrapper; + root.directContent.anchors.fill = directContentWrapper; + Qt.callLater(() => root.directContent.forceActiveFocus()); + } + } + + Connections { + target: root + function onDirectContentChanged() { + if (root.directContent) { + root.directContent.parent = directContentWrapper; + root.directContent.anchors.fill = directContentWrapper; + Qt.callLater(() => root.directContent.forceActiveFocus()); + } + } + } + } + + Loader { + id: contentLoader + anchors.fill: parent + active: root.directContent === null && (root.keepContentLoaded || root.shouldBeVisible || contentWindow.visible) + asynchronous: false + focus: true + clip: false + visible: root.directContent === null + + onLoaded: { + if (item) { + Qt.callLater(() => item.forceActiveFocus()); + } + } + } + } + } + } + } + + FocusScope { + id: focusScope + objectName: "modalFocusScope" + anchors.fill: parent + visible: root.shouldBeVisible || contentWindow.visible + focus: root.shouldBeVisible + Keys.onEscapePressed: event => { + if (root.closeOnEscapeKey && shouldHaveFocus) { + root.close(); + event.accepted = true; + } + } + } + } +} diff --git a/quickshell/Modals/Common/DankModalStandalone.qml b/quickshell/Modals/Common/DankModalStandalone.qml new file mode 100644 index 00000000..84d439dc --- /dev/null +++ b/quickshell/Modals/Common/DankModalStandalone.qml @@ -0,0 +1,502 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import qs.Common +import qs.Services +import qs.Widgets + +Item { + id: root + + property var modalHandle: root + property string layerNamespace: "dms:modal" + property alias content: contentLoader.sourceComponent + property alias contentLoader: contentLoader + property Item directContent: null + property real modalWidth: 400 + property real modalHeight: 300 + property var targetScreen + readonly property var effectiveScreen: contentWindow.screen ?? targetScreen + readonly property real screenWidth: effectiveScreen?.width ?? 1920 + readonly property real screenHeight: effectiveScreen?.height ?? 1080 + readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1 + property bool showBackground: true + property real backgroundOpacity: 0.5 + property string positioning: "center" + property point customPosition: Qt.point(0, 0) + property bool closeOnEscapeKey: true + 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 color backgroundColor: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) + property color borderColor: Theme.outlineMedium + property real borderWidth: 0 + property real cornerRadius: Theme.cornerRadius + property bool enableShadow: true + property alias modalFocusScope: focusScope + property bool shouldBeVisible: false + property bool isClosing: false + property bool shouldHaveFocus: shouldBeVisible + property bool allowFocusOverride: false + property bool allowStacking: false + property bool keepContentLoaded: false + property bool keepPopoutsOpen: false + property var customKeyboardFocus: null + property bool useOverlayLayer: false + 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 + + signal opened + signal dialogClosed + signal backgroundClicked + + property bool animationsEnabled: true + + function open() { + closeTimer.stop(); + isClosing = false; + const focusedScreen = CompositorService.getFocusedScreen(); + const screenChanged = focusedScreen && contentWindow.screen !== focusedScreen; + if (focusedScreen) { + if (screenChanged) + contentWindow.visible = false; + contentWindow.screen = focusedScreen; + if (!useSingleWindow) { + if (screenChanged) + clickCatcher.visible = false; + clickCatcher.screen = focusedScreen; + } + } + if (screenChanged) { + Qt.callLater(() => root._finishOpen()); + } else { + _finishOpen(); + } + } + + function _finishOpen() { + ModalManager.openModal(modalHandle); + shouldBeVisible = true; + if (!useSingleWindow) + clickCatcher.visible = true; + contentWindow.visible = true; + shouldHaveFocus = false; + Qt.callLater(() => shouldHaveFocus = Qt.binding(() => shouldBeVisible)); + } + + function close() { + isClosing = true; + shouldBeVisible = false; + shouldHaveFocus = false; + ModalManager.closeModal(modalHandle); + closeTimer.restart(); + } + + function instantClose() { + animationsEnabled = false; + isClosing = false; + shouldBeVisible = false; + shouldHaveFocus = false; + ModalManager.closeModal(modalHandle); + closeTimer.stop(); + contentWindow.visible = false; + if (!useSingleWindow) + clickCatcher.visible = false; + dialogClosed(); + Qt.callLater(() => animationsEnabled = true); + } + + function toggle() { + shouldBeVisible ? close() : open(); + } + + Connections { + target: ModalManager + function onCloseAllModalsExcept(excludedModal) { + if (excludedModal !== modalHandle && !allowStacking && shouldBeVisible) + close(); + } + } + + Connections { + target: Quickshell + function onScreensChanged() { + if (!contentWindow.screen) + return; + const currentScreenName = contentWindow.screen.name; + let screenStillExists = false; + for (let i = 0; i < Quickshell.screens.length; i++) { + if (Quickshell.screens[i].name === currentScreenName) { + screenStillExists = true; + break; + } + } + if (screenStillExists) + return; + const newScreen = CompositorService.getFocusedScreen(); + if (newScreen) { + contentWindow.screen = newScreen; + if (!useSingleWindow) + clickCatcher.screen = newScreen; + } + } + } + + Timer { + id: closeTimer + interval: animationDuration + 50 + onTriggered: { + if (shouldBeVisible) + return; + isClosing = false; + contentWindow.visible = false; + if (!useSingleWindow) + clickCatcher.visible = false; + dialogClosed(); + } + } + + 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 shadowBuffer: Theme.snap(shadowRenderPadding + shadowMotionPadding, dpr) + readonly property real alignedWidth: Theme.px(modalWidth, dpr) + readonly property real alignedHeight: Theme.px(modalHeight, dpr) + + readonly property real alignedX: Theme.snap((() => { + switch (positioning) { + case "center": + return (screenWidth - alignedWidth) / 2; + case "top-right": + return Math.max(Theme.spacingL, screenWidth - alignedWidth - Theme.spacingL); + case "custom": + return customPosition.x; + default: + return 0; + } + })(), dpr) + + readonly property real alignedY: Theme.snap((() => { + switch (positioning) { + case "center": + return (screenHeight - alignedHeight) / 2; + case "top-right": + return Theme.barHeight + Theme.spacingXS; + case "custom": + return customPosition.y; + default: + return 0; + } + })(), dpr) + + PanelWindow { + id: clickCatcher + visible: false + color: "transparent" + + WlrLayershell.namespace: root.layerNamespace + ":clickcatcher" + WlrLayershell.layer: WlrLayershell.Top + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + anchors { + top: true + left: true + right: true + bottom: true + } + + mask: Region { + item: Rectangle { + x: root.alignedX + y: root.alignedY + width: root.alignedWidth + height: root.alignedHeight + } + intersection: Intersection.Xor + } + + MouseArea { + anchors.fill: parent + enabled: root.closeOnBackgroundClick && root.shouldBeVisible + onClicked: root.backgroundClicked() + } + } + + PanelWindow { + id: contentWindow + visible: false + color: "transparent" + + WindowBlur { + targetWindow: contentWindow + readonly property real s: Math.min(1, modalContainer.scaleValue) + blurX: modalContainer.x + modalContainer.width * (1 - s) * 0.5 + Theme.snap(modalContainer.animX, root.dpr) + blurY: modalContainer.y + modalContainer.height * (1 - s) * 0.5 + Theme.snap(modalContainer.animY, root.dpr) + blurWidth: (shouldBeVisible && animatedContent.opacity > 0) ? modalContainer.width * s : 0 + blurHeight: (shouldBeVisible && animatedContent.opacity > 0) ? modalContainer.height * s : 0 + blurRadius: root.cornerRadius + } + + WlrLayershell.namespace: root.layerNamespace + WlrLayershell.layer: { + if (root.useOverlayLayer) + return WlrLayershell.Overlay; + 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.exclusiveZone: -1 + WlrLayershell.keyboardFocus: { + if (customKeyboardFocus !== null) + return customKeyboardFocus; + if (!shouldHaveFocus) + return WlrKeyboardFocus.None; + if (root.useHyprlandFocusGrab) + return WlrKeyboardFocus.OnDemand; + return WlrKeyboardFocus.Exclusive; + } + + anchors { + left: true + top: true + right: root.useSingleWindow + bottom: root.useSingleWindow + } + + 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)) + right: 0 + bottom: 0 + } + + implicitWidth: root.useSingleWindow ? 0 : root.alignedWidth + (shadowBuffer * 2) + implicitHeight: root.useSingleWindow ? 0 : root.alignedHeight + (shadowBuffer * 2) + + onVisibleChanged: { + if (visible) { + opened(); + } else { + if (Qt.inputMethod) { + Qt.inputMethod.hide(); + Qt.inputMethod.reset(); + } + } + } + + MouseArea { + anchors.fill: parent + enabled: root.useSingleWindow && root.closeOnBackgroundClick && root.shouldBeVisible + z: -2 + onClicked: root.backgroundClicked() + } + + Rectangle { + anchors.fill: parent + z: -1 + color: "black" + opacity: root.useBackground ? (root.shouldBeVisible ? root.backgroundOpacity : 0) : 0 + visible: root.useBackground + + Behavior on opacity { + enabled: root.animationsEnabled + DankAnim { + duration: root.animationDuration + easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve + } + } + } + + Item { + id: modalContainer + x: root.useSingleWindow ? root.alignedX : shadowBuffer + y: root.useSingleWindow ? root.alignedY : shadowBuffer + + width: root.alignedWidth + height: root.alignedHeight + + MouseArea { + anchors.fill: parent + enabled: root.useSingleWindow && root.shouldBeVisible + hoverEnabled: false + acceptedButtons: Qt.AllButtons + onPressed: mouse.accepted = true + onClicked: mouse.accepted = true + z: -1 + } + + 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; + } + } + + Behavior on animX { + enabled: root.animationsEnabled + DankAnim { + duration: root.animationDuration + easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve + } + } + + Behavior on animY { + enabled: root.animationsEnabled + DankAnim { + duration: root.animationDuration + easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve + } + } + + Behavior on scaleValue { + enabled: root.animationsEnabled + DankAnim { + duration: root.animationDuration + easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve + } + } + + Item { + id: contentContainer + anchors.centerIn: parent + width: parent.width + height: parent.height + clip: false + + Item { + id: animatedContent + anchors.fill: parent + clip: false + opacity: 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 + + Behavior on opacity { + enabled: root.animationsEnabled + NumberAnimation { + duration: animationDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve + } + } + + ElevationShadow { + id: modalShadowLayer + anchors.fill: parent + level: root.shadowLevel + fallbackOffset: root.shadowFallbackOffset + targetRadius: root.cornerRadius + targetColor: root.backgroundColor + borderColor: root.borderColor + borderWidth: root.borderWidth + shadowEnabled: root.enableShadow && Theme.elevationEnabled && SettingsData.modalElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1" + } + + Rectangle { + anchors.fill: parent + radius: root.cornerRadius + color: "transparent" + border.color: BlurService.borderColor + border.width: BlurService.borderWidth + z: 100 + } + + FocusScope { + anchors.fill: parent + focus: root.shouldBeVisible + clip: false + + Item { + id: directContentWrapper + anchors.fill: parent + visible: root.directContent !== null + focus: true + clip: false + + Component.onCompleted: { + if (root.directContent) { + root.directContent.parent = directContentWrapper; + root.directContent.anchors.fill = directContentWrapper; + Qt.callLater(() => root.directContent.forceActiveFocus()); + } + } + + Connections { + target: root + function onDirectContentChanged() { + if (root.directContent) { + root.directContent.parent = directContentWrapper; + root.directContent.anchors.fill = directContentWrapper; + Qt.callLater(() => root.directContent.forceActiveFocus()); + } + } + } + } + + Loader { + id: contentLoader + anchors.fill: parent + active: root.directContent === null && (root.keepContentLoaded || root.shouldBeVisible || contentWindow.visible) + asynchronous: false + focus: true + clip: false + visible: root.directContent === null + + onLoaded: { + if (item) { + Qt.callLater(() => item.forceActiveFocus()); + } + } + } + } + } + } + } + + FocusScope { + id: focusScope + objectName: "modalFocusScope" + anchors.fill: parent + visible: root.shouldBeVisible || contentWindow.visible + focus: root.shouldBeVisible + Keys.onEscapePressed: event => { + if (root.closeOnEscapeKey && shouldHaveFocus) { + root.close(); + event.accepted = true; + } + } + } + } +} diff --git a/quickshell/Modals/DankLauncherV2/DankLauncherV2Modal.qml b/quickshell/Modals/DankLauncherV2/DankLauncherV2Modal.qml index bdf55006..b69ef0bc 100644 --- a/quickshell/Modals/DankLauncherV2/DankLauncherV2Modal.qml +++ b/quickshell/Modals/DankLauncherV2/DankLauncherV2Modal.qml @@ -1,826 +1,84 @@ import QtQuick -import QtQuick.Effects -import Quickshell -import Quickshell.Wayland -import Quickshell.Hyprland import qs.Common -import qs.Services -import qs.Widgets Item { id: root - visible: false - - property bool spotlightOpen: false - property bool keyboardActive: false - property bool contentVisible: false - readonly property bool launcherMotionVisible: Theme.isConnectedEffect ? _motionActive : (Theme.isDirectionalEffect ? spotlightOpen : _motionActive) - property var spotlightContent: launcherContentLoader.item - property bool openedFromOverview: false - property bool isClosing: false - property bool _windowEnabled: true - property bool _pendingInitialize: false - property string _pendingQuery: "" - 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: 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 - - readonly property int baseWidth: { - switch (SettingsData.dankLauncherV2Size) { - case "micro": - return 500; - case "medium": - return 720; - case "large": - return 860; - default: - return 620; - } - } - readonly property int baseHeight: { - switch (SettingsData.dankLauncherV2Size) { - case "micro": - return 480; - case "medium": - return 720; - case "large": - return 860; - default: - return 600; - } - } - readonly property int modalWidth: Math.min(baseWidth, screenWidth - 100) - readonly property int modalHeight: Math.min(baseHeight, screenHeight - 100) - - readonly property string preferredConnectedBarSide: SettingsData.frameLauncherEmergeSide - - readonly property bool frameConnectedMode: SettingsData.frameEnabled - && Theme.isConnectedEffect - && !!effectiveScreen - && SettingsData.isScreenInPreferences(effectiveScreen, SettingsData.frameScreenPreferences) - - readonly property string resolvedConnectedBarSide: frameConnectedMode ? preferredConnectedBarSide : "" - - readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" - - function _dockOccupiesSide(side) { - if (!SettingsData.showDock) return false; - switch (side) { - case "top": return SettingsData.dockPosition === SettingsData.Position.Top; - case "bottom": return SettingsData.dockPosition === SettingsData.Position.Bottom; - case "left": return SettingsData.dockPosition === SettingsData.Position.Left; - case "right": return SettingsData.dockPosition === SettingsData.Position.Right; - } - return false; - } - readonly property bool _dockBlocksEmergence: frameOwnsConnectedChrome && _dockOccupiesSide(resolvedConnectedBarSide) - - function _frameEdgeInset(side) { - if (!effectiveScreen) return 0; - return SettingsData.frameEdgeInsetForSide(effectiveScreen, side); - } - - // frameEdgeInsetForSide is the full inset; do not add frameBarSize - readonly property real _connectedModalX: { - switch (resolvedConnectedBarSide) { - case "top": - case "bottom": { - const insetL = _frameEdgeInset("left"); - const insetR = _frameEdgeInset("right"); - const usable = Math.max(0, screenWidth - insetL - insetR); - return insetL + Math.max(0, (usable - modalWidth) / 2); - } - case "left": - return _frameEdgeInset("left"); - case "right": - return screenWidth - modalWidth - _frameEdgeInset("right"); - } - return (screenWidth - modalWidth) / 2; - } - - readonly property real _connectedModalY: { - switch (resolvedConnectedBarSide) { - case "top": - return _frameEdgeInset("top"); - case "bottom": - return screenHeight - modalHeight - _frameEdgeInset("bottom"); - case "left": - case "right": { - const insetT = _frameEdgeInset("top"); - const insetB = _frameEdgeInset("bottom"); - const usable = Math.max(0, screenHeight - insetT - insetB); - return insetT + Math.max(0, (usable - modalHeight) / 2); - } - } - return (screenHeight - modalHeight) / 2; - } - - readonly property real modalX: frameOwnsConnectedChrome ? _connectedModalX : ((screenWidth - modalWidth) / 2) - readonly property real modalY: frameOwnsConnectedChrome ? _connectedModalY : ((screenHeight - modalHeight) / 2) - - readonly property bool connectedSurfaceOverride: Theme.isConnectedEffect - readonly property int launcherAnimationDuration: Theme.isConnectedEffect ? Theme.popoutAnimationDuration : Theme.modalAnimationDuration - readonly property list launcherEnterCurve: Theme.isConnectedEffect ? Theme.variantPopoutEnterCurve : Theme.variantModalEnterCurve - readonly property list launcherExitCurve: Theme.isConnectedEffect ? Theme.variantPopoutExitCurve : Theme.variantModalExitCurve - readonly property color backgroundColor: connectedSurfaceOverride ? Theme.connectedSurfaceColor : Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) - readonly property real cornerRadius: connectedSurfaceOverride ? Theme.connectedSurfaceRadius : Theme.cornerRadius - readonly property color borderColor: { - if (!SettingsData.dankLauncherV2BorderEnabled) - return Theme.outlineMedium; - switch (SettingsData.dankLauncherV2BorderColor) { - case "primary": - return Theme.primary; - case "secondary": - return Theme.secondary; - case "outline": - return Theme.outline; - case "surfaceText": - return Theme.surfaceText; - default: - return Theme.primary; - } - } - readonly property int borderWidth: SettingsData.dankLauncherV2BorderEnabled ? SettingsData.dankLauncherV2BorderThickness : 0 - readonly property color effectiveBorderColor: connectedSurfaceOverride ? "transparent" : borderColor - readonly property int effectiveBorderWidth: connectedSurfaceOverride ? 0 : borderWidth - readonly property bool effectiveBlurEnabled: Theme.connectedSurfaceBlurEnabled - - // Shadow padding for the content window (render padding only, no motion padding). - // Zeroed when frame owns the chrome and Wayland clips past the bar edge - readonly property var shadowLevel: Theme.elevationLevel3 - readonly property real shadowFallbackOffset: 6 - readonly property real shadowRenderPadding: (!frameOwnsConnectedChrome && 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.isConnectedEffect) || 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 && !Theme.isConnectedEffect) - 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 + readonly property bool spotlightOpen: impl.item ? impl.item.spotlightOpen : false + readonly property bool isClosing: impl.item ? impl.item.isClosing : false + readonly property bool keyboardActive: impl.item ? impl.item.keyboardActive : false + readonly property bool contentVisible: impl.item ? impl.item.contentVisible : false + readonly property var spotlightContent: impl.item ? impl.item.spotlightContent : null + readonly property bool openedFromOverview: impl.item ? impl.item.openedFromOverview : false + readonly property var effectiveScreen: impl.item ? impl.item.effectiveScreen : null + readonly property real screenWidth: impl.item ? impl.item.screenWidth : 1920 + readonly property real screenHeight: impl.item ? impl.item.screenHeight : 1080 + readonly property real dpr: impl.item ? impl.item.dpr : 1 + readonly property int modalWidth: impl.item ? impl.item.modalWidth : 620 + readonly property int modalHeight: impl.item ? impl.item.modalHeight : 600 + readonly property real modalX: impl.item ? impl.item.modalX : 0 + readonly property real modalY: impl.item ? impl.item.modalY : 0 + readonly property bool frameOwnsConnectedChrome: impl.item ? (impl.item.frameOwnsConnectedChrome ?? false) : false + readonly property string resolvedConnectedBarSide: impl.item ? (impl.item.resolvedConnectedBarSide ?? "") : "" signal dialogClosed - // ─── Connected chrome sync ──────────────────────────────────────────────── - property string _chromeClaimId: "" - property bool _fullSyncPending: false - - function _nextChromeClaimId() { - return "dms:launcher-v2:" + (new Date()).getTime() + ":" + Math.floor(Math.random() * 1000); - } - - function _currentScreenName() { - return effectiveScreen ? effectiveScreen.name : ""; - } - - function _publishModalChromeState() { - const screenName = _currentScreenName(); - if (!screenName) return; - ConnectedModeState.setModalState(screenName, { - "visible": spotlightOpen || contentWindow.visible, - "barSide": resolvedConnectedBarSide, - "bodyX": alignedX, - "bodyY": alignedY, - "bodyW": alignedWidth, - "bodyH": alignedHeight, - "animX": contentContainer ? contentContainer.animX : 0, - "animY": contentContainer ? contentContainer.animY : 0, - "omitStartConnector": false, - "omitEndConnector": false - }); - } - - function _syncModalChromeState() { - if (!frameOwnsConnectedChrome) { - _releaseModalChrome(); - return; - } - if (!_chromeClaimId) - _chromeClaimId = _nextChromeClaimId(); - _publishModalChromeState(); - if (_dockBlocksEmergence && (spotlightOpen || contentWindow.visible)) - ConnectedModeState.requestDockRetract(_chromeClaimId, _currentScreenName(), resolvedConnectedBarSide); - else - ConnectedModeState.releaseDockRetract(_chromeClaimId); - } - - function _flushFullSync() { - _fullSyncPending = false; - _syncModalChromeState(); - } - - function _queueFullSync() { - if (_fullSyncPending) return; - _fullSyncPending = true; - Qt.callLater(() => { - if (root && typeof root._flushFullSync === "function") - root._flushFullSync(); - }); - } - - function _syncModalAnim() { - if (!frameOwnsConnectedChrome || !_chromeClaimId) return; - const screenName = _currentScreenName(); - if (!screenName || !contentContainer) return; - ConnectedModeState.setModalAnim(screenName, contentContainer.animX, contentContainer.animY); - } - - function _syncModalBody() { - if (!frameOwnsConnectedChrome || !_chromeClaimId) return; - const screenName = _currentScreenName(); - if (!screenName) return; - ConnectedModeState.setModalBody(screenName, alignedX, alignedY, alignedWidth, alignedHeight); - } - - function _releaseModalChrome() { - if (_chromeClaimId) { - ConnectedModeState.releaseDockRetract(_chromeClaimId); - _chromeClaimId = ""; - } - const screenName = _currentScreenName(); - if (screenName) - ConnectedModeState.clearModalState(screenName); - } - - onFrameOwnsConnectedChromeChanged: _syncModalChromeState() - onResolvedConnectedBarSideChanged: _queueFullSync() - onSpotlightOpenChanged: _queueFullSync() - onAlignedXChanged: _syncModalBody() - onAlignedYChanged: _syncModalBody() - onAlignedWidthChanged: _syncModalBody() - onAlignedHeightChanged: _syncModalBody() - - Component.onDestruction: _releaseModalChrome() - - Connections { - target: contentWindow - function onVisibleChanged() { - if (contentWindow.visible) - root._syncModalChromeState(); - else - root._releaseModalChrome(); - } - } - - function _ensureContentLoadedAndInitialize(query, mode) { - _pendingQuery = query || ""; - _pendingMode = mode || ""; - _pendingInitialize = true; - contentVisible = true; - launcherContentLoader.active = true; - - if (spotlightContent) { - _initializeAndShow(_pendingQuery, _pendingMode); - _pendingInitialize = false; - } - } - - function _initializeAndShow(query, mode) { - if (!spotlightContent) - return; - contentVisible = true; - // 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; - } - if (spotlightContent.controller) { - var targetMode = mode || SessionData.launcherLastMode || "all"; - spotlightContent.controller.searchMode = targetMode; - spotlightContent.controller.activePluginId = ""; - spotlightContent.controller.activePluginName = ""; - spotlightContent.controller.pluginFilter = ""; - spotlightContent.controller.fileSearchType = "all"; - spotlightContent.controller.fileSearchExt = ""; - spotlightContent.controller.fileSearchFolder = ""; - spotlightContent.controller.fileSearchSort = "score"; - spotlightContent.controller.collapsedSections = {}; - spotlightContent.controller.selectedFlatIndex = 0; - spotlightContent.controller.selectedItem = null; - if (query) { - spotlightContent.controller.setSearchQuery(query); - } else { - spotlightContent.controller.searchQuery = ""; - spotlightContent.controller.performSearch(); - } - } - if (spotlightContent.resetScroll) { - spotlightContent.resetScroll(); - } - if (spotlightContent.actionPanel) { - spotlightContent.actionPanel.hide(); - } - } - - function _openCommon(query, mode) { - closeCleanupTimer.stop(); - isClosing = false; - openedFromOverview = false; - - // Disable animations so the snap is instant - animationsEnabled = false; - - // 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; - - // 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("", ""); + if (impl.item) + impl.item.show(); } function showWithQuery(query) { - _openCommon(query, ""); - } - - function hide() { - if (!spotlightOpen) - return; - openedFromOverview = false; - isClosing = true; - // 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(); - } - - function toggle() { - spotlightOpen ? hide() : show(); + if (impl.item) + impl.item.showWithQuery(query); } function showWithMode(mode) { - _openCommon("", mode); + if (impl.item) + impl.item.showWithMode(mode); } - function toggleWithMode(mode) { - if (spotlightOpen) { - hide(); - } else { - showWithMode(mode); - } + function hide() { + if (impl.item) + impl.item.hide(); + } + + function toggle() { + if (impl.item) + impl.item.toggle(); } function toggleWithQuery(query) { - if (spotlightOpen) { - hide(); - } else { - showWithQuery(query); - } + if (impl.item) + impl.item.toggleWithQuery(query); } - Timer { - id: closeCleanupTimer - interval: Theme.variantCloseInterval(root.launcherAnimationDuration) - repeat: false - onTriggered: { - isClosing = false; - contentVisible = false; - contentWindow.visible = false; - backgroundWindow.visible = false; - if (root.unloadContentOnClose) - launcherContentLoader.active = false; - dialogClosed(); - } + function toggleWithMode(mode) { + if (impl.item) + impl.item.toggleWithMode(mode); } - Connections { - target: spotlightContent?.controller ?? null - function onModeChanged(mode) { - if (spotlightContent.controller.autoSwitchedToFiles) - return; - SessionData.setLauncherLastMode(mode); - } + Loader { + id: impl + sourceComponent: SettingsData.connectedFrameModeActive ? connectedComp : standaloneComp + onItemChanged: if (item) + root._wireBackend(item) } - HyprlandFocusGrab { - id: focusGrab - windows: [contentWindow] - active: false - - onCleared: { - if (spotlightOpen) { - hide(); - } - } + Component { + id: standaloneComp + DankLauncherV2ModalStandalone {} } - Connections { - target: ModalManager - function onCloseAllModalsExcept(excludedModal) { - if (excludedModal !== root && spotlightOpen) { - hide(); - } - } + Component { + id: connectedComp + DankLauncherV2ModalConnected {} } - Connections { - target: Quickshell - function onScreensChanged() { - if (Quickshell.screens.length === 0) - return; - - const screen = contentWindow.screen; - const screenName = screen?.name; - - let needsReset = !screen || !screenName; - if (!needsReset) { - needsReset = true; - for (let i = 0; i < Quickshell.screens.length; i++) { - if (Quickshell.screens[i].name === screenName) { - needsReset = false; - break; - } - } - } - - if (!needsReset) - return; - - const newScreen = CompositorService.getFocusedScreen() ?? Quickshell.screens[0]; - if (!newScreen) - return; - - root._windowEnabled = false; - backgroundWindow.screen = newScreen; - contentWindow.screen = newScreen; - Qt.callLater(() => { - root._windowEnabled = true; - }); - } + function _wireBackend(it) { + if (!it) + return; + it.modalHandle = root; + it.dialogClosed.connect(root.dialogClosed); } - - // ── Background window: fullscreen, handles darkening + click-to-dismiss ── - PanelWindow { - id: backgroundWindow - visible: false - color: "transparent" - - WlrLayershell.namespace: "dms:spotlight:bg" - WlrLayershell.layer: WlrLayershell.Top - WlrLayershell.exclusiveZone: -1 - WlrLayershell.keyboardFocus: WlrKeyboardFocus.None - - WlrLayershell.margins { - top: contentContainer.dockTop ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 0 ? Theme.px(42, root.dpr) : 0) - bottom: contentContainer.dockBottom ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 1 ? Theme.px(42, root.dpr) : 0) - left: contentContainer.dockLeft ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 2 ? Theme.px(42, root.dpr) : 0) - right: contentContainer.dockRight ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 3 ? Theme.px(42, root.dpr) : 0) - } - - anchors { - top: true - bottom: true - left: true - right: true - } - - mask: Region { - item: (spotlightOpen || isClosing) ? bgFullScreenMask : null - } - - Item { - id: bgFullScreenMask - anchors.fill: parent - } - - Rectangle { - id: backgroundDarken - anchors.fill: parent - color: "black" - opacity: launcherMotionVisible && SettingsData.modalDarkenBackground ? 0.5 : 0 - visible: launcherMotionVisible || opacity > 0 - - Behavior on opacity { - enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || Theme.isConnectedEffect) - DankAnim { - duration: Math.round(Theme.variantDuration(root.launcherAnimationDuration, launcherMotionVisible) * Theme.variantOpacityDurationScale) - easing.bezierCurve: launcherMotionVisible ? root.launcherEnterCurve : root.launcherExitCurve - } - } - } - - MouseArea { - anchors.fill: parent - enabled: spotlightOpen - onClicked: root.hide() - } - } - - // ── Content window: SMALL, positioned with margins — only renders the modal area ── - PanelWindow { - id: contentWindow - visible: false - color: "transparent" - - WindowBlur { - targetWindow: contentWindow - blurEnabled: root.effectiveBlurEnabled && !root.frameOwnsConnectedChrome - readonly property real s: Math.min(1, contentContainer.scaleValue) - blurX: root._ccX + root.alignedWidth * (1 - s) * 0.5 + Theme.snap(contentContainer.animX, root.dpr) - blurY: root._ccY + root.alignedHeight * (1 - s) * 0.5 + Theme.snap(contentContainer.animY, root.dpr) - blurWidth: (root.spotlightOpen || root.isClosing) && contentWrapper.opacity > 0 && !root.frameOwnsConnectedChrome ? root.alignedWidth * s : 0 - blurHeight: (root.spotlightOpen || root.isClosing) && contentWrapper.opacity > 0 && !root.frameOwnsConnectedChrome ? root.alignedHeight * s : 0 - blurRadius: root.cornerRadius - } - - 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: contentInputMask - visible: false - x: contentContainer.x + contentWrapper.x - y: contentContainer.y + contentWrapper.y - width: root.alignedWidth - height: root.alignedHeight - } - - Item { - id: contentContainer - - // 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 int dockEdge: typeof SettingsData !== "undefined" ? SettingsData.dockPosition : 1 - readonly property bool dockTop: dockEdge === 0 - readonly property bool dockBottom: dockEdge === 1 - readonly property bool dockLeft: dockEdge === 2 - readonly property bool dockRight: dockEdge === 3 - - readonly property real dockThickness: typeof SettingsData !== "undefined" && SettingsData.showDock ? Theme.px(SettingsData.dockIconSize + (SettingsData.dockMargin * 2) + SettingsData.dockSpacing + 8, root.dpr) : Theme.px(60, root.dpr) - - readonly property bool directionalEffect: Theme.isDirectionalEffect - readonly property bool depthEffect: Theme.isDepthEffect - readonly property real _connectedTravelX: Math.max(Theme.effectAnimOffset, root.alignedWidth + Theme.spacingL) - readonly property real _connectedTravelY: Math.max(Theme.effectAnimOffset, root.alignedHeight + Theme.spacingL) - readonly property real collapsedMotionX: { - if (root.frameOwnsConnectedChrome) { - switch (root.resolvedConnectedBarSide) { - case "left": return -_connectedTravelX; - case "right": return _connectedTravelX; - } - return 0; - } - if (directionalEffect) { - if (dockLeft) - return -(root._ccX + root.alignedWidth + Theme.effectAnimOffset); - if (dockRight) - return root.screenWidth - root._ccX + Theme.effectAnimOffset; - } - if (depthEffect) - return Theme.effectAnimOffset * 0.25; - return 0; - } - readonly property real collapsedMotionY: { - if (root.frameOwnsConnectedChrome) { - switch (root.resolvedConnectedBarSide) { - case "top": return -_connectedTravelY; - case "bottom": return _connectedTravelY; - } - return 0; - } - if (directionalEffect) { - if (dockTop) - return -(root._ccY + root.alignedHeight + Theme.effectAnimOffset); - if (dockBottom) - return root.screenHeight - root._ccY + root.shadowPad + Theme.effectAnimOffset; - return 0; - } - if (depthEffect) - return -Math.max(Theme.effectAnimOffset * 0.85, 34); - return -Math.max((root.shadowPad || 0) + Theme.effectAnimOffset, 40); - } - - // Declarative bindings — snap applied at render layer (contentWrapper x/y) - property real animX: root._motionActive ? 0 : root._frozenMotionX - property real animY: root._motionActive ? 0 : root._frozenMotionY - property real scaleValue: root._motionActive ? 1.0 : (Theme.isDirectionalEffect && typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2 ? Theme.effectScaleCollapsed : (Theme.isDirectionalEffect ? 1 : Theme.effectScaleCollapsed)) - - onAnimXChanged: if (root.frameOwnsConnectedChrome) root._syncModalAnim() - onAnimYChanged: if (root.frameOwnsConnectedChrome) root._syncModalAnim() - - Behavior on animX { - enabled: root.animationsEnabled - DankAnim { - duration: Theme.variantDuration(root.launcherAnimationDuration, root._motionActive) - easing.bezierCurve: root._motionActive ? root.launcherEnterCurve : root.launcherExitCurve - } - } - - Behavior on animY { - enabled: root.animationsEnabled - DankAnim { - duration: Theme.variantDuration(root.launcherAnimationDuration, root._motionActive) - easing.bezierCurve: root._motionActive ? root.launcherEnterCurve : root.launcherExitCurve - } - } - - Behavior on scaleValue { - enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2)) - DankAnim { - duration: Theme.variantDuration(root.launcherAnimationDuration, root._motionActive) - easing.bezierCurve: root._motionActive ? root.launcherEnterCurve : root.launcherExitCurve - } - } - - Item { - id: directionalClipMask - readonly property bool shouldClip: Theme.isDirectionalEffect && typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode > 0 - readonly property real clipOversize: 2000 - - clip: shouldClip - - x: shouldClip ? (contentContainer.dockRight ? -clipOversize : (contentContainer.dockLeft ? contentContainer.dockThickness - root._ccX : -clipOversize)) : 0 - y: shouldClip ? (contentContainer.dockBottom ? -clipOversize : (contentContainer.dockTop ? contentContainer.dockThickness - root._ccY : -clipOversize)) : 0 - - width: shouldClip ? parent.width + clipOversize + (contentContainer.dockRight ? (root.screenWidth - contentContainer.dockThickness - root._ccX - parent.width) : (contentContainer.dockLeft ? clipOversize : clipOversize)) : parent.width - height: shouldClip ? parent.height + clipOversize + (contentContainer.dockBottom ? (root.screenHeight - contentContainer.dockThickness - root._ccY - parent.height) : (contentContainer.dockTop ? clipOversize : clipOversize)) : parent.height - - Item { - id: aligner - x: directionalClipMask.x !== 0 ? -directionalClipMask.x : 0 - y: directionalClipMask.y !== 0 ? -directionalClipMask.y : 0 - width: contentContainer.width - height: contentContainer.height - - // Shadow mirrors contentWrapper position/scale/opacity - ElevationShadow { - id: launcherShadowLayer - 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.frameOwnsConnectedChrome ? "transparent" : root.backgroundColor - borderColor: root.frameOwnsConnectedChrome ? "transparent" : root.effectiveBorderColor - borderWidth: root.frameOwnsConnectedChrome ? 0 : root.effectiveBorderWidth - targetRadius: root.cornerRadius - shadowEnabled: !root.frameOwnsConnectedChrome && Theme.elevationEnabled && SettingsData.modalElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1" - } - - // contentWrapper moves inside static contentContainer — DankPopout pattern - Item { - id: contentWrapper - width: parent.width - height: parent.height - opacity: (Theme.isDirectionalEffect && !Theme.isConnectedEffect) ? 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) - - Behavior on opacity { - enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || Theme.isConnectedEffect) - DankAnim { - duration: Math.round(Theme.variantDuration(root.launcherAnimationDuration, launcherMotionVisible) * Theme.variantOpacityDurationScale) - easing.bezierCurve: launcherMotionVisible ? root.launcherEnterCurve : root.launcherExitCurve - } - } - - 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; - } - } - } // contentWrapper - } // aligner - } // directionalClipMask - } // contentContainer - } // PanelWindow } diff --git a/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalConnected.qml b/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalConnected.qml new file mode 100644 index 00000000..fcd12c92 --- /dev/null +++ b/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalConnected.qml @@ -0,0 +1,844 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland +import qs.Common +import qs.Services +import qs.Widgets + +Item { + id: root + + property var modalHandle: root + + visible: false + + property bool spotlightOpen: false + property bool keyboardActive: false + property bool contentVisible: false + readonly property bool launcherMotionVisible: Theme.isConnectedEffect ? _motionActive : (Theme.isDirectionalEffect ? spotlightOpen : _motionActive) + property var spotlightContent: launcherContentLoader.item + property bool openedFromOverview: false + property bool isClosing: false + property bool _windowEnabled: true + property bool _pendingInitialize: false + property string _pendingQuery: "" + 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: 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 + + readonly property int baseWidth: { + switch (SettingsData.dankLauncherV2Size) { + case "micro": + return 500; + case "medium": + return 720; + case "large": + return 860; + default: + return 620; + } + } + readonly property int baseHeight: { + switch (SettingsData.dankLauncherV2Size) { + case "micro": + return 480; + case "medium": + return 720; + case "large": + return 860; + default: + return 600; + } + } + readonly property int modalWidth: Math.min(baseWidth, screenWidth - 100) + readonly property int modalHeight: Math.min(baseHeight, screenHeight - 100) + + readonly property string preferredConnectedBarSide: SettingsData.frameLauncherEmergeSide + + readonly property bool frameConnectedMode: SettingsData.frameEnabled && Theme.isConnectedEffect && !!effectiveScreen && SettingsData.isScreenInPreferences(effectiveScreen, SettingsData.frameScreenPreferences) + + readonly property string resolvedConnectedBarSide: frameConnectedMode ? preferredConnectedBarSide : "" + + readonly property bool frameOwnsConnectedChrome: frameConnectedMode && resolvedConnectedBarSide !== "" + + function _dockOccupiesSide(side) { + if (!SettingsData.showDock) + return false; + switch (side) { + case "top": + return SettingsData.dockPosition === SettingsData.Position.Top; + case "bottom": + return SettingsData.dockPosition === SettingsData.Position.Bottom; + case "left": + return SettingsData.dockPosition === SettingsData.Position.Left; + case "right": + return SettingsData.dockPosition === SettingsData.Position.Right; + } + return false; + } + readonly property bool _dockBlocksEmergence: frameOwnsConnectedChrome && _dockOccupiesSide(resolvedConnectedBarSide) + + function _frameEdgeInset(side) { + if (!effectiveScreen) + return 0; + return SettingsData.frameEdgeInsetForSide(effectiveScreen, side); + } + + // frameEdgeInsetForSide is the full inset; do not add frameBarSize + readonly property real _connectedModalX: { + switch (resolvedConnectedBarSide) { + case "top": + case "bottom": + { + const insetL = _frameEdgeInset("left"); + const insetR = _frameEdgeInset("right"); + const usable = Math.max(0, screenWidth - insetL - insetR); + return insetL + Math.max(0, (usable - modalWidth) / 2); + } + case "left": + return _frameEdgeInset("left"); + case "right": + return screenWidth - modalWidth - _frameEdgeInset("right"); + } + return (screenWidth - modalWidth) / 2; + } + + readonly property real _connectedModalY: { + switch (resolvedConnectedBarSide) { + case "top": + return _frameEdgeInset("top"); + case "bottom": + return screenHeight - modalHeight - _frameEdgeInset("bottom"); + case "left": + case "right": + { + const insetT = _frameEdgeInset("top"); + const insetB = _frameEdgeInset("bottom"); + const usable = Math.max(0, screenHeight - insetT - insetB); + return insetT + Math.max(0, (usable - modalHeight) / 2); + } + } + return (screenHeight - modalHeight) / 2; + } + + readonly property real modalX: frameOwnsConnectedChrome ? _connectedModalX : ((screenWidth - modalWidth) / 2) + readonly property real modalY: frameOwnsConnectedChrome ? _connectedModalY : ((screenHeight - modalHeight) / 2) + + readonly property bool connectedSurfaceOverride: Theme.isConnectedEffect + readonly property int launcherAnimationDuration: Theme.isConnectedEffect ? Theme.popoutAnimationDuration : Theme.modalAnimationDuration + readonly property list launcherEnterCurve: Theme.isConnectedEffect ? Theme.variantPopoutEnterCurve : Theme.variantModalEnterCurve + readonly property list launcherExitCurve: Theme.isConnectedEffect ? Theme.variantPopoutExitCurve : Theme.variantModalExitCurve + readonly property color backgroundColor: connectedSurfaceOverride ? Theme.connectedSurfaceColor : Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) + readonly property real cornerRadius: connectedSurfaceOverride ? Theme.connectedSurfaceRadius : Theme.cornerRadius + readonly property color borderColor: { + if (!SettingsData.dankLauncherV2BorderEnabled) + return Theme.outlineMedium; + switch (SettingsData.dankLauncherV2BorderColor) { + case "primary": + return Theme.primary; + case "secondary": + return Theme.secondary; + case "outline": + return Theme.outline; + case "surfaceText": + return Theme.surfaceText; + default: + return Theme.primary; + } + } + readonly property int borderWidth: SettingsData.dankLauncherV2BorderEnabled ? SettingsData.dankLauncherV2BorderThickness : 0 + readonly property color effectiveBorderColor: connectedSurfaceOverride ? "transparent" : borderColor + readonly property int effectiveBorderWidth: connectedSurfaceOverride ? 0 : borderWidth + readonly property bool effectiveBlurEnabled: Theme.connectedSurfaceBlurEnabled + + // Shadow padding for the content window (render padding only, no motion padding). + // Zeroed when frame owns the chrome and Wayland clips past the bar edge + readonly property var shadowLevel: Theme.elevationLevel3 + readonly property real shadowFallbackOffset: 6 + readonly property real shadowRenderPadding: (!frameOwnsConnectedChrome && 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.isConnectedEffect) || 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 && !Theme.isConnectedEffect) + 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 + + // ─── Connected chrome sync ──────────────────────────────────────────────── + property string _chromeClaimId: "" + property bool _fullSyncPending: false + + function _nextChromeClaimId() { + return "dms:launcher-v2:" + (new Date()).getTime() + ":" + Math.floor(Math.random() * 1000); + } + + function _currentScreenName() { + return effectiveScreen ? effectiveScreen.name : ""; + } + + function _publishModalChromeState() { + const screenName = _currentScreenName(); + if (!screenName) + return; + ConnectedModeState.setModalState(screenName, { + "visible": spotlightOpen || contentWindow.visible, + "barSide": resolvedConnectedBarSide, + "bodyX": alignedX, + "bodyY": alignedY, + "bodyW": alignedWidth, + "bodyH": alignedHeight, + "animX": contentContainer ? contentContainer.animX : 0, + "animY": contentContainer ? contentContainer.animY : 0, + "omitStartConnector": false, + "omitEndConnector": false + }); + } + + function _syncModalChromeState() { + if (!frameOwnsConnectedChrome) { + _releaseModalChrome(); + return; + } + if (!_chromeClaimId) + _chromeClaimId = _nextChromeClaimId(); + _publishModalChromeState(); + if (_dockBlocksEmergence && (spotlightOpen || contentWindow.visible)) + ConnectedModeState.requestDockRetract(_chromeClaimId, _currentScreenName(), resolvedConnectedBarSide); + else + ConnectedModeState.releaseDockRetract(_chromeClaimId); + } + + function _flushFullSync() { + _fullSyncPending = false; + _syncModalChromeState(); + } + + function _queueFullSync() { + if (_fullSyncPending) + return; + _fullSyncPending = true; + Qt.callLater(() => { + if (root && typeof root._flushFullSync === "function") + root._flushFullSync(); + }); + } + + function _syncModalAnim() { + if (!frameOwnsConnectedChrome || !_chromeClaimId) + return; + const screenName = _currentScreenName(); + if (!screenName || !contentContainer) + return; + ConnectedModeState.setModalAnim(screenName, contentContainer.animX, contentContainer.animY); + } + + function _syncModalBody() { + if (!frameOwnsConnectedChrome || !_chromeClaimId) + return; + const screenName = _currentScreenName(); + if (!screenName) + return; + ConnectedModeState.setModalBody(screenName, alignedX, alignedY, alignedWidth, alignedHeight); + } + + function _releaseModalChrome() { + if (_chromeClaimId) { + ConnectedModeState.releaseDockRetract(_chromeClaimId); + _chromeClaimId = ""; + } + const screenName = _currentScreenName(); + if (screenName) + ConnectedModeState.clearModalState(screenName); + } + + onFrameOwnsConnectedChromeChanged: _syncModalChromeState() + onResolvedConnectedBarSideChanged: _queueFullSync() + onSpotlightOpenChanged: _queueFullSync() + onAlignedXChanged: _syncModalBody() + onAlignedYChanged: _syncModalBody() + onAlignedWidthChanged: _syncModalBody() + onAlignedHeightChanged: _syncModalBody() + + Component.onDestruction: _releaseModalChrome() + + Connections { + target: contentWindow + function onVisibleChanged() { + if (contentWindow.visible) + root._syncModalChromeState(); + else + root._releaseModalChrome(); + } + } + + function _ensureContentLoadedAndInitialize(query, mode) { + _pendingQuery = query || ""; + _pendingMode = mode || ""; + _pendingInitialize = true; + contentVisible = true; + launcherContentLoader.active = true; + + if (spotlightContent) { + _initializeAndShow(_pendingQuery, _pendingMode); + _pendingInitialize = false; + } + } + + function _initializeAndShow(query, mode) { + if (!spotlightContent) + return; + contentVisible = true; + // 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; + } + if (spotlightContent.controller) { + var targetMode = mode || SessionData.launcherLastMode || "all"; + spotlightContent.controller.searchMode = targetMode; + spotlightContent.controller.activePluginId = ""; + spotlightContent.controller.activePluginName = ""; + spotlightContent.controller.pluginFilter = ""; + spotlightContent.controller.fileSearchType = "all"; + spotlightContent.controller.fileSearchExt = ""; + spotlightContent.controller.fileSearchFolder = ""; + spotlightContent.controller.fileSearchSort = "score"; + spotlightContent.controller.collapsedSections = {}; + spotlightContent.controller.selectedFlatIndex = 0; + spotlightContent.controller.selectedItem = null; + if (query) { + spotlightContent.controller.setSearchQuery(query); + } else { + spotlightContent.controller.searchQuery = ""; + spotlightContent.controller.performSearch(); + } + } + if (spotlightContent.resetScroll) { + spotlightContent.resetScroll(); + } + if (spotlightContent.actionPanel) { + spotlightContent.actionPanel.hide(); + } + } + + function _openCommon(query, mode) { + closeCleanupTimer.stop(); + isClosing = false; + openedFromOverview = false; + + // Disable animations so the snap is instant + animationsEnabled = false; + + // 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(modalHandle); + spotlightOpen = true; + backgroundWindow.visible = true; + contentWindow.visible = true; + if (useHyprlandFocusGrab) + focusGrab.active = true; + + // 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) { + _openCommon(query, ""); + } + + function hide() { + if (!spotlightOpen) + return; + openedFromOverview = false; + isClosing = true; + // 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(modalHandle); + closeCleanupTimer.start(); + } + + function toggle() { + spotlightOpen ? hide() : show(); + } + + function showWithMode(mode) { + _openCommon("", mode); + } + + function toggleWithMode(mode) { + if (spotlightOpen) { + hide(); + } else { + showWithMode(mode); + } + } + + function toggleWithQuery(query) { + if (spotlightOpen) { + hide(); + } else { + showWithQuery(query); + } + } + + Timer { + id: closeCleanupTimer + interval: Theme.variantCloseInterval(root.launcherAnimationDuration) + repeat: false + onTriggered: { + isClosing = false; + contentVisible = false; + contentWindow.visible = false; + backgroundWindow.visible = false; + if (root.unloadContentOnClose) + launcherContentLoader.active = false; + dialogClosed(); + } + } + + Connections { + target: spotlightContent?.controller ?? null + function onModeChanged(mode) { + if (spotlightContent.controller.autoSwitchedToFiles) + return; + SessionData.setLauncherLastMode(mode); + } + } + + HyprlandFocusGrab { + id: focusGrab + windows: [contentWindow] + active: false + + onCleared: { + if (spotlightOpen) { + hide(); + } + } + } + + Connections { + target: ModalManager + function onCloseAllModalsExcept(excludedModal) { + if (excludedModal !== modalHandle && spotlightOpen) { + hide(); + } + } + } + + Connections { + target: Quickshell + function onScreensChanged() { + if (Quickshell.screens.length === 0) + return; + + const screen = contentWindow.screen; + const screenName = screen?.name; + + let needsReset = !screen || !screenName; + if (!needsReset) { + needsReset = true; + for (let i = 0; i < Quickshell.screens.length; i++) { + if (Quickshell.screens[i].name === screenName) { + needsReset = false; + break; + } + } + } + + if (!needsReset) + return; + + const newScreen = CompositorService.getFocusedScreen() ?? Quickshell.screens[0]; + if (!newScreen) + return; + + root._windowEnabled = false; + backgroundWindow.screen = newScreen; + contentWindow.screen = newScreen; + Qt.callLater(() => { + root._windowEnabled = true; + }); + } + } + + // ── Background window: fullscreen, handles darkening + click-to-dismiss ── + PanelWindow { + id: backgroundWindow + visible: false + color: "transparent" + + WlrLayershell.namespace: "dms:spotlight:bg" + WlrLayershell.layer: WlrLayershell.Top + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + WlrLayershell.margins { + top: contentContainer.dockTop ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 0 ? Theme.px(42, root.dpr) : 0) + bottom: contentContainer.dockBottom ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 1 ? Theme.px(42, root.dpr) : 0) + left: contentContainer.dockLeft ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 2 ? Theme.px(42, root.dpr) : 0) + right: contentContainer.dockRight ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 3 ? Theme.px(42, root.dpr) : 0) + } + + anchors { + top: true + bottom: true + left: true + right: true + } + + mask: Region { + item: (spotlightOpen || isClosing) ? bgFullScreenMask : null + } + + Item { + id: bgFullScreenMask + anchors.fill: parent + } + + Rectangle { + id: backgroundDarken + anchors.fill: parent + color: "black" + opacity: launcherMotionVisible && SettingsData.modalDarkenBackground ? 0.5 : 0 + visible: launcherMotionVisible || opacity > 0 + + Behavior on opacity { + enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || Theme.isConnectedEffect) + DankAnim { + duration: Math.round(Theme.variantDuration(root.launcherAnimationDuration, launcherMotionVisible) * Theme.variantOpacityDurationScale) + easing.bezierCurve: launcherMotionVisible ? root.launcherEnterCurve : root.launcherExitCurve + } + } + } + + MouseArea { + anchors.fill: parent + enabled: spotlightOpen + onClicked: root.hide() + } + } + + // ── Content window: SMALL, positioned with margins — only renders the modal area ── + PanelWindow { + id: contentWindow + visible: false + color: "transparent" + + WindowBlur { + targetWindow: contentWindow + blurEnabled: root.effectiveBlurEnabled && !root.frameOwnsConnectedChrome + readonly property real s: Math.min(1, contentContainer.scaleValue) + blurX: root._ccX + root.alignedWidth * (1 - s) * 0.5 + Theme.snap(contentContainer.animX, root.dpr) + blurY: root._ccY + root.alignedHeight * (1 - s) * 0.5 + Theme.snap(contentContainer.animY, root.dpr) + blurWidth: (root.spotlightOpen || root.isClosing) && contentWrapper.opacity > 0 && !root.frameOwnsConnectedChrome ? root.alignedWidth * s : 0 + blurHeight: (root.spotlightOpen || root.isClosing) && contentWrapper.opacity > 0 && !root.frameOwnsConnectedChrome ? root.alignedHeight * s : 0 + blurRadius: root.cornerRadius + } + + 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: contentInputMask + visible: false + x: contentContainer.x + contentWrapper.x + y: contentContainer.y + contentWrapper.y + width: root.alignedWidth + height: root.alignedHeight + } + + Item { + id: contentContainer + + // 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 int dockEdge: typeof SettingsData !== "undefined" ? SettingsData.dockPosition : 1 + readonly property bool dockTop: dockEdge === 0 + readonly property bool dockBottom: dockEdge === 1 + readonly property bool dockLeft: dockEdge === 2 + readonly property bool dockRight: dockEdge === 3 + + readonly property real dockThickness: typeof SettingsData !== "undefined" && SettingsData.showDock ? Theme.px(SettingsData.dockIconSize + (SettingsData.dockMargin * 2) + SettingsData.dockSpacing + 8, root.dpr) : Theme.px(60, root.dpr) + + readonly property bool directionalEffect: Theme.isDirectionalEffect + readonly property bool depthEffect: Theme.isDepthEffect + readonly property real _connectedTravelX: Math.max(Theme.effectAnimOffset, root.alignedWidth + Theme.spacingL) + readonly property real _connectedTravelY: Math.max(Theme.effectAnimOffset, root.alignedHeight + Theme.spacingL) + readonly property real collapsedMotionX: { + if (root.frameOwnsConnectedChrome) { + switch (root.resolvedConnectedBarSide) { + case "left": + return -_connectedTravelX; + case "right": + return _connectedTravelX; + } + return 0; + } + if (directionalEffect) { + if (dockLeft) + return -(root._ccX + root.alignedWidth + Theme.effectAnimOffset); + if (dockRight) + return root.screenWidth - root._ccX + Theme.effectAnimOffset; + } + if (depthEffect) + return Theme.effectAnimOffset * 0.25; + return 0; + } + readonly property real collapsedMotionY: { + if (root.frameOwnsConnectedChrome) { + switch (root.resolvedConnectedBarSide) { + case "top": + return -_connectedTravelY; + case "bottom": + return _connectedTravelY; + } + return 0; + } + if (directionalEffect) { + if (dockTop) + return -(root._ccY + root.alignedHeight + Theme.effectAnimOffset); + if (dockBottom) + return root.screenHeight - root._ccY + root.shadowPad + Theme.effectAnimOffset; + return 0; + } + if (depthEffect) + return -Math.max(Theme.effectAnimOffset * 0.85, 34); + return -Math.max((root.shadowPad || 0) + Theme.effectAnimOffset, 40); + } + + // Declarative bindings — snap applied at render layer (contentWrapper x/y) + property real animX: root._motionActive ? 0 : root._frozenMotionX + property real animY: root._motionActive ? 0 : root._frozenMotionY + property real scaleValue: root._motionActive ? 1.0 : (Theme.isDirectionalEffect && typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2 ? Theme.effectScaleCollapsed : (Theme.isDirectionalEffect ? 1 : Theme.effectScaleCollapsed)) + + onAnimXChanged: if (root.frameOwnsConnectedChrome) + root._syncModalAnim() + onAnimYChanged: if (root.frameOwnsConnectedChrome) + root._syncModalAnim() + + Behavior on animX { + enabled: root.animationsEnabled + DankAnim { + duration: Theme.variantDuration(root.launcherAnimationDuration, root._motionActive) + easing.bezierCurve: root._motionActive ? root.launcherEnterCurve : root.launcherExitCurve + } + } + + Behavior on animY { + enabled: root.animationsEnabled + DankAnim { + duration: Theme.variantDuration(root.launcherAnimationDuration, root._motionActive) + easing.bezierCurve: root._motionActive ? root.launcherEnterCurve : root.launcherExitCurve + } + } + + Behavior on scaleValue { + enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2)) + DankAnim { + duration: Theme.variantDuration(root.launcherAnimationDuration, root._motionActive) + easing.bezierCurve: root._motionActive ? root.launcherEnterCurve : root.launcherExitCurve + } + } + + Item { + id: directionalClipMask + readonly property bool shouldClip: Theme.isDirectionalEffect && typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode > 0 + readonly property real clipOversize: 2000 + + clip: shouldClip + + x: shouldClip ? (contentContainer.dockRight ? -clipOversize : (contentContainer.dockLeft ? contentContainer.dockThickness - root._ccX : -clipOversize)) : 0 + y: shouldClip ? (contentContainer.dockBottom ? -clipOversize : (contentContainer.dockTop ? contentContainer.dockThickness - root._ccY : -clipOversize)) : 0 + + width: shouldClip ? parent.width + clipOversize + (contentContainer.dockRight ? (root.screenWidth - contentContainer.dockThickness - root._ccX - parent.width) : (contentContainer.dockLeft ? clipOversize : clipOversize)) : parent.width + height: shouldClip ? parent.height + clipOversize + (contentContainer.dockBottom ? (root.screenHeight - contentContainer.dockThickness - root._ccY - parent.height) : (contentContainer.dockTop ? clipOversize : clipOversize)) : parent.height + + Item { + id: aligner + x: directionalClipMask.x !== 0 ? -directionalClipMask.x : 0 + y: directionalClipMask.y !== 0 ? -directionalClipMask.y : 0 + width: contentContainer.width + height: contentContainer.height + + // Shadow mirrors contentWrapper position/scale/opacity + ElevationShadow { + id: launcherShadowLayer + 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.frameOwnsConnectedChrome ? "transparent" : root.backgroundColor + borderColor: root.frameOwnsConnectedChrome ? "transparent" : root.effectiveBorderColor + borderWidth: root.frameOwnsConnectedChrome ? 0 : root.effectiveBorderWidth + targetRadius: root.cornerRadius + shadowEnabled: !root.frameOwnsConnectedChrome && Theme.elevationEnabled && SettingsData.modalElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1" + } + + // contentWrapper moves inside static contentContainer — DankPopout pattern + Item { + id: contentWrapper + width: parent.width + height: parent.height + opacity: (Theme.isDirectionalEffect && !Theme.isConnectedEffect) ? 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) + + Behavior on opacity { + enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || Theme.isConnectedEffect) + DankAnim { + duration: Math.round(Theme.variantDuration(root.launcherAnimationDuration, launcherMotionVisible) * Theme.variantOpacityDurationScale) + easing.bezierCurve: launcherMotionVisible ? root.launcherEnterCurve : root.launcherExitCurve + } + } + + 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; + } + } + } // contentWrapper + } // aligner + } // directionalClipMask + } // contentContainer + } // PanelWindow +} diff --git a/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalStandalone.qml b/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalStandalone.qml new file mode 100644 index 00000000..0bb2fe81 --- /dev/null +++ b/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalStandalone.qml @@ -0,0 +1,470 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland +import qs.Common +import qs.Services +import qs.Widgets + +Item { + id: root + + property var modalHandle: root + + visible: false + + property bool spotlightOpen: false + property bool keyboardActive: false + property bool contentVisible: false + property var spotlightContent: launcherContentLoader.item + property bool openedFromOverview: false + property bool isClosing: false + property bool _pendingInitialize: false + property string _pendingQuery: "" + property string _pendingMode: "" + readonly property bool unloadContentOnClose: SettingsData.dankLauncherV2UnloadOnClose + + readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab + readonly property var effectiveScreen: launcherWindow.screen + readonly property real screenWidth: effectiveScreen?.width ?? 1920 + readonly property real screenHeight: effectiveScreen?.height ?? 1080 + readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1 + + readonly property bool frameOwnsConnectedChrome: SettingsData.connectedFrameModeActive && !!effectiveScreen && SettingsData.isScreenInPreferences(effectiveScreen, SettingsData.frameScreenPreferences) + readonly property string resolvedConnectedBarSide: frameOwnsConnectedChrome ? (SettingsData.frameLauncherEmergeSide || "bottom") : "" + + readonly property int baseWidth: { + switch (SettingsData.dankLauncherV2Size) { + case "micro": + return 500; + case "medium": + return 720; + case "large": + return 860; + default: + return 620; + } + } + readonly property int baseHeight: { + switch (SettingsData.dankLauncherV2Size) { + case "micro": + return 480; + case "medium": + return 720; + case "large": + return 860; + default: + return 600; + } + } + readonly property int modalWidth: Math.min(baseWidth, screenWidth - 100) + readonly property int modalHeight: Math.min(baseHeight, screenHeight - 100) + readonly property real modalX: (screenWidth - modalWidth) / 2 + readonly property real modalY: (screenHeight - modalHeight) / 2 + + readonly property color backgroundColor: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) + readonly property real cornerRadius: Theme.cornerRadius + readonly property color borderColor: { + if (!SettingsData.dankLauncherV2BorderEnabled) + return Theme.outlineMedium; + switch (SettingsData.dankLauncherV2BorderColor) { + case "primary": + return Theme.primary; + case "secondary": + return Theme.secondary; + case "outline": + return Theme.outline; + case "surfaceText": + return Theme.surfaceText; + default: + return Theme.primary; + } + } + readonly property int borderWidth: SettingsData.dankLauncherV2BorderEnabled ? SettingsData.dankLauncherV2BorderThickness : 0 + + signal dialogClosed + + function _ensureContentLoadedAndInitialize(query, mode) { + _pendingQuery = query || ""; + _pendingMode = mode || ""; + _pendingInitialize = true; + contentVisible = true; + launcherContentLoader.active = true; + + if (spotlightContent) { + _initializeAndShow(_pendingQuery, _pendingMode); + _pendingInitialize = false; + } + } + + function _initializeAndShow(query, mode) { + if (!spotlightContent) + return; + contentVisible = true; + spotlightContent.searchField.forceActiveFocus(); + + var targetQuery = ""; + + if (query) { + targetQuery = query; + } else if (SettingsData.rememberLastQuery) { + targetQuery = SessionData.launcherLastQuery || ""; + } + + if (spotlightContent.searchField) { + spotlightContent.searchField.text = targetQuery; + } + if (spotlightContent.controller) { + var targetMode = mode || SessionData.launcherLastMode || "all"; + spotlightContent.controller.searchMode = targetMode; + spotlightContent.controller.activePluginId = ""; + spotlightContent.controller.activePluginName = ""; + spotlightContent.controller.pluginFilter = ""; + spotlightContent.controller.fileSearchType = "all"; + spotlightContent.controller.fileSearchExt = ""; + spotlightContent.controller.fileSearchFolder = ""; + spotlightContent.controller.fileSearchSort = "score"; + spotlightContent.controller.collapsedSections = {}; + spotlightContent.controller.selectedFlatIndex = 0; + spotlightContent.controller.selectedItem = null; + spotlightContent.controller.historyIndex = -1; + spotlightContent.controller.searchQuery = targetQuery; + + spotlightContent.controller.performSearch(); + } + if (spotlightContent.resetScroll) { + spotlightContent.resetScroll(); + } + if (spotlightContent.actionPanel) { + spotlightContent.actionPanel.hide(); + } + } + + function _finishShow(query, mode) { + spotlightOpen = true; + isClosing = false; + openedFromOverview = false; + + keyboardActive = true; + ModalManager.openModal(modalHandle); + if (useHyprlandFocusGrab) + focusGrab.active = true; + + _ensureContentLoadedAndInitialize(query || "", mode || ""); + } + + function show() { + closeCleanupTimer.stop(); + + var focusedScreen = CompositorService.getFocusedScreen(); + if (focusedScreen && launcherWindow.screen !== focusedScreen) { + spotlightOpen = false; + isClosing = false; + launcherWindow.screen = focusedScreen; + Qt.callLater(() => root._finishShow("", "")); + return; + } + + _finishShow("", ""); + } + + function showWithQuery(query) { + closeCleanupTimer.stop(); + + var focusedScreen = CompositorService.getFocusedScreen(); + if (focusedScreen && launcherWindow.screen !== focusedScreen) { + spotlightOpen = false; + isClosing = false; + launcherWindow.screen = focusedScreen; + Qt.callLater(() => root._finishShow(query, "")); + return; + } + + _finishShow(query, ""); + } + + function hide() { + if (!spotlightOpen) + return; + openedFromOverview = false; + isClosing = true; + contentVisible = false; + + keyboardActive = false; + spotlightOpen = false; + focusGrab.active = false; + ModalManager.closeModal(modalHandle); + + closeCleanupTimer.start(); + } + + function toggle() { + spotlightOpen ? hide() : show(); + } + + function showWithMode(mode) { + closeCleanupTimer.stop(); + + var focusedScreen = CompositorService.getFocusedScreen(); + if (focusedScreen && launcherWindow.screen !== focusedScreen) { + spotlightOpen = false; + isClosing = false; + launcherWindow.screen = focusedScreen; + Qt.callLater(() => root._finishShow("", mode)); + return; + } + + spotlightOpen = true; + isClosing = false; + openedFromOverview = false; + + keyboardActive = true; + ModalManager.openModal(modalHandle); + if (useHyprlandFocusGrab) + focusGrab.active = true; + + _ensureContentLoadedAndInitialize("", mode); + } + + function toggleWithMode(mode) { + if (spotlightOpen) { + hide(); + } else { + showWithMode(mode); + } + } + + function toggleWithQuery(query) { + if (spotlightOpen) { + hide(); + } else { + showWithQuery(query); + } + } + + Timer { + id: closeCleanupTimer + interval: Theme.modalAnimationDuration + 50 + repeat: false + onTriggered: { + isClosing = false; + if (root.unloadContentOnClose) + launcherContentLoader.active = false; + dialogClosed(); + } + } + + Connections { + target: spotlightContent?.controller ?? null + + function onModeChanged(mode) { + if (spotlightContent.controller.autoSwitchedToFiles) + return; + SessionData.setLauncherLastMode(mode); + } + } + + HyprlandFocusGrab { + id: focusGrab + windows: [launcherWindow] + active: false + + onCleared: { + if (spotlightOpen) { + hide(); + } + } + } + + Connections { + target: ModalManager + function onCloseAllModalsExcept(excludedModal) { + if (excludedModal !== modalHandle && spotlightOpen) { + hide(); + } + } + } + + Connections { + target: Quickshell + function onScreensChanged() { + if (Quickshell.screens.length === 0) + return; + + const screenName = launcherWindow.screen?.name; + if (screenName) { + for (let i = 0; i < Quickshell.screens.length; i++) { + if (Quickshell.screens[i].name === screenName) + return; + } + } + + if (spotlightOpen) + hide(); + + const newScreen = CompositorService.getFocusedScreen() ?? Quickshell.screens[0]; + if (newScreen) + launcherWindow.screen = newScreen; + } + } + + PanelWindow { + id: launcherWindow + visible: spotlightOpen || isClosing + color: "transparent" + exclusionMode: ExclusionMode.Ignore + + WindowBlur { + targetWindow: launcherWindow + readonly property real s: Math.min(1, modalContainer.scale) + blurX: root.modalX + root.modalWidth * (1 - s) * 0.5 + blurY: root.modalY + root.modalHeight * (1 - s) * 0.5 + blurWidth: (contentVisible && modalContainer.opacity > 0) ? root.modalWidth * s : 0 + blurHeight: (contentVisible && modalContainer.opacity > 0) ? root.modalHeight * s : 0 + blurRadius: root.cornerRadius + } + + 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 + + anchors { + top: true + bottom: true + left: true + right: true + } + + mask: Region { + item: spotlightOpen ? fullScreenMask : null + } + + Item { + id: fullScreenMask + anchors.fill: parent + } + + Rectangle { + id: backgroundDarken + anchors.fill: parent + color: "black" + opacity: contentVisible && SettingsData.modalDarkenBackground ? 0.5 : 0 + visible: contentVisible || opacity > 0 + + Behavior on opacity { + DankAnim { + duration: Theme.modalAnimationDuration + easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized + } + } + } + + MouseArea { + anchors.fill: parent + enabled: spotlightOpen + onClicked: mouse => { + var contentX = modalContainer.x; + var contentY = modalContainer.y; + var contentW = modalContainer.width; + var contentH = modalContainer.height; + + if (mouse.x < contentX || mouse.x > contentX + contentW || mouse.y < contentY || mouse.y > contentY + contentH) { + root.hide(); + } + } + } + + Item { + id: modalContainer + x: root.modalX + y: root.modalY + width: root.modalWidth + height: root.modalHeight + visible: contentVisible || opacity > 0 + + opacity: contentVisible ? 1 : 0 + scale: contentVisible ? 1 : 0.96 + transformOrigin: Item.Center + + Behavior on opacity { + DankAnim { + duration: Theme.modalAnimationDuration + easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized + } + } + + Behavior on scale { + DankAnim { + duration: Theme.modalAnimationDuration + easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized + } + } + + ElevationShadow { + id: launcherShadowLayer + anchors.fill: parent + level: Theme.elevationLevel3 + fallbackOffset: 6 + targetColor: root.backgroundColor + borderColor: root.borderColor + borderWidth: root.borderWidth + targetRadius: root.cornerRadius + 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 + } + + 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; + } + } + + Rectangle { + anchors.fill: parent + radius: root.cornerRadius + color: "transparent" + border.color: BlurService.borderColor + border.width: BlurService.borderWidth + } + } + } +} diff --git a/quickshell/Widgets/DankPopout.qml b/quickshell/Widgets/DankPopout.qml index 798d6bf9..30281698 100644 --- a/quickshell/Widgets/DankPopout.qml +++ b/quickshell/Widgets/DankPopout.qml @@ -1,17 +1,12 @@ import QtQuick -import Quickshell -import Quickshell.Wayland import qs.Common -import qs.Services Item { id: root property string layerNamespace: "dms:popout" - property alias content: contentLoader.sourceComponent - property alias contentLoader: contentLoader + property Component content: null property Component overlayContent: null - property alias overlayLoader: overlayLoader property real popupWidth: 400 property real popupHeight: 300 property real triggerX: 0 @@ -20,10 +15,10 @@ Item { property string triggerSection: "" property string positioning: "center" property int animationDuration: Theme.popoutAnimationDuration - property real animationScaleCollapsed: Theme.effectScaleCollapsed - property real animationOffset: Theme.effectAnimOffset - property list animationEnterCurve: Theme.variantPopoutEnterCurve - property list animationExitCurve: Theme.variantPopoutExitCurve + property real animationScaleCollapsed: 0.96 + property real animationOffset: Theme.spacingL + property list animationEnterCurve: Theme.expressiveCurves.expressiveDefaultSpatial + property list animationExitCurve: Theme.expressiveCurves.emphasized property bool suspendShadowWhileResizing: false property bool shouldBeVisible: false property var customKeyboardFocus: null @@ -31,11 +26,6 @@ Item { property bool contentHandlesKeys: false property bool fullHeightSurface: false property bool _primeContent: false - property bool _resizeActive: false - property string _chromeClaimId: "" - property int _connectedChromeSerial: 0 - property real _chromeAnimTravelX: 1 - property real _chromeAnimTravelY: 1 property real storedBarThickness: Theme.barHeight - 4 property real storedBarSpacing: 4 @@ -47,90 +37,60 @@ Item { "rightBar": 0 }) property var screen: null - // Connected resize uses one full-screen surface; body-sized regions are masks. - readonly property bool useBackgroundWindow: false - - readonly property real effectiveBarThickness: { - if (Theme.isConnectedEffect) - return Math.max(0, storedBarThickness); - const padding = storedBarConfig ? (storedBarConfig.innerPadding !== undefined ? storedBarConfig.innerPadding : 4) : 4; - return Math.max(26 + padding * 0.6, Theme.barHeight - 4 - (8 - padding)) + storedBarSpacing; - } - - readonly property var barBounds: { - if (!screen) - return { - "x": 0, - "y": 0, - "width": 0, - "height": 0, - "wingSize": 0 - }; - return SettingsData.getBarBounds(screen, effectiveBarThickness, effectiveBarPosition, storedBarConfig); - } - - readonly property real barX: barBounds.x - readonly property real barY: barBounds.y - readonly property real barWidth: barBounds.width - readonly property real barHeight: barBounds.height - readonly property real barWingSize: barBounds.wingSize - readonly property bool effectiveSurfaceBlurEnabled: Theme.connectedSurfaceBlurEnabled + property int effectiveBarPosition: 0 + property real effectiveBarBottomGap: 0 signal opened signal popoutClosed signal backgroundClicked - property var _lastOpenedScreen: null - property bool isClosing: false + readonly property var contentLoader: impl.item ? impl.item.contentLoader : _fallbackContentLoader + readonly property var overlayLoader: impl.item ? impl.item.overlayLoader : _fallbackOverlayLoader - property int effectiveBarPosition: 0 - property real effectiveBarBottomGap: 0 - readonly property string autoBarShadowDirection: { - const section = triggerSection || "center"; - switch (effectiveBarPosition) { - case SettingsData.Position.Top: - if (section === "left") - return "topLeft"; - if (section === "right") - return "topRight"; - return "top"; - case SettingsData.Position.Bottom: - if (section === "left") - return "bottomLeft"; - if (section === "right") - return "bottomRight"; - return "bottom"; - case SettingsData.Position.Left: - if (section === "left") - return "topLeft"; - if (section === "right") - return "bottomLeft"; - return "left"; - case SettingsData.Position.Right: - if (section === "left") - return "topRight"; - if (section === "right") - return "bottomRight"; - return "right"; - default: - return "top"; - } + Loader { + id: _fallbackContentLoader + active: false + } + Loader { + id: _fallbackOverlayLoader + active: false + } + readonly property bool isClosing: impl.item ? (impl.item.isClosing ?? false) : false + readonly property real dpr: impl.item ? impl.item.dpr : 1 + readonly property real screenWidth: impl.item ? impl.item.screenWidth : 0 + readonly property real screenHeight: impl.item ? impl.item.screenHeight : 0 + readonly property real alignedX: impl.item ? impl.item.alignedX : 0 + readonly property real alignedY: impl.item ? impl.item.alignedY : 0 + readonly property real alignedWidth: impl.item ? impl.item.alignedWidth : 0 + readonly property real alignedHeight: impl.item ? impl.item.alignedHeight : 0 + readonly property real maskX: impl.item ? impl.item.maskX : 0 + readonly property real maskY: impl.item ? impl.item.maskY : 0 + readonly property real maskWidth: impl.item ? impl.item.maskWidth : 0 + readonly property real maskHeight: impl.item ? impl.item.maskHeight : 0 + readonly property real barX: impl.item ? impl.item.barX : 0 + readonly property real barY: impl.item ? impl.item.barY : 0 + readonly property real barWidth: impl.item ? impl.item.barWidth : 0 + readonly property real barHeight: impl.item ? impl.item.barHeight : 0 + + function open() { + if (impl.item) + impl.item.open(); + } + + function close() { + if (impl.item) + impl.item.close(); + } + + function toggle() { + shouldBeVisible ? close() : open(); } - readonly property string effectiveShadowDirection: Theme.elevationLightDirection === "autoBar" ? autoBarShadowDirection : Theme.elevationLightDirection function setBarContext(position, bottomGap) { effectiveBarPosition = position !== undefined ? position : 0; effectiveBarBottomGap = bottomGap !== undefined ? bottomGap : 0; } - function primeContent() { - _primeContent = true; - } - - function clearPrimedContent() { - _primeContent = false; - } - function setTriggerPosition(x, y, width, section, targetScreen, barPosition, barThickness, barSpacing, barConfig) { triggerX = x; triggerY = y; @@ -149,1006 +109,90 @@ Item { setBarContext(pos, bottomGap); } - function _nextChromeClaimId() { - _connectedChromeSerial += 1; - return layerNamespace + ":" + _connectedChromeSerial + ":" + (new Date()).getTime(); + function updateSurfacePosition() { + if (impl.item && typeof impl.item.updateSurfacePosition === "function") + impl.item.updateSurfacePosition(); } - function _captureChromeAnimTravel() { - _chromeAnimTravelX = Math.max(1, Math.abs(contentContainer.offsetX)); - _chromeAnimTravelY = Math.max(1, Math.abs(contentContainer.offsetY)); + Loader { + id: impl + sourceComponent: SettingsData.connectedFrameModeActive ? connectedComp : standaloneComp + onItemChanged: if (item) + root._wireBackend(item) } - function _connectedChromeAnimX() { - const barSide = contentContainer.connectedBarSide; - if (barSide !== "left" && barSide !== "right") - return contentContainer.animX; - - const extent = Math.max(0, root.alignedWidth); - const progress = Math.min(1, Math.abs(contentContainer.animX) / Math.max(1, _chromeAnimTravelX)); - const offset = Theme.snap(extent * progress, root.dpr); - return contentContainer.animX < 0 ? -offset : offset; + Component { + id: standaloneComp + DankPopoutStandalone {} } - function _connectedChromeAnimY() { - const barSide = contentContainer.connectedBarSide; - if (barSide !== "top" && barSide !== "bottom") - return contentContainer.animY; - - const extent = Math.max(0, root.renderedAlignedHeight); - const progress = Math.min(1, Math.abs(contentContainer.animY) / Math.max(1, _chromeAnimTravelY)); - const offset = Theme.snap(extent * progress, root.dpr); - return contentContainer.animY < 0 ? -offset : offset; + Component { + id: connectedComp + DankPopoutConnected {} } - function _connectedChromeState(visibleOverride) { - const visible = visibleOverride !== undefined ? !!visibleOverride : contentWindow.visible; - return { - "visible": visible, - "barSide": contentContainer.connectedBarSide, - "bodyX": root.alignedX, - "bodyY": root.renderedAlignedY, - "bodyW": root.alignedWidth, - "bodyH": root.renderedAlignedHeight, - "animX": _connectedChromeAnimX(), - "animY": _connectedChromeAnimY(), - "screen": root.screen ? root.screen.name : "", - "omitStartConnector": root._closeGapOmitStartConnector(), - "omitEndConnector": root._closeGapOmitEndConnector() - }; - } - - function _publishConnectedChromeState(forceClaim, visibleOverride) { - if (!root.frameOwnsConnectedChrome || !root.screen || !_chromeClaimId) + function _wireBackend(it) { + if (!it) return; - const state = _connectedChromeState(visibleOverride); - if (forceClaim || !ConnectedModeState.hasPopoutOwner(_chromeClaimId)) { - ConnectedModeState.claimPopout(_chromeClaimId, state); - } else { - ConnectedModeState.updatePopout(_chromeClaimId, state); - } - } + it.popoutHandle = root; + it.layerNamespace = Qt.binding(() => root.layerNamespace); + it.content = Qt.binding(() => root.content); + it.overlayContent = Qt.binding(() => root.overlayContent); + it.popupWidth = Qt.binding(() => root.popupWidth); + it.popupHeight = Qt.binding(() => root.popupHeight); + it.triggerX = Qt.binding(() => root.triggerX); + it.triggerY = Qt.binding(() => root.triggerY); + it.triggerWidth = Qt.binding(() => root.triggerWidth); + it.triggerSection = Qt.binding(() => root.triggerSection); + it.positioning = Qt.binding(() => root.positioning); + it.animationDuration = Qt.binding(() => root.animationDuration); + it.animationScaleCollapsed = Qt.binding(() => root.animationScaleCollapsed); + it.animationOffset = Qt.binding(() => root.animationOffset); + it.animationEnterCurve = Qt.binding(() => root.animationEnterCurve); + it.animationExitCurve = Qt.binding(() => root.animationExitCurve); + it.suspendShadowWhileResizing = Qt.binding(() => root.suspendShadowWhileResizing); + it.customKeyboardFocus = Qt.binding(() => root.customKeyboardFocus); + it.backgroundInteractive = Qt.binding(() => root.backgroundInteractive); + it.contentHandlesKeys = Qt.binding(() => root.contentHandlesKeys); + it.fullHeightSurface = Qt.binding(() => root.fullHeightSurface); + it.storedBarThickness = Qt.binding(() => root.storedBarThickness); + it.storedBarSpacing = Qt.binding(() => root.storedBarSpacing); + it.storedBarConfig = Qt.binding(() => root.storedBarConfig); + it.adjacentBarInfo = Qt.binding(() => root.adjacentBarInfo); + it.screen = Qt.binding(() => root.screen); + it.effectiveBarPosition = Qt.binding(() => root.effectiveBarPosition); + it.effectiveBarBottomGap = Qt.binding(() => root.effectiveBarBottomGap); - function _releaseConnectedChromeState() { - if (_chromeClaimId) - ConnectedModeState.releasePopout(_chromeClaimId); - _chromeClaimId = ""; - } - - // ─── Exposed animation state for ConnectedModeState ──────────────────── - readonly property real contentAnimX: contentContainer.animX - readonly property real contentAnimY: contentContainer.animY - - property bool _fullSyncPending: false - - // ─── ConnectedModeState sync ──────────────────────────────────────────── - function _syncPopoutChromeState() { - if (!root.frameOwnsConnectedChrome) { - _releaseConnectedChromeState(); - return; - } - if (!root.screen) { - _releaseConnectedChromeState(); - return; - } - if (!contentWindow.visible && !shouldBeVisible) - return; - if (!_chromeClaimId) - _chromeClaimId = _nextChromeClaimId(); - _publishConnectedChromeState(contentWindow.visible && !ConnectedModeState.hasPopoutOwner(_chromeClaimId)); - } - - function _syncPopoutAnim(axis) { - if (!root.frameOwnsConnectedChrome || !_chromeClaimId) - return; - if (!contentWindow.visible && !shouldBeVisible) - return; - const barSide = contentContainer.connectedBarSide; - const syncX = axis === "x" && (barSide === "left" || barSide === "right"); - const syncY = axis === "y" && (barSide === "top" || barSide === "bottom"); - if (!syncX && !syncY) - return; - ConnectedModeState.setPopoutAnim(_chromeClaimId, syncX ? _connectedChromeAnimX() : undefined, syncY ? _connectedChromeAnimY() : undefined); - } - - function _syncPopoutBody() { - if (!root.frameOwnsConnectedChrome || !_chromeClaimId) - return; - if (!contentWindow.visible && !shouldBeVisible) - return; - ConnectedModeState.setPopoutBody(_chromeClaimId, root.alignedX, root.renderedAlignedY, root.alignedWidth, root.renderedAlignedHeight); - } - - function _flushFullSync() { - _fullSyncPending = false; - if (root && typeof root._syncPopoutChromeState === "function") - root._syncPopoutChromeState(); - } - - function _queueFullSync() { - if (_fullSyncPending) - return; - _fullSyncPending = true; - Qt.callLater(() => { - if (root && typeof root._flushFullSync === "function") - root._flushFullSync(); + // shouldBeVisible is two-way — backend's open()/close() flips it internally. + it.shouldBeVisible = root.shouldBeVisible; + it.shouldBeVisibleChanged.connect(function () { + if (root.shouldBeVisible !== it.shouldBeVisible) + root.shouldBeVisible = it.shouldBeVisible; }); + + it.opened.connect(root.opened); + it.popoutClosed.connect(root.popoutClosed); + it.backgroundClicked.connect(root.backgroundClicked); } - onAlignedXChanged: _queueFullSync() - onAlignedYChanged: _queueFullSync() - onAlignedWidthChanged: _queueFullSync() - onContentAnimXChanged: _syncPopoutAnim("x") - onContentAnimYChanged: _syncPopoutAnim("y") - onRenderedAlignedYChanged: _syncPopoutBody() - onRenderedAlignedHeightChanged: _syncPopoutBody() - onScreenChanged: _syncPopoutChromeState() - onEffectiveBarPositionChanged: _syncPopoutChromeState() - - Connections { - target: contentWindow - function onVisibleChanged() { - if (contentWindow.visible) - root._publishConnectedChromeState(true); - else - root._releaseConnectedChromeState(); - } - } - - Connections { - target: SettingsData - function onConnectedFrameModeActiveChanged() { - if (root.frameOwnsConnectedChrome) { - if (contentWindow.visible || root.shouldBeVisible) { - if (!root._chromeClaimId) - root._chromeClaimId = root._nextChromeClaimId(); - root._publishConnectedChromeState(true); - } - } else { - root._releaseConnectedChromeState(); - } - } - function onFrameCloseGapsChanged() { - root._syncPopoutChromeState(); - } - } - - readonly property bool frameOwnsConnectedChrome: SettingsData.connectedFrameModeActive - && !!root.screen - && SettingsData.isScreenInPreferences(root.screen, SettingsData.frameScreenPreferences) - - property bool animationsEnabled: true - - function open() { - if (!screen) - return; - closeTimer.stop(); - isClosing = false; - animationsEnabled = false; + function primeContent() { _primeContent = true; - - if (_lastOpenedScreen !== null && _lastOpenedScreen !== screen) { - contentWindow.visible = false; - } - _lastOpenedScreen = screen; - - if (contentContainer) { - contentContainer.animX = Theme.snap(contentContainer.offsetX, root.dpr); - contentContainer.animY = Theme.snap(contentContainer.offsetY, root.dpr); - contentContainer.scaleValue = root.animationScaleCollapsed; - _captureChromeAnimTravel(); - } - - if (root.frameOwnsConnectedChrome) { - _chromeClaimId = _nextChromeClaimId(); - _publishConnectedChromeState(true, true); - } else { - _chromeClaimId = ""; - } - - contentWindow.visible = true; - - Qt.callLater(() => { - animationsEnabled = true; - shouldBeVisible = true; - if (shouldBeVisible && screen) { - contentWindow.visible = true; - PopoutManager.showPopout(root); - opened(); - } - }); + if (impl.item) + impl.item.primeContent(); } - function close() { - isClosing = true; - shouldBeVisible = false; + function clearPrimedContent() { _primeContent = false; - PopoutManager.popoutChanged(); - closeTimer.restart(); - } - - function toggle() { - shouldBeVisible ? close() : open(); + if (impl.item) + impl.item.clearPrimedContent(); } Connections { - target: Quickshell - function onScreensChanged() { - if (!shouldBeVisible || !screen) - return; - const currentScreenName = screen.name; - let screenStillExists = false; - for (let i = 0; i < Quickshell.screens.length; i++) { - if (Quickshell.screens[i].name === currentScreenName) { - screenStillExists = true; - break; - } - } - if (!screenStillExists) { - close(); - } - } - } - - Timer { - id: closeTimer - interval: Theme.variantCloseInterval(animationDuration) - onTriggered: { - if (!shouldBeVisible) { - isClosing = false; - contentWindow.visible = false; - PopoutManager.hidePopout(root); - popoutClosed(); - } - } - } - - Component.onDestruction: _releaseConnectedChromeState() - - readonly property real screenWidth: screen ? screen.width : 0 - readonly property real screenHeight: screen ? screen.height : 0 - readonly property real dpr: screen ? screen.devicePixelRatio : 1 - readonly property bool closeFrameGapsActive: SettingsData.frameCloseGaps && frameOwnsConnectedChrome - readonly property real frameInset: { - if (!SettingsData.frameEnabled) - return 0; - const ft = SettingsData.frameThickness; - const fr = SettingsData.frameRounding; - const ccr = Theme.connectedCornerRadius; - if (Theme.isConnectedEffect) - return Math.max(ft * 4, ft + ccr * 2); - const useAutoGaps = storedBarConfig?.popupGapsAuto !== undefined ? storedBarConfig.popupGapsAuto : true; - const manualGapValue = storedBarConfig?.popupGapsManual !== undefined ? storedBarConfig.popupGapsManual : 6; - const gap = useAutoGaps ? Math.max(6, storedBarSpacing) : manualGapValue; - return Math.max(ft + gap, fr); - } - - function _popupGapValue() { - const useAutoGaps = storedBarConfig?.popupGapsAuto !== undefined ? storedBarConfig.popupGapsAuto : true; - const manualGapValue = storedBarConfig?.popupGapsManual !== undefined ? storedBarConfig.popupGapsManual : 4; - const rawPopupGap = useAutoGaps ? Math.max(4, storedBarSpacing) : manualGapValue; - return Theme.isConnectedEffect ? 0 : rawPopupGap; - } - - function _frameEdgeInset(side) { - if (!SettingsData.frameEnabled || !root.screen) - return 0; - const edges = SettingsData.getActiveBarEdgesForScreen(root.screen); - const raw = edges.includes(side) ? SettingsData.frameBarSize : SettingsData.frameThickness; - return Math.max(0, raw); - } - - function _edgeGapFor(side, popupGap) { - if (root.closeFrameGapsActive) - return Math.max(popupGap, _frameEdgeInset(side)); - return Math.max(popupGap, frameInset); - } - - function _sideAdjacentClearance(side) { - switch (side) { - case "left": - return adjacentBarClearance(adjacentBarInfo.leftBar); - case "right": - return adjacentBarClearance(adjacentBarInfo.rightBar); - case "top": - return adjacentBarClearance(adjacentBarInfo.topBar); - case "bottom": - return adjacentBarClearance(adjacentBarInfo.bottomBar); - default: - return 0; - } - } - - function _nearFrameBound(value, bound) { - return Math.abs(value - bound) <= Math.max(1, Theme.hairline(root.dpr) * 2); - } - - function _closeGapClampedToFrameSide(side) { - if (!root.closeFrameGapsActive) - return false; - const popupGap = _popupGapValue(); - const edgeGap = _edgeGapFor(side, popupGap); - const adjacentGap = _sideAdjacentClearance(side); - if (edgeGap < adjacentGap - Math.max(1, Theme.hairline(root.dpr) * 2)) - return false; - - switch (side) { - case "left": - return _nearFrameBound(root.alignedX, edgeGap); - case "right": - return _nearFrameBound(root.alignedX, screenWidth - popupWidth - edgeGap); - case "top": - return _nearFrameBound(root.alignedY, edgeGap); - case "bottom": - return _nearFrameBound(root.alignedY, screenHeight - popupHeight - edgeGap); - default: - return false; - } - } - - function _closeGapOmitStartConnector() { - const side = contentContainer.connectedBarSide; - if (side === "top" || side === "bottom") - return _closeGapClampedToFrameSide("left"); - return _closeGapClampedToFrameSide("top"); - } - - function _closeGapOmitEndConnector() { - const side = contentContainer.connectedBarSide; - if (side === "top" || side === "bottom") - return _closeGapClampedToFrameSide("right"); - return _closeGapClampedToFrameSide("bottom"); - } - - 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: { - if (Theme.isConnectedEffect) - return Math.max(storedBarSpacing + Theme.connectedCornerRadius + 4, 40); - if (Theme.isDirectionalEffect) { - if (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode !== 0) - return 16; // Slide Behind and Roll Out do not add animationOffset, enabling strict Wayland clipping. - 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) - property real renderedAlignedY: alignedY - property real renderedAlignedHeight: alignedHeight - readonly property bool renderedGeometryGrowing: alignedHeight >= renderedAlignedHeight - - Behavior on renderedAlignedY { - enabled: root.animationsEnabled && contentWindow.visible && root.shouldBeVisible - NumberAnimation { - duration: Theme.variantDuration(root.animationDuration, root.renderedGeometryGrowing) - easing.type: Easing.BezierSpline - easing.bezierCurve: root.renderedGeometryGrowing ? root.animationEnterCurve : root.animationExitCurve - } - } - - Behavior on renderedAlignedHeight { - enabled: root.animationsEnabled && contentWindow.visible && root.shouldBeVisible - NumberAnimation { - duration: Theme.variantDuration(root.animationDuration, root.renderedGeometryGrowing) - easing.type: Easing.BezierSpline - easing.bezierCurve: root.renderedGeometryGrowing ? root.animationEnterCurve : root.animationExitCurve - } - } - readonly property real connectedAnchorX: { - if (!Theme.isConnectedEffect) - return triggerX; - switch (effectiveBarPosition) { - case SettingsData.Position.Left: - return barX + barWidth; - case SettingsData.Position.Right: - return barX; - default: - return triggerX; - } - } - readonly property real connectedAnchorY: { - if (!Theme.isConnectedEffect) - return triggerY; - switch (effectiveBarPosition) { - case SettingsData.Position.Top: - return barY + barHeight; - case SettingsData.Position.Bottom: - return barY; - default: - return triggerY; - } - } - - function adjacentBarClearance(exclusion) { - if (exclusion <= 0) - return 0; - if (!Theme.isConnectedEffect) - return exclusion; - // In a shared frame corner, the adjacent connected bar already occupies - // one rounded-corner radius before the popout's own connector begins. - return exclusion + Theme.connectedCornerRadius * 2; - } - - onAlignedHeightChanged: { - _queueFullSync(); - if (!suspendShadowWhileResizing || !shouldBeVisible) - return; - _resizeActive = true; - resizeSettleTimer.restart(); - } - onShouldBeVisibleChanged: { - if (!shouldBeVisible) { - _resizeActive = false; - resizeSettleTimer.stop(); - } - } - - Timer { - id: resizeSettleTimer - interval: 80 - repeat: false - onTriggered: root._resizeActive = false - } - - readonly property real alignedX: Theme.snap((() => { - const popupGap = _popupGapValue(); - const edgeGapLeft = _edgeGapFor("left", popupGap); - const edgeGapRight = _edgeGapFor("right", popupGap); - const anchorX = Theme.isConnectedEffect ? connectedAnchorX : triggerX; - - switch (effectiveBarPosition) { - case SettingsData.Position.Left: - // bar on left: left side is bar-adjacent (popupGap), right side is frame-perpendicular (edgeGap) - return Math.max(popupGap, Math.min(screenWidth - popupWidth - edgeGapRight, anchorX)); - case SettingsData.Position.Right: - // bar on right: right side is bar-adjacent (popupGap), left side is frame-perpendicular (edgeGap) - return Math.max(edgeGapLeft, Math.min(screenWidth - popupWidth - popupGap, anchorX - popupWidth)); - default: - const rawX = triggerX + (triggerWidth / 2) - (popupWidth / 2); - const minX = Math.max(edgeGapLeft, adjacentBarClearance(adjacentBarInfo.leftBar)); - const maxX = screenWidth - popupWidth - Math.max(edgeGapRight, adjacentBarClearance(adjacentBarInfo.rightBar)); - return Math.max(minX, Math.min(maxX, rawX)); - } - })(), dpr) - - readonly property real alignedY: Theme.snap((() => { - const popupGap = _popupGapValue(); - const edgeGapTop = _edgeGapFor("top", popupGap); - const edgeGapBottom = _edgeGapFor("bottom", popupGap); - const anchorY = Theme.isConnectedEffect ? connectedAnchorY : triggerY; - - switch (effectiveBarPosition) { - case SettingsData.Position.Bottom: - // bar on bottom: bottom side is bar-adjacent (popupGap), top side is frame-perpendicular (edgeGap) - return Math.max(edgeGapTop, Math.min(screenHeight - popupHeight - popupGap, anchorY - popupHeight)); - case SettingsData.Position.Top: - // bar on top: top side is bar-adjacent (popupGap), bottom side is frame-perpendicular (edgeGap) - return Math.max(popupGap, Math.min(screenHeight - popupHeight - edgeGapBottom, anchorY)); - default: - const rawY = triggerY - (popupHeight / 2); - const minY = Math.max(edgeGapTop, adjacentBarClearance(adjacentBarInfo.topBar)); - const maxY = screenHeight - popupHeight - Math.max(edgeGapBottom, adjacentBarClearance(adjacentBarInfo.bottomBar)); - return Math.max(minY, Math.min(maxY, rawY)); - } - })(), dpr) - - readonly property real triggeringBarLeftExclusion: (effectiveBarPosition === SettingsData.Position.Left && barWidth > 0) ? Math.max(0, barX + barWidth) : 0 - readonly property real triggeringBarTopExclusion: (effectiveBarPosition === SettingsData.Position.Top && barHeight > 0) ? Math.max(0, barY + barHeight) : 0 - readonly property real triggeringBarRightExclusion: (effectiveBarPosition === SettingsData.Position.Right && barWidth > 0) ? Math.max(0, screenWidth - barX) : 0 - readonly property real triggeringBarBottomExclusion: (effectiveBarPosition === SettingsData.Position.Bottom && barHeight > 0) ? Math.max(0, screenHeight - barY) : 0 - - readonly property real maskX: { - const adjacentLeftBar = adjacentBarInfo?.leftBar ?? 0; - return Math.max(triggeringBarLeftExclusion, adjacentLeftBar); - } - - readonly property real maskY: { - const adjacentTopBar = adjacentBarInfo?.topBar ?? 0; - return Math.max(triggeringBarTopExclusion, adjacentTopBar); - } - - readonly property real maskWidth: { - const adjacentRightBar = adjacentBarInfo?.rightBar ?? 0; - const rightExclusion = Math.max(triggeringBarRightExclusion, adjacentRightBar); - return Math.max(100, screenWidth - maskX - rightExclusion); - } - - readonly property real maskHeight: { - const adjacentBottomBar = adjacentBarInfo?.bottomBar ?? 0; - const bottomExclusion = Math.max(triggeringBarBottomExclusion, adjacentBottomBar); - return Math.max(100, screenHeight - maskY - bottomExclusion); - } - - PanelWindow { - id: contentWindow - screen: root.screen - visible: false - color: "transparent" - - WindowBlur { - id: popoutBlur - targetWindow: contentWindow - blurEnabled: root.effectiveSurfaceBlurEnabled && !root.frameOwnsConnectedChrome - - readonly property real s: Math.min(1, contentContainer.scaleValue) - readonly property bool trackBlurFromBarEdge: Theme.isConnectedEffect || (typeof SettingsData !== "undefined" && Theme.isDirectionalEffect && SettingsData.directionalAnimationMode !== 2) - - // Directional popouts clip to the bar edge, so the blur needs to grow from - // that same edge instead of translating through the bar before settling. - readonly property real _dyClamp: (contentContainer.barTop || contentContainer.barBottom) ? Math.max(-contentContainer.height, Math.min(contentContainer.animY, contentContainer.height)) : 0 - readonly property real _dxClamp: (contentContainer.barLeft || contentContainer.barRight) ? Math.max(-contentContainer.width, Math.min(contentContainer.animX, contentContainer.width)) : 0 - - blurX: trackBlurFromBarEdge ? contentContainer.x + (contentContainer.barRight ? _dxClamp : 0) : contentContainer.x + contentContainer.width * (1 - s) * 0.5 + Theme.snap(contentContainer.animX, root.dpr) - contentContainer.horizontalConnectorExtent * s - blurY: trackBlurFromBarEdge ? contentContainer.y + (contentContainer.barBottom ? _dyClamp : 0) : contentContainer.y + contentContainer.height * (1 - s) * 0.5 + Theme.snap(contentContainer.animY, root.dpr) - contentContainer.verticalConnectorExtent * s - blurWidth: (shouldBeVisible && contentWrapper.opacity > 0) ? (trackBlurFromBarEdge ? Math.max(0, contentContainer.width - Math.abs(_dxClamp)) : (contentContainer.width + contentContainer.horizontalConnectorExtent * 2) * s) : 0 - blurHeight: (shouldBeVisible && contentWrapper.opacity > 0) ? (trackBlurFromBarEdge ? Math.max(0, contentContainer.height - Math.abs(_dyClamp)) : (contentContainer.height + contentContainer.verticalConnectorExtent * 2) * s) : 0 - blurRadius: Theme.isConnectedEffect ? Theme.connectedCornerRadius : Theme.connectedSurfaceRadius - } - - WlrLayershell.namespace: root.layerNamespace - WlrLayershell.layer: { - switch (Quickshell.env("DMS_POPOUT_LAYER")) { - case "bottom": - console.warn("DankPopout: 'bottom' layer is not valid for popouts. Defaulting to 'top' layer."); - return WlrLayershell.Top; - case "background": - console.warn("DankPopout: 'background' layer is not valid for popouts. Defaulting to 'top' layer."); - return WlrLayershell.Top; - case "overlay": - return WlrLayershell.Overlay; - default: - return WlrLayershell.Top; - } - } - WlrLayershell.exclusiveZone: -1 - WlrLayershell.keyboardFocus: { - if (customKeyboardFocus !== null) - return customKeyboardFocus; - if (!shouldBeVisible) - return WlrKeyboardFocus.None; - if (CompositorService.useHyprlandFocusGrab) - return WlrKeyboardFocus.OnDemand; - return WlrKeyboardFocus.Exclusive; - } - - readonly property bool _fullHeight: root.fullHeightSurface - anchors { - left: true - top: true - right: true - bottom: true - } - - WlrLayershell.margins { - left: 0 - top: 0 - } - - implicitWidth: 0 - implicitHeight: 0 - - mask: contentInputMask - - Region { - id: contentInputMask - // Outside-click dismissal needs full-screen input only while interactive. - item: (shouldBeVisible && backgroundInteractive) ? fullScreenMaskItem : contentMaskRect - } - - Item { - id: fullScreenMaskItem - visible: false - x: 0 - y: 0 - width: 32767 - height: 32767 - } - - Item { - id: contentMaskRect - visible: false - x: contentContainer.x - contentContainer.horizontalConnectorExtent - y: contentContainer.y - contentContainer.verticalConnectorExtent - width: root.alignedWidth + contentContainer.horizontalConnectorExtent * 2 - height: root.renderedAlignedHeight + contentContainer.verticalConnectorExtent * 2 - } - - MouseArea { - anchors.fill: parent - enabled: shouldBeVisible && backgroundInteractive - acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton - z: -1 - onClicked: mouse => { - const clickX = mouse.x; - const clickY = mouse.y; - const outsideContent = clickX < root.alignedX || clickX > root.alignedX + root.alignedWidth || clickY < root.renderedAlignedY || clickY > root.renderedAlignedY + root.renderedAlignedHeight; - if (!outsideContent) - return; - backgroundClicked(); - } - } - - Item { - id: contentContainer - x: root.alignedX - y: root.renderedAlignedY - width: root.alignedWidth - height: root.renderedAlignedHeight - - readonly property bool barTop: effectiveBarPosition === SettingsData.Position.Top - 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 string connectedBarSide: barTop ? "top" : (barBottom ? "bottom" : (barLeft ? "left" : "right")) - readonly property real surfaceRadius: Theme.connectedSurfaceRadius - readonly property color surfaceColor: Theme.popupLayerColor(Theme.surfaceContainer) - readonly property color surfaceBorderColor: Theme.isConnectedEffect ? "transparent" : (BlurService.enabled ? BlurService.borderColor : Theme.outlineMedium) - readonly property real surfaceBorderWidth: Theme.isConnectedEffect ? 0 : BlurService.borderWidth - readonly property real surfaceTopLeftRadius: Theme.isConnectedEffect && (barTop || barLeft) ? 0 : surfaceRadius - readonly property real surfaceTopRightRadius: Theme.isConnectedEffect && (barTop || barRight) ? 0 : surfaceRadius - readonly property real surfaceBottomLeftRadius: Theme.isConnectedEffect && (barBottom || barLeft) ? 0 : surfaceRadius - readonly property real surfaceBottomRightRadius: Theme.isConnectedEffect && (barBottom || barRight) ? 0 : surfaceRadius - 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 horizontalConnectorExtent: Theme.isConnectedEffect && (barTop || barBottom) ? Theme.connectedCornerRadius : 0 - readonly property real verticalConnectorExtent: Theme.isConnectedEffect && (barLeft || barRight) ? Theme.connectedCornerRadius : 0 - - function connectorWidth(spacing) { - return (barTop || barBottom) ? Theme.connectedCornerRadius : (spacing + Theme.connectedCornerRadius); - } - - function connectorHeight(spacing) { - return (barTop || barBottom) ? (spacing + Theme.connectedCornerRadius) : Theme.connectedCornerRadius; - } - - function connectorSeamX(baseX, bodyWidth, placement) { - if (barTop || barBottom) - return placement === "left" ? baseX : baseX + bodyWidth; - return barLeft ? baseX : baseX + bodyWidth; - } - - function connectorSeamY(baseY, bodyHeight, placement) { - if (barTop) - return baseY; - if (barBottom) - return baseY + bodyHeight; - return placement === "left" ? baseY : baseY + bodyHeight; - } - - function connectorX(baseX, bodyWidth, placement, spacing) { - const seamX = connectorSeamX(baseX, bodyWidth, placement); - const width = connectorWidth(spacing); - if (barTop || barBottom) - return placement === "left" ? seamX - width : seamX; - return barLeft ? seamX : seamX - width; - } - - function connectorY(baseY, bodyHeight, placement, spacing) { - const seamY = connectorSeamY(baseY, bodyHeight, placement); - const height = connectorHeight(spacing); - if (barTop) - return seamY; - if (barBottom) - return seamY - height; - return placement === "left" ? seamY - height : seamY; - } - - readonly property real offsetX: { - if (directionalEffect) { - if (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2) - return 0; - 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 (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2) - return 0; - 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 - - readonly property real computedScaleCollapsed: (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2 && Theme.isDirectionalEffect) ? 0.0 : root.animationScaleCollapsed - property real scaleValue: computedScaleCollapsed - - 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 : computedScaleCollapsed; - root._captureChromeAnimTravel(); - } - - onOffsetXChanged: { - if (!root.shouldBeVisible) - animX = Theme.snap(offsetX, root.dpr); - } - onOffsetYChanged: { - if (!root.shouldBeVisible) - animY = Theme.snap(offsetY, root.dpr); - } - - Connections { - target: root - function onShouldBeVisibleChanged() { - root._captureChromeAnimTravel(); - contentContainer.animX = Theme.snap(root.shouldBeVisible ? 0 : contentContainer.offsetX, root.dpr); - contentContainer.animY = Theme.snap(root.shouldBeVisible ? 0 : contentContainer.offsetY, root.dpr); - contentContainer.scaleValue = root.shouldBeVisible ? 1.0 : contentContainer.computedScaleCollapsed; - } - } - - Behavior on animX { - enabled: root.animationsEnabled - NumberAnimation { - 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: 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: Theme.variantDuration(root.animationDuration, root.shouldBeVisible) - easing.type: Easing.BezierSpline - easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve - } - } - - Item { - id: directionalClipMask - - readonly property bool shouldClip: (Theme.isDirectionalEffect && typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode > 0) || Theme.isConnectedEffect - readonly property real clipOversize: 1000 - readonly property real connectedClipAllowance: { - if (!Theme.isConnectedEffect) return 0; - if (root.frameOwnsConnectedChrome) return 0; - return -Theme.connectedCornerRadius; - } - - clip: shouldClip - - // Bound the clipping strictly to the bar side, allowing massive overflow on the other 3 sides for shadows - x: shouldClip ? (contentContainer.barLeft ? -connectedClipAllowance : -clipOversize) : 0 - y: shouldClip ? (contentContainer.barTop ? -connectedClipAllowance : -clipOversize) : 0 - - width: { - if (!shouldClip) - return parent.width; - if (contentContainer.barLeft) - return parent.width + connectedClipAllowance + clipOversize; - if (contentContainer.barRight) - return parent.width + clipOversize + connectedClipAllowance; - return parent.width + clipOversize * 2; - } - height: { - if (!shouldClip) - return parent.height; - if (contentContainer.barTop) - return parent.height + connectedClipAllowance + clipOversize; - if (contentContainer.barBottom) - return parent.height + clipOversize + connectedClipAllowance; - return parent.height + clipOversize * 2; - } - - // Roll-out clips a wrapper while content and shadow keep full-size geometry. - Item { - id: rollOutAdjuster - readonly property real baseWidth: contentContainer.width - readonly property real baseHeight: contentContainer.height - readonly property bool isRollOut: typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2 && Theme.isDirectionalEffect - - x: directionalClipMask.x !== 0 ? -directionalClipMask.x : 0 - y: directionalClipMask.y !== 0 ? -directionalClipMask.y : 0 - width: isRollOut && (contentContainer.barLeft || contentContainer.barRight) ? Math.max(0, baseWidth * contentContainer.scaleValue) : baseWidth - height: isRollOut && (contentContainer.barTop || contentContainer.barBottom) ? Math.max(0, baseHeight * contentContainer.scaleValue) : baseHeight - - clip: isRollOut - - ElevationShadow { - id: shadowSource - readonly property real connectorExtent: Theme.isConnectedEffect ? Theme.connectedCornerRadius : 0 - readonly property real extraLeft: Theme.isConnectedEffect && (contentContainer.barTop || contentContainer.barBottom) ? connectorExtent : 0 - readonly property real extraRight: Theme.isConnectedEffect && (contentContainer.barTop || contentContainer.barBottom) ? connectorExtent : 0 - readonly property real extraTop: Theme.isConnectedEffect && (contentContainer.barLeft || contentContainer.barRight) ? connectorExtent : 0 - readonly property real extraBottom: Theme.isConnectedEffect && (contentContainer.barLeft || contentContainer.barRight) ? connectorExtent : 0 - readonly property real bodyX: extraLeft - readonly property real bodyY: extraTop - readonly property real bodyWidth: rollOutAdjuster.baseWidth - readonly property real bodyHeight: rollOutAdjuster.baseHeight - - width: rollOutAdjuster.baseWidth + extraLeft + extraRight - height: rollOutAdjuster.baseHeight + extraTop + extraBottom - opacity: contentWrapper.opacity - scale: contentWrapper.scale - x: contentWrapper.x - extraLeft - y: contentWrapper.y - extraTop - level: root.shadowLevel - direction: root.effectiveShadowDirection - fallbackOffset: root.shadowFallbackOffset - targetRadius: contentContainer.surfaceRadius - topLeftRadius: contentContainer.surfaceTopLeftRadius - topRightRadius: contentContainer.surfaceTopRightRadius - bottomLeftRadius: contentContainer.surfaceBottomLeftRadius - bottomRightRadius: contentContainer.surfaceBottomRightRadius - targetColor: contentContainer.surfaceColor - borderColor: contentContainer.surfaceBorderColor - borderWidth: contentContainer.surfaceBorderWidth - useCustomSource: Theme.isConnectedEffect - shadowEnabled: Theme.elevationEnabled && SettingsData.popoutElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1" && !(root.suspendShadowWhileResizing && root._resizeActive) && !root.frameOwnsConnectedChrome - - Item { - anchors.fill: parent - visible: Theme.isConnectedEffect && !root.frameOwnsConnectedChrome - clip: false - - Rectangle { - x: shadowSource.bodyX - y: shadowSource.bodyY - width: shadowSource.bodyWidth - height: shadowSource.bodyHeight - topLeftRadius: contentContainer.surfaceTopLeftRadius - topRightRadius: contentContainer.surfaceTopRightRadius - bottomLeftRadius: contentContainer.surfaceBottomLeftRadius - bottomRightRadius: contentContainer.surfaceBottomRightRadius - color: contentContainer.surfaceColor - } - - ConnectedCorner { - visible: Theme.isConnectedEffect - barSide: contentContainer.connectedBarSide - placement: "left" - spacing: 0 - connectorRadius: Theme.connectedCornerRadius - color: contentContainer.surfaceColor - dpr: root.dpr - x: Theme.snap(contentContainer.connectorX(shadowSource.bodyX, shadowSource.bodyWidth, placement, spacing), root.dpr) - y: Theme.snap(contentContainer.connectorY(shadowSource.bodyY, shadowSource.bodyHeight, placement, spacing), root.dpr) - } - - ConnectedCorner { - visible: Theme.isConnectedEffect - barSide: contentContainer.connectedBarSide - placement: "right" - spacing: 0 - connectorRadius: Theme.connectedCornerRadius - color: contentContainer.surfaceColor - dpr: root.dpr - x: Theme.snap(contentContainer.connectorX(shadowSource.bodyX, shadowSource.bodyWidth, placement, spacing), root.dpr) - y: Theme.snap(contentContainer.connectorY(shadowSource.bodyY, shadowSource.bodyHeight, placement, spacing), root.dpr) - } - } - } - - Item { - id: contentWrapper - width: rollOutAdjuster.baseWidth - height: rollOutAdjuster.baseHeight - opacity: Theme.isDirectionalEffect ? 1 : (shouldBeVisible ? 1 : 0) - visible: opacity > 0 - - scale: rollOutAdjuster.isRollOut ? 1.0 : contentContainer.scaleValue - x: Theme.snap(contentContainer.animX + (rollOutAdjuster.baseWidth - width) * (1 - scale) * 0.5, root.dpr) - y: Theme.snap(contentContainer.animY + (rollOutAdjuster.baseHeight - height) * (1 - scale) * 0.5, root.dpr) - - layer.enabled: contentWrapper.opacity < 1 - layer.smooth: false - 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: Math.round(Theme.variantDuration(animationDuration, shouldBeVisible) * Theme.variantOpacityDurationScale) - easing.type: Easing.BezierSpline - easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve - } - } - - Item { - anchors.fill: parent - clip: false - visible: !Theme.isConnectedEffect - - Rectangle { - anchors.fill: parent - antialiasing: true - topLeftRadius: contentContainer.surfaceTopLeftRadius - topRightRadius: contentContainer.surfaceTopRightRadius - bottomLeftRadius: contentContainer.surfaceBottomLeftRadius - bottomRightRadius: contentContainer.surfaceBottomRightRadius - color: contentContainer.surfaceColor - border.color: contentContainer.surfaceBorderColor - border.width: contentContainer.surfaceBorderWidth - } - } - - Loader { - id: contentLoader - anchors.fill: parent - active: root._primeContent || shouldBeVisible || contentWindow.visible - asynchronous: false - } - } - } - } - } - - Item { - id: focusHelper - parent: contentContainer - anchors.fill: parent - visible: !root.contentHandlesKeys - enabled: !root.contentHandlesKeys - focus: !root.contentHandlesKeys - Keys.onPressed: event => { - if (root.contentHandlesKeys) - return; - if (event.key === Qt.Key_Escape) { - close(); - event.accepted = true; - } - } - } - - Loader { - id: overlayLoader - anchors.fill: parent - active: root.overlayContent !== null && contentWindow.visible - sourceComponent: root.overlayContent + target: root + function onShouldBeVisibleChanged() { + if (impl.item && impl.item.shouldBeVisible !== root.shouldBeVisible) + impl.item.shouldBeVisible = root.shouldBeVisible; } } } diff --git a/quickshell/Widgets/DankPopoutConnected.qml b/quickshell/Widgets/DankPopoutConnected.qml new file mode 100644 index 00000000..c7c1acbd --- /dev/null +++ b/quickshell/Widgets/DankPopoutConnected.qml @@ -0,0 +1,1155 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import qs.Common +import qs.Services + +Item { + id: root + + property var popoutHandle: root + property string layerNamespace: "dms:popout" + property alias content: contentLoader.sourceComponent + property alias contentLoader: contentLoader + property Component overlayContent: null + property alias overlayLoader: overlayLoader + property real popupWidth: 400 + property real popupHeight: 300 + property real triggerX: 0 + property real triggerY: 0 + property real triggerWidth: 40 + property string triggerSection: "" + property string positioning: "center" + property int animationDuration: Theme.popoutAnimationDuration + 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 + property bool backgroundInteractive: true + property bool contentHandlesKeys: false + property bool fullHeightSurface: false + property bool _primeContent: false + property bool _resizeActive: false + property string _chromeClaimId: "" + property int _connectedChromeSerial: 0 + property real _chromeAnimTravelX: 1 + property real _chromeAnimTravelY: 1 + + property real storedBarThickness: Theme.barHeight - 4 + property real storedBarSpacing: 4 + property var storedBarConfig: null + property var adjacentBarInfo: ({ + "topBar": 0, + "bottomBar": 0, + "leftBar": 0, + "rightBar": 0 + }) + property var screen: null + // Connected resize uses one full-screen surface; body-sized regions are masks. + readonly property bool useBackgroundWindow: false + + readonly property real effectiveBarThickness: { + if (Theme.isConnectedEffect) + return Math.max(0, storedBarThickness); + const padding = storedBarConfig ? (storedBarConfig.innerPadding !== undefined ? storedBarConfig.innerPadding : 4) : 4; + return Math.max(26 + padding * 0.6, Theme.barHeight - 4 - (8 - padding)) + storedBarSpacing; + } + + readonly property var barBounds: { + if (!screen) + return { + "x": 0, + "y": 0, + "width": 0, + "height": 0, + "wingSize": 0 + }; + return SettingsData.getBarBounds(screen, effectiveBarThickness, effectiveBarPosition, storedBarConfig); + } + + readonly property real barX: barBounds.x + readonly property real barY: barBounds.y + readonly property real barWidth: barBounds.width + readonly property real barHeight: barBounds.height + readonly property real barWingSize: barBounds.wingSize + readonly property bool effectiveSurfaceBlurEnabled: Theme.connectedSurfaceBlurEnabled + + signal opened + signal popoutClosed + signal backgroundClicked + + property var _lastOpenedScreen: null + property bool isClosing: false + + property int effectiveBarPosition: 0 + property real effectiveBarBottomGap: 0 + readonly property string autoBarShadowDirection: { + const section = triggerSection || "center"; + switch (effectiveBarPosition) { + case SettingsData.Position.Top: + if (section === "left") + return "topLeft"; + if (section === "right") + return "topRight"; + return "top"; + case SettingsData.Position.Bottom: + if (section === "left") + return "bottomLeft"; + if (section === "right") + return "bottomRight"; + return "bottom"; + case SettingsData.Position.Left: + if (section === "left") + return "topLeft"; + if (section === "right") + return "bottomLeft"; + return "left"; + case SettingsData.Position.Right: + if (section === "left") + return "topRight"; + if (section === "right") + return "bottomRight"; + return "right"; + default: + return "top"; + } + } + readonly property string effectiveShadowDirection: Theme.elevationLightDirection === "autoBar" ? autoBarShadowDirection : Theme.elevationLightDirection + + function setBarContext(position, bottomGap) { + effectiveBarPosition = position !== undefined ? position : 0; + effectiveBarBottomGap = bottomGap !== undefined ? bottomGap : 0; + } + + function primeContent() { + _primeContent = true; + } + + function clearPrimedContent() { + _primeContent = false; + } + + function setTriggerPosition(x, y, width, section, targetScreen, barPosition, barThickness, barSpacing, barConfig) { + triggerX = x; + triggerY = y; + triggerWidth = width; + triggerSection = section; + screen = targetScreen; + + storedBarThickness = barThickness !== undefined ? barThickness : (Theme.barHeight - 4); + storedBarSpacing = barSpacing !== undefined ? barSpacing : 4; + storedBarConfig = barConfig; + + const pos = barPosition !== undefined ? barPosition : 0; + const bottomGap = barConfig ? (barConfig.bottomGap !== undefined ? barConfig.bottomGap : 0) : 0; + + adjacentBarInfo = SettingsData.getAdjacentBarInfo(targetScreen, pos, barConfig); + setBarContext(pos, bottomGap); + } + + function _nextChromeClaimId() { + _connectedChromeSerial += 1; + return layerNamespace + ":" + _connectedChromeSerial + ":" + (new Date()).getTime(); + } + + function _captureChromeAnimTravel() { + _chromeAnimTravelX = Math.max(1, Math.abs(contentContainer.offsetX)); + _chromeAnimTravelY = Math.max(1, Math.abs(contentContainer.offsetY)); + } + + function _connectedChromeAnimX() { + const barSide = contentContainer.connectedBarSide; + if (barSide !== "left" && barSide !== "right") + return contentContainer.animX; + + const extent = Math.max(0, root.alignedWidth); + const progress = Math.min(1, Math.abs(contentContainer.animX) / Math.max(1, _chromeAnimTravelX)); + const offset = Theme.snap(extent * progress, root.dpr); + return contentContainer.animX < 0 ? -offset : offset; + } + + function _connectedChromeAnimY() { + const barSide = contentContainer.connectedBarSide; + if (barSide !== "top" && barSide !== "bottom") + return contentContainer.animY; + + const extent = Math.max(0, root.renderedAlignedHeight); + const progress = Math.min(1, Math.abs(contentContainer.animY) / Math.max(1, _chromeAnimTravelY)); + const offset = Theme.snap(extent * progress, root.dpr); + return contentContainer.animY < 0 ? -offset : offset; + } + + function _connectedChromeState(visibleOverride) { + const visible = visibleOverride !== undefined ? !!visibleOverride : contentWindow.visible; + return { + "visible": visible, + "barSide": contentContainer.connectedBarSide, + "bodyX": root.alignedX, + "bodyY": root.renderedAlignedY, + "bodyW": root.alignedWidth, + "bodyH": root.renderedAlignedHeight, + "animX": _connectedChromeAnimX(), + "animY": _connectedChromeAnimY(), + "screen": root.screen ? root.screen.name : "", + "omitStartConnector": root._closeGapOmitStartConnector(), + "omitEndConnector": root._closeGapOmitEndConnector() + }; + } + + function _publishConnectedChromeState(forceClaim, visibleOverride) { + if (!root.frameOwnsConnectedChrome || !root.screen || !_chromeClaimId) + return; + + const state = _connectedChromeState(visibleOverride); + if (forceClaim || !ConnectedModeState.hasPopoutOwner(_chromeClaimId)) { + ConnectedModeState.claimPopout(_chromeClaimId, state); + } else { + ConnectedModeState.updatePopout(_chromeClaimId, state); + } + } + + function _releaseConnectedChromeState() { + if (_chromeClaimId) + ConnectedModeState.releasePopout(_chromeClaimId); + _chromeClaimId = ""; + } + + // ─── Exposed animation state for ConnectedModeState ──────────────────── + readonly property real contentAnimX: contentContainer.animX + readonly property real contentAnimY: contentContainer.animY + + property bool _fullSyncPending: false + + // ─── ConnectedModeState sync ──────────────────────────────────────────── + function _syncPopoutChromeState() { + if (!root.frameOwnsConnectedChrome) { + _releaseConnectedChromeState(); + return; + } + if (!root.screen) { + _releaseConnectedChromeState(); + return; + } + if (!contentWindow.visible && !shouldBeVisible) + return; + if (!_chromeClaimId) + _chromeClaimId = _nextChromeClaimId(); + _publishConnectedChromeState(contentWindow.visible && !ConnectedModeState.hasPopoutOwner(_chromeClaimId)); + } + + function _syncPopoutAnim(axis) { + if (!root.frameOwnsConnectedChrome || !_chromeClaimId) + return; + if (!contentWindow.visible && !shouldBeVisible) + return; + const barSide = contentContainer.connectedBarSide; + const syncX = axis === "x" && (barSide === "left" || barSide === "right"); + const syncY = axis === "y" && (barSide === "top" || barSide === "bottom"); + if (!syncX && !syncY) + return; + ConnectedModeState.setPopoutAnim(_chromeClaimId, syncX ? _connectedChromeAnimX() : undefined, syncY ? _connectedChromeAnimY() : undefined); + } + + function _syncPopoutBody() { + if (!root.frameOwnsConnectedChrome || !_chromeClaimId) + return; + if (!contentWindow.visible && !shouldBeVisible) + return; + ConnectedModeState.setPopoutBody(_chromeClaimId, root.alignedX, root.renderedAlignedY, root.alignedWidth, root.renderedAlignedHeight); + } + + function _flushFullSync() { + _fullSyncPending = false; + if (root && typeof root._syncPopoutChromeState === "function") + root._syncPopoutChromeState(); + } + + function _queueFullSync() { + if (_fullSyncPending) + return; + _fullSyncPending = true; + Qt.callLater(() => { + if (root && typeof root._flushFullSync === "function") + root._flushFullSync(); + }); + } + + onAlignedXChanged: _queueFullSync() + onAlignedYChanged: _queueFullSync() + onAlignedWidthChanged: _queueFullSync() + onContentAnimXChanged: _syncPopoutAnim("x") + onContentAnimYChanged: _syncPopoutAnim("y") + onRenderedAlignedYChanged: _syncPopoutBody() + onRenderedAlignedHeightChanged: _syncPopoutBody() + onScreenChanged: _syncPopoutChromeState() + onEffectiveBarPositionChanged: _syncPopoutChromeState() + + Connections { + target: contentWindow + function onVisibleChanged() { + if (contentWindow.visible) + root._publishConnectedChromeState(true); + else + root._releaseConnectedChromeState(); + } + } + + Connections { + target: SettingsData + function onConnectedFrameModeActiveChanged() { + if (root.frameOwnsConnectedChrome) { + if (contentWindow.visible || root.shouldBeVisible) { + if (!root._chromeClaimId) + root._chromeClaimId = root._nextChromeClaimId(); + root._publishConnectedChromeState(true); + } + } else { + root._releaseConnectedChromeState(); + } + } + function onFrameCloseGapsChanged() { + root._syncPopoutChromeState(); + } + } + + readonly property bool frameOwnsConnectedChrome: SettingsData.connectedFrameModeActive && !!root.screen && SettingsData.isScreenInPreferences(root.screen, SettingsData.frameScreenPreferences) + + property bool animationsEnabled: true + + function open() { + if (!screen) + return; + closeTimer.stop(); + isClosing = false; + animationsEnabled = false; + _primeContent = true; + + if (_lastOpenedScreen !== null && _lastOpenedScreen !== screen) { + contentWindow.visible = false; + } + _lastOpenedScreen = screen; + + if (contentContainer) { + contentContainer.animX = Theme.snap(contentContainer.offsetX, root.dpr); + contentContainer.animY = Theme.snap(contentContainer.offsetY, root.dpr); + contentContainer.scaleValue = root.animationScaleCollapsed; + _captureChromeAnimTravel(); + } + + if (root.frameOwnsConnectedChrome) { + _chromeClaimId = _nextChromeClaimId(); + _publishConnectedChromeState(true, true); + } else { + _chromeClaimId = ""; + } + + contentWindow.visible = true; + + Qt.callLater(() => { + animationsEnabled = true; + shouldBeVisible = true; + if (shouldBeVisible && screen) { + contentWindow.visible = true; + PopoutManager.showPopout(popoutHandle); + opened(); + } + }); + } + + function close() { + isClosing = true; + shouldBeVisible = false; + _primeContent = false; + PopoutManager.popoutChanged(); + closeTimer.restart(); + } + + function toggle() { + shouldBeVisible ? close() : open(); + } + + Connections { + target: Quickshell + function onScreensChanged() { + if (!shouldBeVisible || !screen) + return; + const currentScreenName = screen.name; + let screenStillExists = false; + for (let i = 0; i < Quickshell.screens.length; i++) { + if (Quickshell.screens[i].name === currentScreenName) { + screenStillExists = true; + break; + } + } + if (!screenStillExists) { + close(); + } + } + } + + Timer { + id: closeTimer + interval: Theme.variantCloseInterval(animationDuration) + onTriggered: { + if (!shouldBeVisible) { + isClosing = false; + contentWindow.visible = false; + PopoutManager.hidePopout(popoutHandle); + popoutClosed(); + } + } + } + + Component.onDestruction: _releaseConnectedChromeState() + + readonly property real screenWidth: screen ? screen.width : 0 + readonly property real screenHeight: screen ? screen.height : 0 + readonly property real dpr: screen ? screen.devicePixelRatio : 1 + readonly property bool closeFrameGapsActive: SettingsData.frameCloseGaps && frameOwnsConnectedChrome + readonly property real frameInset: { + if (!SettingsData.frameEnabled) + return 0; + const ft = SettingsData.frameThickness; + const fr = SettingsData.frameRounding; + const ccr = Theme.connectedCornerRadius; + if (Theme.isConnectedEffect) + return Math.max(ft * 4, ft + ccr * 2); + const useAutoGaps = storedBarConfig?.popupGapsAuto !== undefined ? storedBarConfig.popupGapsAuto : true; + const manualGapValue = storedBarConfig?.popupGapsManual !== undefined ? storedBarConfig.popupGapsManual : 6; + const gap = useAutoGaps ? Math.max(6, storedBarSpacing) : manualGapValue; + return Math.max(ft + gap, fr); + } + + function _popupGapValue() { + const useAutoGaps = storedBarConfig?.popupGapsAuto !== undefined ? storedBarConfig.popupGapsAuto : true; + const manualGapValue = storedBarConfig?.popupGapsManual !== undefined ? storedBarConfig.popupGapsManual : 4; + const rawPopupGap = useAutoGaps ? Math.max(4, storedBarSpacing) : manualGapValue; + return Theme.isConnectedEffect ? 0 : rawPopupGap; + } + + function _frameEdgeInset(side) { + if (!SettingsData.frameEnabled || !root.screen) + return 0; + const edges = SettingsData.getActiveBarEdgesForScreen(root.screen); + const raw = edges.includes(side) ? SettingsData.frameBarSize : SettingsData.frameThickness; + return Math.max(0, raw); + } + + function _edgeGapFor(side, popupGap) { + if (root.closeFrameGapsActive) + return Math.max(popupGap, _frameEdgeInset(side)); + return Math.max(popupGap, frameInset); + } + + function _sideAdjacentClearance(side) { + switch (side) { + case "left": + return adjacentBarClearance(adjacentBarInfo.leftBar); + case "right": + return adjacentBarClearance(adjacentBarInfo.rightBar); + case "top": + return adjacentBarClearance(adjacentBarInfo.topBar); + case "bottom": + return adjacentBarClearance(adjacentBarInfo.bottomBar); + default: + return 0; + } + } + + function _nearFrameBound(value, bound) { + return Math.abs(value - bound) <= Math.max(1, Theme.hairline(root.dpr) * 2); + } + + function _closeGapClampedToFrameSide(side) { + if (!root.closeFrameGapsActive) + return false; + const popupGap = _popupGapValue(); + const edgeGap = _edgeGapFor(side, popupGap); + const adjacentGap = _sideAdjacentClearance(side); + if (edgeGap < adjacentGap - Math.max(1, Theme.hairline(root.dpr) * 2)) + return false; + + switch (side) { + case "left": + return _nearFrameBound(root.alignedX, edgeGap); + case "right": + return _nearFrameBound(root.alignedX, screenWidth - popupWidth - edgeGap); + case "top": + return _nearFrameBound(root.alignedY, edgeGap); + case "bottom": + return _nearFrameBound(root.alignedY, screenHeight - popupHeight - edgeGap); + default: + return false; + } + } + + function _closeGapOmitStartConnector() { + const side = contentContainer.connectedBarSide; + if (side === "top" || side === "bottom") + return _closeGapClampedToFrameSide("left"); + return _closeGapClampedToFrameSide("top"); + } + + function _closeGapOmitEndConnector() { + const side = contentContainer.connectedBarSide; + if (side === "top" || side === "bottom") + return _closeGapClampedToFrameSide("right"); + return _closeGapClampedToFrameSide("bottom"); + } + + 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: { + if (Theme.isConnectedEffect) + return Math.max(storedBarSpacing + Theme.connectedCornerRadius + 4, 40); + if (Theme.isDirectionalEffect) { + if (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode !== 0) + return 16; // Slide Behind and Roll Out do not add animationOffset, enabling strict Wayland clipping. + 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) + property real renderedAlignedY: alignedY + property real renderedAlignedHeight: alignedHeight + readonly property bool renderedGeometryGrowing: alignedHeight >= renderedAlignedHeight + + Behavior on renderedAlignedY { + enabled: root.animationsEnabled && contentWindow.visible && root.shouldBeVisible + NumberAnimation { + duration: Theme.variantDuration(root.animationDuration, root.renderedGeometryGrowing) + easing.type: Easing.BezierSpline + easing.bezierCurve: root.renderedGeometryGrowing ? root.animationEnterCurve : root.animationExitCurve + } + } + + Behavior on renderedAlignedHeight { + enabled: root.animationsEnabled && contentWindow.visible && root.shouldBeVisible + NumberAnimation { + duration: Theme.variantDuration(root.animationDuration, root.renderedGeometryGrowing) + easing.type: Easing.BezierSpline + easing.bezierCurve: root.renderedGeometryGrowing ? root.animationEnterCurve : root.animationExitCurve + } + } + readonly property real connectedAnchorX: { + if (!Theme.isConnectedEffect) + return triggerX; + switch (effectiveBarPosition) { + case SettingsData.Position.Left: + return barX + barWidth; + case SettingsData.Position.Right: + return barX; + default: + return triggerX; + } + } + readonly property real connectedAnchorY: { + if (!Theme.isConnectedEffect) + return triggerY; + switch (effectiveBarPosition) { + case SettingsData.Position.Top: + return barY + barHeight; + case SettingsData.Position.Bottom: + return barY; + default: + return triggerY; + } + } + + function adjacentBarClearance(exclusion) { + if (exclusion <= 0) + return 0; + if (!Theme.isConnectedEffect) + return exclusion; + // In a shared frame corner, the adjacent connected bar already occupies + // one rounded-corner radius before the popout's own connector begins. + return exclusion + Theme.connectedCornerRadius * 2; + } + + onAlignedHeightChanged: { + _queueFullSync(); + if (!suspendShadowWhileResizing || !shouldBeVisible) + return; + _resizeActive = true; + resizeSettleTimer.restart(); + } + onShouldBeVisibleChanged: { + if (!shouldBeVisible) { + _resizeActive = false; + resizeSettleTimer.stop(); + } + } + + Timer { + id: resizeSettleTimer + interval: 80 + repeat: false + onTriggered: root._resizeActive = false + } + + readonly property real alignedX: Theme.snap((() => { + const popupGap = _popupGapValue(); + const edgeGapLeft = _edgeGapFor("left", popupGap); + const edgeGapRight = _edgeGapFor("right", popupGap); + const anchorX = Theme.isConnectedEffect ? connectedAnchorX : triggerX; + + switch (effectiveBarPosition) { + case SettingsData.Position.Left: + // bar on left: left side is bar-adjacent (popupGap), right side is frame-perpendicular (edgeGap) + return Math.max(popupGap, Math.min(screenWidth - popupWidth - edgeGapRight, anchorX)); + case SettingsData.Position.Right: + // bar on right: right side is bar-adjacent (popupGap), left side is frame-perpendicular (edgeGap) + return Math.max(edgeGapLeft, Math.min(screenWidth - popupWidth - popupGap, anchorX - popupWidth)); + default: + const rawX = triggerX + (triggerWidth / 2) - (popupWidth / 2); + const minX = Math.max(edgeGapLeft, adjacentBarClearance(adjacentBarInfo.leftBar)); + const maxX = screenWidth - popupWidth - Math.max(edgeGapRight, adjacentBarClearance(adjacentBarInfo.rightBar)); + return Math.max(minX, Math.min(maxX, rawX)); + } + })(), dpr) + + readonly property real alignedY: Theme.snap((() => { + const popupGap = _popupGapValue(); + const edgeGapTop = _edgeGapFor("top", popupGap); + const edgeGapBottom = _edgeGapFor("bottom", popupGap); + const anchorY = Theme.isConnectedEffect ? connectedAnchorY : triggerY; + + switch (effectiveBarPosition) { + case SettingsData.Position.Bottom: + // bar on bottom: bottom side is bar-adjacent (popupGap), top side is frame-perpendicular (edgeGap) + return Math.max(edgeGapTop, Math.min(screenHeight - popupHeight - popupGap, anchorY - popupHeight)); + case SettingsData.Position.Top: + // bar on top: top side is bar-adjacent (popupGap), bottom side is frame-perpendicular (edgeGap) + return Math.max(popupGap, Math.min(screenHeight - popupHeight - edgeGapBottom, anchorY)); + default: + const rawY = triggerY - (popupHeight / 2); + const minY = Math.max(edgeGapTop, adjacentBarClearance(adjacentBarInfo.topBar)); + const maxY = screenHeight - popupHeight - Math.max(edgeGapBottom, adjacentBarClearance(adjacentBarInfo.bottomBar)); + return Math.max(minY, Math.min(maxY, rawY)); + } + })(), dpr) + + readonly property real triggeringBarLeftExclusion: (effectiveBarPosition === SettingsData.Position.Left && barWidth > 0) ? Math.max(0, barX + barWidth) : 0 + readonly property real triggeringBarTopExclusion: (effectiveBarPosition === SettingsData.Position.Top && barHeight > 0) ? Math.max(0, barY + barHeight) : 0 + readonly property real triggeringBarRightExclusion: (effectiveBarPosition === SettingsData.Position.Right && barWidth > 0) ? Math.max(0, screenWidth - barX) : 0 + readonly property real triggeringBarBottomExclusion: (effectiveBarPosition === SettingsData.Position.Bottom && barHeight > 0) ? Math.max(0, screenHeight - barY) : 0 + + readonly property real maskX: { + const adjacentLeftBar = adjacentBarInfo?.leftBar ?? 0; + return Math.max(triggeringBarLeftExclusion, adjacentLeftBar); + } + + readonly property real maskY: { + const adjacentTopBar = adjacentBarInfo?.topBar ?? 0; + return Math.max(triggeringBarTopExclusion, adjacentTopBar); + } + + readonly property real maskWidth: { + const adjacentRightBar = adjacentBarInfo?.rightBar ?? 0; + const rightExclusion = Math.max(triggeringBarRightExclusion, adjacentRightBar); + return Math.max(100, screenWidth - maskX - rightExclusion); + } + + readonly property real maskHeight: { + const adjacentBottomBar = adjacentBarInfo?.bottomBar ?? 0; + const bottomExclusion = Math.max(triggeringBarBottomExclusion, adjacentBottomBar); + return Math.max(100, screenHeight - maskY - bottomExclusion); + } + + PanelWindow { + id: contentWindow + screen: root.screen + visible: false + color: "transparent" + + WindowBlur { + id: popoutBlur + targetWindow: contentWindow + blurEnabled: root.effectiveSurfaceBlurEnabled && !root.frameOwnsConnectedChrome + + readonly property real s: Math.min(1, contentContainer.scaleValue) + readonly property bool trackBlurFromBarEdge: Theme.isConnectedEffect || (typeof SettingsData !== "undefined" && Theme.isDirectionalEffect && SettingsData.directionalAnimationMode !== 2) + + // Directional popouts clip to the bar edge, so the blur needs to grow from + // that same edge instead of translating through the bar before settling. + readonly property real _dyClamp: (contentContainer.barTop || contentContainer.barBottom) ? Math.max(-contentContainer.height, Math.min(contentContainer.animY, contentContainer.height)) : 0 + readonly property real _dxClamp: (contentContainer.barLeft || contentContainer.barRight) ? Math.max(-contentContainer.width, Math.min(contentContainer.animX, contentContainer.width)) : 0 + + blurX: trackBlurFromBarEdge ? contentContainer.x + (contentContainer.barRight ? _dxClamp : 0) : contentContainer.x + contentContainer.width * (1 - s) * 0.5 + Theme.snap(contentContainer.animX, root.dpr) - contentContainer.horizontalConnectorExtent * s + blurY: trackBlurFromBarEdge ? contentContainer.y + (contentContainer.barBottom ? _dyClamp : 0) : contentContainer.y + contentContainer.height * (1 - s) * 0.5 + Theme.snap(contentContainer.animY, root.dpr) - contentContainer.verticalConnectorExtent * s + blurWidth: (shouldBeVisible && contentWrapper.opacity > 0) ? (trackBlurFromBarEdge ? Math.max(0, contentContainer.width - Math.abs(_dxClamp)) : (contentContainer.width + contentContainer.horizontalConnectorExtent * 2) * s) : 0 + blurHeight: (shouldBeVisible && contentWrapper.opacity > 0) ? (trackBlurFromBarEdge ? Math.max(0, contentContainer.height - Math.abs(_dyClamp)) : (contentContainer.height + contentContainer.verticalConnectorExtent * 2) * s) : 0 + blurRadius: Theme.isConnectedEffect ? Theme.connectedCornerRadius : Theme.connectedSurfaceRadius + } + + WlrLayershell.namespace: root.layerNamespace + WlrLayershell.layer: { + switch (Quickshell.env("DMS_POPOUT_LAYER")) { + case "bottom": + console.warn("DankPopout: 'bottom' layer is not valid for popouts. Defaulting to 'top' layer."); + return WlrLayershell.Top; + case "background": + console.warn("DankPopout: 'background' layer is not valid for popouts. Defaulting to 'top' layer."); + return WlrLayershell.Top; + case "overlay": + return WlrLayershell.Overlay; + default: + return WlrLayershell.Top; + } + } + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: { + if (customKeyboardFocus !== null) + return customKeyboardFocus; + if (!shouldBeVisible) + return WlrKeyboardFocus.None; + if (CompositorService.useHyprlandFocusGrab) + return WlrKeyboardFocus.OnDemand; + return WlrKeyboardFocus.Exclusive; + } + + readonly property bool _fullHeight: root.fullHeightSurface + anchors { + left: true + top: true + right: true + bottom: true + } + + WlrLayershell.margins { + left: 0 + top: 0 + } + + implicitWidth: 0 + implicitHeight: 0 + + mask: contentInputMask + + Region { + id: contentInputMask + // Outside-click dismissal needs full-screen input only while interactive. + item: (shouldBeVisible && backgroundInteractive) ? fullScreenMaskItem : contentMaskRect + } + + Item { + id: fullScreenMaskItem + visible: false + x: 0 + y: 0 + width: 32767 + height: 32767 + } + + Item { + id: contentMaskRect + visible: false + x: contentContainer.x - contentContainer.horizontalConnectorExtent + y: contentContainer.y - contentContainer.verticalConnectorExtent + width: root.alignedWidth + contentContainer.horizontalConnectorExtent * 2 + height: root.renderedAlignedHeight + contentContainer.verticalConnectorExtent * 2 + } + + MouseArea { + anchors.fill: parent + enabled: shouldBeVisible && backgroundInteractive + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + z: -1 + onClicked: mouse => { + const clickX = mouse.x; + const clickY = mouse.y; + const outsideContent = clickX < root.alignedX || clickX > root.alignedX + root.alignedWidth || clickY < root.renderedAlignedY || clickY > root.renderedAlignedY + root.renderedAlignedHeight; + if (!outsideContent) + return; + backgroundClicked(); + } + } + + Item { + id: contentContainer + x: root.alignedX + y: root.renderedAlignedY + width: root.alignedWidth + height: root.renderedAlignedHeight + + readonly property bool barTop: effectiveBarPosition === SettingsData.Position.Top + 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 string connectedBarSide: barTop ? "top" : (barBottom ? "bottom" : (barLeft ? "left" : "right")) + readonly property real surfaceRadius: Theme.connectedSurfaceRadius + readonly property color surfaceColor: Theme.popupLayerColor(Theme.surfaceContainer) + readonly property color surfaceBorderColor: Theme.isConnectedEffect ? "transparent" : (BlurService.enabled ? BlurService.borderColor : Theme.outlineMedium) + readonly property real surfaceBorderWidth: Theme.isConnectedEffect ? 0 : BlurService.borderWidth + readonly property real surfaceTopLeftRadius: Theme.isConnectedEffect && (barTop || barLeft) ? 0 : surfaceRadius + readonly property real surfaceTopRightRadius: Theme.isConnectedEffect && (barTop || barRight) ? 0 : surfaceRadius + readonly property real surfaceBottomLeftRadius: Theme.isConnectedEffect && (barBottom || barLeft) ? 0 : surfaceRadius + readonly property real surfaceBottomRightRadius: Theme.isConnectedEffect && (barBottom || barRight) ? 0 : surfaceRadius + 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 horizontalConnectorExtent: Theme.isConnectedEffect && (barTop || barBottom) ? Theme.connectedCornerRadius : 0 + readonly property real verticalConnectorExtent: Theme.isConnectedEffect && (barLeft || barRight) ? Theme.connectedCornerRadius : 0 + + function connectorWidth(spacing) { + return (barTop || barBottom) ? Theme.connectedCornerRadius : (spacing + Theme.connectedCornerRadius); + } + + function connectorHeight(spacing) { + return (barTop || barBottom) ? (spacing + Theme.connectedCornerRadius) : Theme.connectedCornerRadius; + } + + function connectorSeamX(baseX, bodyWidth, placement) { + if (barTop || barBottom) + return placement === "left" ? baseX : baseX + bodyWidth; + return barLeft ? baseX : baseX + bodyWidth; + } + + function connectorSeamY(baseY, bodyHeight, placement) { + if (barTop) + return baseY; + if (barBottom) + return baseY + bodyHeight; + return placement === "left" ? baseY : baseY + bodyHeight; + } + + function connectorX(baseX, bodyWidth, placement, spacing) { + const seamX = connectorSeamX(baseX, bodyWidth, placement); + const width = connectorWidth(spacing); + if (barTop || barBottom) + return placement === "left" ? seamX - width : seamX; + return barLeft ? seamX : seamX - width; + } + + function connectorY(baseY, bodyHeight, placement, spacing) { + const seamY = connectorSeamY(baseY, bodyHeight, placement); + const height = connectorHeight(spacing); + if (barTop) + return seamY; + if (barBottom) + return seamY - height; + return placement === "left" ? seamY - height : seamY; + } + + readonly property real offsetX: { + if (directionalEffect) { + if (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2) + return 0; + 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 (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2) + return 0; + 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 + + readonly property real computedScaleCollapsed: (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2 && Theme.isDirectionalEffect) ? 0.0 : root.animationScaleCollapsed + property real scaleValue: computedScaleCollapsed + + 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 : computedScaleCollapsed; + root._captureChromeAnimTravel(); + } + + onOffsetXChanged: { + if (!root.shouldBeVisible) + animX = Theme.snap(offsetX, root.dpr); + } + onOffsetYChanged: { + if (!root.shouldBeVisible) + animY = Theme.snap(offsetY, root.dpr); + } + + Connections { + target: root + function onShouldBeVisibleChanged() { + root._captureChromeAnimTravel(); + contentContainer.animX = Theme.snap(root.shouldBeVisible ? 0 : contentContainer.offsetX, root.dpr); + contentContainer.animY = Theme.snap(root.shouldBeVisible ? 0 : contentContainer.offsetY, root.dpr); + contentContainer.scaleValue = root.shouldBeVisible ? 1.0 : contentContainer.computedScaleCollapsed; + } + } + + Behavior on animX { + enabled: root.animationsEnabled + NumberAnimation { + 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: 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: Theme.variantDuration(root.animationDuration, root.shouldBeVisible) + easing.type: Easing.BezierSpline + easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve + } + } + + Item { + id: directionalClipMask + + readonly property bool shouldClip: (Theme.isDirectionalEffect && typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode > 0) || Theme.isConnectedEffect + readonly property real clipOversize: 1000 + readonly property real connectedClipAllowance: { + if (!Theme.isConnectedEffect) + return 0; + if (root.frameOwnsConnectedChrome) + return 0; + return -Theme.connectedCornerRadius; + } + + clip: shouldClip + + // Bound the clipping strictly to the bar side, allowing massive overflow on the other 3 sides for shadows + x: shouldClip ? (contentContainer.barLeft ? -connectedClipAllowance : -clipOversize) : 0 + y: shouldClip ? (contentContainer.barTop ? -connectedClipAllowance : -clipOversize) : 0 + + width: { + if (!shouldClip) + return parent.width; + if (contentContainer.barLeft) + return parent.width + connectedClipAllowance + clipOversize; + if (contentContainer.barRight) + return parent.width + clipOversize + connectedClipAllowance; + return parent.width + clipOversize * 2; + } + height: { + if (!shouldClip) + return parent.height; + if (contentContainer.barTop) + return parent.height + connectedClipAllowance + clipOversize; + if (contentContainer.barBottom) + return parent.height + clipOversize + connectedClipAllowance; + return parent.height + clipOversize * 2; + } + + // Roll-out clips a wrapper while content and shadow keep full-size geometry. + Item { + id: rollOutAdjuster + readonly property real baseWidth: contentContainer.width + readonly property real baseHeight: contentContainer.height + readonly property bool isRollOut: typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2 && Theme.isDirectionalEffect + + x: directionalClipMask.x !== 0 ? -directionalClipMask.x : 0 + y: directionalClipMask.y !== 0 ? -directionalClipMask.y : 0 + width: isRollOut && (contentContainer.barLeft || contentContainer.barRight) ? Math.max(0, baseWidth * contentContainer.scaleValue) : baseWidth + height: isRollOut && (contentContainer.barTop || contentContainer.barBottom) ? Math.max(0, baseHeight * contentContainer.scaleValue) : baseHeight + + clip: isRollOut + + ElevationShadow { + id: shadowSource + readonly property real connectorExtent: Theme.isConnectedEffect ? Theme.connectedCornerRadius : 0 + readonly property real extraLeft: Theme.isConnectedEffect && (contentContainer.barTop || contentContainer.barBottom) ? connectorExtent : 0 + readonly property real extraRight: Theme.isConnectedEffect && (contentContainer.barTop || contentContainer.barBottom) ? connectorExtent : 0 + readonly property real extraTop: Theme.isConnectedEffect && (contentContainer.barLeft || contentContainer.barRight) ? connectorExtent : 0 + readonly property real extraBottom: Theme.isConnectedEffect && (contentContainer.barLeft || contentContainer.barRight) ? connectorExtent : 0 + readonly property real bodyX: extraLeft + readonly property real bodyY: extraTop + readonly property real bodyWidth: rollOutAdjuster.baseWidth + readonly property real bodyHeight: rollOutAdjuster.baseHeight + + width: rollOutAdjuster.baseWidth + extraLeft + extraRight + height: rollOutAdjuster.baseHeight + extraTop + extraBottom + opacity: contentWrapper.opacity + scale: contentWrapper.scale + x: contentWrapper.x - extraLeft + y: contentWrapper.y - extraTop + level: root.shadowLevel + direction: root.effectiveShadowDirection + fallbackOffset: root.shadowFallbackOffset + targetRadius: contentContainer.surfaceRadius + topLeftRadius: contentContainer.surfaceTopLeftRadius + topRightRadius: contentContainer.surfaceTopRightRadius + bottomLeftRadius: contentContainer.surfaceBottomLeftRadius + bottomRightRadius: contentContainer.surfaceBottomRightRadius + targetColor: contentContainer.surfaceColor + borderColor: contentContainer.surfaceBorderColor + borderWidth: contentContainer.surfaceBorderWidth + useCustomSource: Theme.isConnectedEffect + shadowEnabled: Theme.elevationEnabled && SettingsData.popoutElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1" && !(root.suspendShadowWhileResizing && root._resizeActive) && !root.frameOwnsConnectedChrome + + Item { + anchors.fill: parent + visible: Theme.isConnectedEffect && !root.frameOwnsConnectedChrome + clip: false + + Rectangle { + x: shadowSource.bodyX + y: shadowSource.bodyY + width: shadowSource.bodyWidth + height: shadowSource.bodyHeight + topLeftRadius: contentContainer.surfaceTopLeftRadius + topRightRadius: contentContainer.surfaceTopRightRadius + bottomLeftRadius: contentContainer.surfaceBottomLeftRadius + bottomRightRadius: contentContainer.surfaceBottomRightRadius + color: contentContainer.surfaceColor + } + + ConnectedCorner { + visible: Theme.isConnectedEffect + barSide: contentContainer.connectedBarSide + placement: "left" + spacing: 0 + connectorRadius: Theme.connectedCornerRadius + color: contentContainer.surfaceColor + dpr: root.dpr + x: Theme.snap(contentContainer.connectorX(shadowSource.bodyX, shadowSource.bodyWidth, placement, spacing), root.dpr) + y: Theme.snap(contentContainer.connectorY(shadowSource.bodyY, shadowSource.bodyHeight, placement, spacing), root.dpr) + } + + ConnectedCorner { + visible: Theme.isConnectedEffect + barSide: contentContainer.connectedBarSide + placement: "right" + spacing: 0 + connectorRadius: Theme.connectedCornerRadius + color: contentContainer.surfaceColor + dpr: root.dpr + x: Theme.snap(contentContainer.connectorX(shadowSource.bodyX, shadowSource.bodyWidth, placement, spacing), root.dpr) + y: Theme.snap(contentContainer.connectorY(shadowSource.bodyY, shadowSource.bodyHeight, placement, spacing), root.dpr) + } + } + } + + Item { + id: contentWrapper + width: rollOutAdjuster.baseWidth + height: rollOutAdjuster.baseHeight + opacity: Theme.isDirectionalEffect ? 1 : (shouldBeVisible ? 1 : 0) + visible: opacity > 0 + + scale: rollOutAdjuster.isRollOut ? 1.0 : contentContainer.scaleValue + x: Theme.snap(contentContainer.animX + (rollOutAdjuster.baseWidth - width) * (1 - scale) * 0.5, root.dpr) + y: Theme.snap(contentContainer.animY + (rollOutAdjuster.baseHeight - height) * (1 - scale) * 0.5, root.dpr) + + layer.enabled: contentWrapper.opacity < 1 + layer.smooth: false + 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: Math.round(Theme.variantDuration(animationDuration, shouldBeVisible) * Theme.variantOpacityDurationScale) + easing.type: Easing.BezierSpline + easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve + } + } + + Item { + anchors.fill: parent + clip: false + visible: !Theme.isConnectedEffect + + Rectangle { + anchors.fill: parent + antialiasing: true + topLeftRadius: contentContainer.surfaceTopLeftRadius + topRightRadius: contentContainer.surfaceTopRightRadius + bottomLeftRadius: contentContainer.surfaceBottomLeftRadius + bottomRightRadius: contentContainer.surfaceBottomRightRadius + color: contentContainer.surfaceColor + border.color: contentContainer.surfaceBorderColor + border.width: contentContainer.surfaceBorderWidth + } + } + + Loader { + id: contentLoader + anchors.fill: parent + active: root._primeContent || shouldBeVisible || contentWindow.visible + asynchronous: false + } + } + } + } + } + + Item { + id: focusHelper + parent: contentContainer + anchors.fill: parent + visible: !root.contentHandlesKeys + enabled: !root.contentHandlesKeys + focus: !root.contentHandlesKeys + Keys.onPressed: event => { + if (root.contentHandlesKeys) + return; + if (event.key === Qt.Key_Escape) { + close(); + event.accepted = true; + } + } + } + + Loader { + id: overlayLoader + anchors.fill: parent + active: root.overlayContent !== null && contentWindow.visible + sourceComponent: root.overlayContent + } + } +} diff --git a/quickshell/Widgets/DankPopoutStandalone.qml b/quickshell/Widgets/DankPopoutStandalone.qml new file mode 100644 index 00000000..98e72ce9 --- /dev/null +++ b/quickshell/Widgets/DankPopoutStandalone.qml @@ -0,0 +1,625 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import qs.Common +import qs.Services + +Item { + id: root + + property var popoutHandle: root + property string layerNamespace: "dms:popout" + property alias content: contentLoader.sourceComponent + property alias contentLoader: contentLoader + property Component overlayContent: null + property alias overlayLoader: overlayLoader + property real popupWidth: 400 + property real popupHeight: 300 + property real triggerX: 0 + property real triggerY: 0 + property real triggerWidth: 40 + 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 bool suspendShadowWhileResizing: false + property bool shouldBeVisible: false + property bool isClosing: false + property var customKeyboardFocus: null + property bool backgroundInteractive: true + property bool contentHandlesKeys: false + property bool fullHeightSurface: false + property bool _primeContent: false + property bool _resizeActive: false + property real _surfaceMarginLeft: 0 + property real _surfaceW: 0 + + property real storedBarThickness: Theme.barHeight - 4 + property real storedBarSpacing: 4 + property var storedBarConfig: null + property var adjacentBarInfo: ({ + "topBar": 0, + "bottomBar": 0, + "leftBar": 0, + "rightBar": 0 + }) + property var screen: null + + readonly property real effectiveBarThickness: { + const padding = storedBarConfig ? (storedBarConfig.innerPadding !== undefined ? storedBarConfig.innerPadding : 4) : 4; + return Math.max(26 + padding * 0.6, Theme.barHeight - 4 - (8 - padding)) + storedBarSpacing; + } + + readonly property var barBounds: { + if (!screen) + return { + "x": 0, + "y": 0, + "width": 0, + "height": 0, + "wingSize": 0 + }; + return SettingsData.getBarBounds(screen, effectiveBarThickness, effectiveBarPosition, storedBarConfig); + } + + readonly property real barX: barBounds.x + readonly property real barY: barBounds.y + readonly property real barWidth: barBounds.width + readonly property real barHeight: barBounds.height + readonly property real barWingSize: barBounds.wingSize + + signal opened + signal popoutClosed + signal backgroundClicked + + property var _lastOpenedScreen: null + + property int effectiveBarPosition: 0 + property real effectiveBarBottomGap: 0 + readonly property string autoBarShadowDirection: { + const section = triggerSection || "center"; + switch (effectiveBarPosition) { + case SettingsData.Position.Top: + if (section === "left") + return "topLeft"; + if (section === "right") + return "topRight"; + return "top"; + case SettingsData.Position.Bottom: + if (section === "left") + return "bottomLeft"; + if (section === "right") + return "bottomRight"; + return "bottom"; + case SettingsData.Position.Left: + if (section === "left") + return "topLeft"; + if (section === "right") + return "bottomLeft"; + return "left"; + case SettingsData.Position.Right: + if (section === "left") + return "topRight"; + if (section === "right") + return "bottomRight"; + return "right"; + default: + return "top"; + } + } + readonly property string effectiveShadowDirection: Theme.elevationLightDirection === "autoBar" ? autoBarShadowDirection : Theme.elevationLightDirection + + // Snapshot mask geometry to prevent background damage on bar updates + property real _frozenMaskX: 0 + property real _frozenMaskY: 0 + property real _frozenMaskWidth: 0 + property real _frozenMaskHeight: 0 + + function setBarContext(position, bottomGap) { + effectiveBarPosition = position !== undefined ? position : 0; + effectiveBarBottomGap = bottomGap !== undefined ? bottomGap : 0; + } + + function primeContent() { + _primeContent = true; + } + + function clearPrimedContent() { + _primeContent = false; + } + + function setTriggerPosition(x, y, width, section, targetScreen, barPosition, barThickness, barSpacing, barConfig) { + triggerX = x; + triggerY = y; + triggerWidth = width; + triggerSection = section; + screen = targetScreen; + + storedBarThickness = barThickness !== undefined ? barThickness : (Theme.barHeight - 4); + storedBarSpacing = barSpacing !== undefined ? barSpacing : 4; + storedBarConfig = barConfig; + + const pos = barPosition !== undefined ? barPosition : 0; + const bottomGap = barConfig ? (barConfig.bottomGap !== undefined ? barConfig.bottomGap : 0) : 0; + + adjacentBarInfo = SettingsData.getAdjacentBarInfo(targetScreen, pos, barConfig); + setBarContext(pos, bottomGap); + } + + readonly property bool useBackgroundWindow: !CompositorService.isHyprland || CompositorService.useHyprlandFocusGrab + + function updateSurfacePosition() { + if (useBackgroundWindow && shouldBeVisible) { + _surfaceMarginLeft = alignedX - shadowBuffer; + _surfaceW = alignedWidth + shadowBuffer * 2; + } + } + + function open() { + if (!screen) + return; + closeTimer.stop(); + isClosing = false; + + _frozenMaskX = maskX; + _frozenMaskY = maskY; + _frozenMaskWidth = maskWidth; + _frozenMaskHeight = maskHeight; + + if (_lastOpenedScreen !== null && _lastOpenedScreen !== screen) { + contentWindow.visible = false; + if (useBackgroundWindow) + backgroundWindow.visible = false; + } + _lastOpenedScreen = screen; + + shouldBeVisible = true; + if (useBackgroundWindow) { + _surfaceMarginLeft = alignedX - shadowBuffer; + _surfaceW = alignedWidth + shadowBuffer * 2; + } + Qt.callLater(() => { + if (shouldBeVisible && screen) { + if (useBackgroundWindow) + backgroundWindow.visible = true; + contentWindow.visible = true; + PopoutManager.showPopout(popoutHandle); + opened(); + } + }); + } + + function close() { + isClosing = true; + shouldBeVisible = false; + _primeContent = false; + PopoutManager.popoutChanged(); + closeTimer.restart(); + } + + function toggle() { + shouldBeVisible ? close() : open(); + } + + Connections { + target: Quickshell + function onScreensChanged() { + if (!shouldBeVisible || !screen) + return; + const currentScreenName = screen.name; + let screenStillExists = false; + for (let i = 0; i < Quickshell.screens.length; i++) { + if (Quickshell.screens[i].name === currentScreenName) { + screenStillExists = true; + break; + } + } + if (!screenStillExists) { + close(); + } + } + } + + Timer { + id: closeTimer + interval: animationDuration + onTriggered: { + if (!shouldBeVisible) { + isClosing = false; + contentWindow.visible = false; + if (useBackgroundWindow) + backgroundWindow.visible = false; + PopoutManager.hidePopout(popoutHandle); + popoutClosed(); + } + } + } + + readonly property real screenWidth: screen ? screen.width : 0 + readonly property real screenHeight: screen ? screen.height : 0 + readonly property real dpr: screen ? screen.devicePixelRatio : 1 + + 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 shadowBuffer: Theme.snap(shadowRenderPadding + shadowMotionPadding, dpr) + readonly property real alignedWidth: Theme.px(popupWidth, dpr) + readonly property real alignedHeight: Theme.px(popupHeight, dpr) + + onAlignedHeightChanged: { + if (!suspendShadowWhileResizing || !shouldBeVisible) + return; + _resizeActive = true; + resizeSettleTimer.restart(); + } + onShouldBeVisibleChanged: { + if (!shouldBeVisible) { + _resizeActive = false; + resizeSettleTimer.stop(); + } + } + + Timer { + id: resizeSettleTimer + interval: 80 + repeat: false + onTriggered: root._resizeActive = false + } + + readonly property real alignedX: Theme.snap((() => { + const useAutoGaps = storedBarConfig?.popupGapsAuto !== undefined ? storedBarConfig.popupGapsAuto : true; + const manualGapValue = storedBarConfig?.popupGapsManual !== undefined ? storedBarConfig.popupGapsManual : 4; + const popupGap = useAutoGaps ? Math.max(4, storedBarSpacing) : manualGapValue; + + switch (effectiveBarPosition) { + case SettingsData.Position.Left: + return Math.max(popupGap, Math.min(screenWidth - popupWidth - popupGap, triggerX)); + case SettingsData.Position.Right: + return Math.max(popupGap, Math.min(screenWidth - popupWidth - popupGap, triggerX - popupWidth)); + default: + const rawX = triggerX + (triggerWidth / 2) - (popupWidth / 2); + const minX = adjacentBarInfo.leftBar > 0 ? adjacentBarInfo.leftBar : popupGap; + const maxX = screenWidth - popupWidth - (adjacentBarInfo.rightBar > 0 ? adjacentBarInfo.rightBar : popupGap); + return Math.max(minX, Math.min(maxX, rawX)); + } + })(), dpr) + + readonly property real alignedY: Theme.snap((() => { + const useAutoGaps = storedBarConfig?.popupGapsAuto !== undefined ? storedBarConfig.popupGapsAuto : true; + const manualGapValue = storedBarConfig?.popupGapsManual !== undefined ? storedBarConfig.popupGapsManual : 4; + const popupGap = useAutoGaps ? Math.max(4, storedBarSpacing) : manualGapValue; + + switch (effectiveBarPosition) { + case SettingsData.Position.Bottom: + return Math.max(popupGap, Math.min(screenHeight - popupHeight - popupGap, triggerY - popupHeight)); + case SettingsData.Position.Top: + return Math.max(popupGap, Math.min(screenHeight - popupHeight - popupGap, triggerY)); + default: + const rawY = triggerY - (popupHeight / 2); + const minY = adjacentBarInfo.topBar > 0 ? adjacentBarInfo.topBar : popupGap; + const maxY = screenHeight - popupHeight - (adjacentBarInfo.bottomBar > 0 ? adjacentBarInfo.bottomBar : popupGap); + return Math.max(minY, Math.min(maxY, rawY)); + } + })(), dpr) + + readonly property real triggeringBarLeftExclusion: (effectiveBarPosition === SettingsData.Position.Left && barWidth > 0) ? Math.max(0, barX + barWidth) : 0 + readonly property real triggeringBarTopExclusion: (effectiveBarPosition === SettingsData.Position.Top && barHeight > 0) ? Math.max(0, barY + barHeight) : 0 + readonly property real triggeringBarRightExclusion: (effectiveBarPosition === SettingsData.Position.Right && barWidth > 0) ? Math.max(0, screenWidth - barX) : 0 + readonly property real triggeringBarBottomExclusion: (effectiveBarPosition === SettingsData.Position.Bottom && barHeight > 0) ? Math.max(0, screenHeight - barY) : 0 + + readonly property real maskX: { + const adjacentLeftBar = adjacentBarInfo?.leftBar ?? 0; + return Math.max(triggeringBarLeftExclusion, adjacentLeftBar); + } + + readonly property real maskY: { + const adjacentTopBar = adjacentBarInfo?.topBar ?? 0; + return Math.max(triggeringBarTopExclusion, adjacentTopBar); + } + + readonly property real maskWidth: { + const adjacentRightBar = adjacentBarInfo?.rightBar ?? 0; + const rightExclusion = Math.max(triggeringBarRightExclusion, adjacentRightBar); + return Math.max(100, screenWidth - maskX - rightExclusion); + } + + readonly property real maskHeight: { + const adjacentBottomBar = adjacentBarInfo?.bottomBar ?? 0; + const bottomExclusion = Math.max(triggeringBarBottomExclusion, adjacentBottomBar); + return Math.max(100, screenHeight - maskY - bottomExclusion); + } + + PanelWindow { + id: backgroundWindow + screen: root.screen + visible: false + color: "transparent" + Component.onCompleted: { + if (typeof updatesEnabled !== "undefined" && !root.overlayContent) + updatesEnabled = false; + } + + WlrLayershell.namespace: root.layerNamespace + ":background" + WlrLayershell.layer: WlrLayershell.Top + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + anchors { + top: true + left: true + right: true + bottom: true + } + + mask: Region { + item: maskRect + } + + Rectangle { + id: maskRect + visible: false + color: "transparent" + x: root._frozenMaskX + y: root._frozenMaskY + width: (shouldBeVisible && backgroundInteractive) ? root._frozenMaskWidth : 0 + height: (shouldBeVisible && backgroundInteractive) ? root._frozenMaskHeight : 0 + } + + MouseArea { + 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; + + if (!outsideContent) + return; + backgroundClicked(); + } + } + + Loader { + id: overlayLoader + anchors.fill: parent + active: root.overlayContent !== null && backgroundWindow.visible + sourceComponent: root.overlayContent + } + } + + PanelWindow { + id: contentWindow + screen: root.screen + visible: false + color: "transparent" + + WindowBlur { + id: popoutBlur + targetWindow: contentWindow + readonly property real s: Math.min(1, contentContainer.scaleValue) + blurX: contentContainer.x + contentContainer.width * (1 - s) * 0.5 + Theme.snap(contentContainer.animX, root.dpr) + blurY: contentContainer.y + contentContainer.height * (1 - s) * 0.5 + Theme.snap(contentContainer.animY, root.dpr) + blurWidth: (shouldBeVisible && contentWrapper.opacity > 0) ? contentContainer.width * s : 0 + blurHeight: (shouldBeVisible && contentWrapper.opacity > 0) ? contentContainer.height * s : 0 + blurRadius: Theme.cornerRadius + } + + WlrLayershell.namespace: root.layerNamespace + WlrLayershell.layer: { + switch (Quickshell.env("DMS_POPOUT_LAYER")) { + case "bottom": + console.warn("DankPopout: 'bottom' layer is not valid for popouts. Defaulting to 'top' layer."); + return WlrLayershell.Top; + case "background": + console.warn("DankPopout: 'background' layer is not valid for popouts. Defaulting to 'top' layer."); + return WlrLayershell.Top; + case "overlay": + return WlrLayershell.Overlay; + default: + return WlrLayershell.Top; + } + } + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: { + if (customKeyboardFocus !== null) + return customKeyboardFocus; + if (!shouldBeVisible) + return WlrKeyboardFocus.None; + if (CompositorService.useHyprlandFocusGrab) + return WlrKeyboardFocus.OnDemand; + return WlrKeyboardFocus.Exclusive; + } + + readonly property bool _fullHeight: useBackgroundWindow && root.fullHeightSurface + + anchors { + left: true + top: true + right: !useBackgroundWindow + bottom: _fullHeight || !useBackgroundWindow + } + + WlrLayershell.margins { + left: useBackgroundWindow ? root._surfaceMarginLeft : 0 + top: (useBackgroundWindow && !_fullHeight) ? (root.alignedY - shadowBuffer) : 0 + } + + implicitWidth: useBackgroundWindow ? root._surfaceW : 0 + implicitHeight: (useBackgroundWindow && !_fullHeight) ? (root.alignedHeight + shadowBuffer * 2) : 0 + + mask: useBackgroundWindow ? contentInputMask : null + + Region { + id: contentInputMask + item: contentMaskRect + } + + Item { + id: contentMaskRect + visible: false + x: contentContainer.x + y: contentContainer.y + width: shouldBeVisible ? root.alignedWidth : 0 + height: shouldBeVisible ? root.alignedHeight : 0 + } + + MouseArea { + anchors.fill: parent + enabled: !useBackgroundWindow && shouldBeVisible && backgroundInteractive + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + z: -1 + onClicked: mouse => { + const clickX = mouse.x; + const clickY = mouse.y; + const outsideContent = clickX < root.alignedX || clickX > root.alignedX + root.alignedWidth || clickY < root.alignedY || clickY > root.alignedY + root.alignedHeight; + if (!outsideContent) + return; + backgroundClicked(); + } + } + + Item { + id: contentContainer + x: useBackgroundWindow ? shadowBuffer : root.alignedX + y: (useBackgroundWindow && !contentWindow._fullHeight) ? shadowBuffer : root.alignedY + width: root.alignedWidth + height: root.alignedHeight + + readonly property bool barTop: effectiveBarPosition === SettingsData.Position.Top + 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 string connectedBarSide: barTop ? "top" : (barBottom ? "bottom" : (barLeft ? "left" : "right")) + readonly property real offsetX: barLeft ? root.animationOffset : (barRight ? -root.animationOffset : 0) + readonly property real offsetY: barBottom ? -root.animationOffset : (barTop ? root.animationOffset : 0) + + 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() { + contentContainer.animX = Theme.snap(root.shouldBeVisible ? 0 : contentContainer.offsetX, root.dpr); + contentContainer.animY = Theme.snap(root.shouldBeVisible ? 0 : contentContainer.offsetY, root.dpr); + contentContainer.scaleValue = root.shouldBeVisible ? 1.0 : root.animationScaleCollapsed; + } + } + + Behavior on animX { + NumberAnimation { + duration: root.animationDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve + } + } + + Behavior on animY { + NumberAnimation { + duration: root.animationDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve + } + } + + Behavior on scaleValue { + NumberAnimation { + duration: root.animationDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve + } + } + + ElevationShadow { + id: shadowSource + width: parent.width + height: parent.height + opacity: contentWrapper.opacity + scale: contentWrapper.scale + x: contentWrapper.x + y: contentWrapper.y + level: root.shadowLevel + direction: root.effectiveShadowDirection + fallbackOffset: root.shadowFallbackOffset + targetRadius: Theme.cornerRadius + targetColor: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) + shadowEnabled: Theme.elevationEnabled && SettingsData.popoutElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1" && !(root.suspendShadowWhileResizing && root._resizeActive) + } + + Item { + id: contentWrapper + anchors.centerIn: parent + width: parent.width + height: parent.height + opacity: shouldBeVisible ? 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) + + layer.enabled: contentWrapper.opacity < 1 + layer.smooth: false + layer.textureSize: root.dpr > 1 ? Qt.size(Math.ceil(width * root.dpr), Math.ceil(height * root.dpr)) : Qt.size(0, 0) + + Behavior on opacity { + NumberAnimation { + duration: animationDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve + } + } + + Loader { + id: contentLoader + anchors.fill: parent + active: root._primeContent || shouldBeVisible || contentWindow.visible + asynchronous: false + } + } + + Rectangle { + width: parent.width + height: parent.height + x: contentWrapper.x + y: contentWrapper.y + opacity: contentWrapper.opacity + scale: contentWrapper.scale + visible: contentWrapper.visible + radius: Theme.cornerRadius + color: "transparent" + border.color: BlurService.enabled ? BlurService.borderColor : Theme.outlineMedium + border.width: BlurService.borderWidth + z: 100 + } + } + + Item { + id: focusHelper + parent: contentContainer + anchors.fill: parent + visible: !root.contentHandlesKeys + enabled: !root.contentHandlesKeys + focus: !root.contentHandlesKeys + Keys.onPressed: event => { + if (root.contentHandlesKeys) + return; + if (event.key === Qt.Key_Escape) { + close(); + event.accepted = true; + } + } + } + } +}