diff --git a/quickshell/Common/PopoutManager.qml b/quickshell/Common/PopoutManager.qml index e8b546cde..f7e49befa 100644 --- a/quickshell/Common/PopoutManager.qml +++ b/quickshell/Common/PopoutManager.qml @@ -79,12 +79,6 @@ Singleton { function _closePopout(popout) { try { - if (popout?.hoverDismissEnabled) { - if (typeof popout.closeFromHoverDismiss === "function") { - popout.closeFromHoverDismiss(); - return; - } - } if (popout.hoverDismissEnabled !== undefined) popout.hoverDismissEnabled = false; switch (true) { @@ -104,6 +98,20 @@ Singleton { } } + function _dismissPopoutFromHover(popout) { + try { + if (!popout || popout.hoverDismissEnabled !== true) + return; + if (typeof popout.closeFromHoverDismiss === "function") { + popout.closeFromHoverDismiss(); + return; + } + _closePopout(popout); + } catch (e) { + return; + } + } + function _isStale(popout) { try { if (!popout || !("shouldBeVisible" in popout)) @@ -174,6 +182,19 @@ Singleton { _closePopout(popout); } + function dismissHoverPopoutForScreen(screen) { + if (!screen) + return; + const screenName = screen.name; + const popout = currentPopoutsByScreen[screenName]; + if (!popout || _isStale(popout)) { + currentPopoutsByScreen[screenName] = null; + currentPopoutTriggers[screenName] = null; + return; + } + _dismissPopoutFromHover(popout); + } + function cancelHoverDismiss(screen) { const popout = getActivePopout(screen); if (popout?.cancelHoverDismiss) @@ -191,7 +212,8 @@ Singleton { const p = getActivePopout(screen); if (!p || !_isPopoutPresented(p)) return false; - return p.hoverDismissEnabled === false || p.hoverDismissSuspended === true; + const dismissSuspended = p.effectiveHoverDismissSuspended ?? p.hoverDismissSuspended; + return p.hoverDismissEnabled === false || dismissSuspended === true; } function isCurrentPopout(popout, screenName) { diff --git a/quickshell/Modals/Clipboard/ClipboardContent.qml b/quickshell/Modals/Clipboard/ClipboardContent.qml index 691917b09..62dfedb8c 100644 --- a/quickshell/Modals/Clipboard/ClipboardContent.qml +++ b/quickshell/Modals/Clipboard/ClipboardContent.qml @@ -8,6 +8,7 @@ Item { id: clipboardContent required property var modal + property var transientSurfaceTracker: null property alias searchField: searchField property alias clipboardListView: clipboardListView @@ -66,7 +67,7 @@ Item { contextMenu.hide(); } - readonly property bool contextMenuActive: contextMenu.openState + readonly property bool contextMenuActive: contextMenu.renderActive anchors.fill: parent @@ -74,6 +75,7 @@ Item { id: contextMenu modal: clipboardContent.modal parentHandler: clipboardContent + transientSurfaceTracker: clipboardContent.transientSurfaceTracker } Column { diff --git a/quickshell/Modals/Clipboard/ClipboardContextMenu.qml b/quickshell/Modals/Clipboard/ClipboardContextMenu.qml index 6ab1e626a..c281b0234 100644 --- a/quickshell/Modals/Clipboard/ClipboardContextMenu.qml +++ b/quickshell/Modals/Clipboard/ClipboardContextMenu.qml @@ -23,6 +23,8 @@ Item { property real anchorY: 0 property bool openState: false property bool renderActive: false + property var transientSurfaceTracker: null + readonly property alias contextWindow: menuWindow readonly property bool blurActive: renderActive && openState && BlurService.enabled && Theme.connectedSurfaceBlurEnabled readonly property bool hasPinnedDuplicate: !!entry && !entry.pinned && ClipboardService.getPinnedEntryByHash(entry.hash) !== null @@ -90,6 +92,18 @@ Item { readonly property real effectiveMenuHeight: Math.min(maxMenuHeight, naturalMenuHeight) readonly property bool menuScrolls: naturalMenuHeight > effectiveMenuHeight + 0.5 + onRenderActiveChanged: transientSurfaceTracker?.setActive(root, renderActive, contextWindow) + Component.onDestruction: transientSurfaceTracker?.unregister(root) + + Connections { + target: root.transientSurfaceTracker + ignoreUnknownSignals: true + + function onCloseRequested() { + root.hide(); + } + } + TextMetrics { id: menuTextMetrics text: root.longestMenuText diff --git a/quickshell/Modals/Clipboard/ClipboardHistoryContent.qml b/quickshell/Modals/Clipboard/ClipboardHistoryContent.qml index c4f288a3f..6a29c20de 100644 --- a/quickshell/Modals/Clipboard/ClipboardHistoryContent.qml +++ b/quickshell/Modals/Clipboard/ClipboardHistoryContent.qml @@ -9,6 +9,7 @@ FocusScope { property var clearConfirmDialog: null property var surfaceHost: null + property var transientSurfaceTracker: null property string activeTab: "recents" property bool showKeyboardHints: false @@ -198,6 +199,7 @@ FocusScope { id: historyContent anchors.fill: parent modal: root + transientSurfaceTracker: root.transientSurfaceTracker } } diff --git a/quickshell/Modals/Clipboard/ClipboardHistoryModal.qml b/quickshell/Modals/Clipboard/ClipboardHistoryModal.qml index cdce12b53..f0f71363e 100644 --- a/quickshell/Modals/Clipboard/ClipboardHistoryModal.qml +++ b/quickshell/Modals/Clipboard/ClipboardHistoryModal.qml @@ -130,6 +130,7 @@ DankModal { content: Component { ClipboardHistoryContent { surfaceHost: clipboardHistoryModal + transientSurfaceTracker: clipboardHistoryModal.transientSurfaceTracker clearConfirmDialog: clearConfirmDialog onCloseRequested: clipboardHistoryModal.hide() onInstantCloseRequested: clipboardHistoryModal.instantHide() diff --git a/quickshell/Modals/Clipboard/ClipboardHistoryPopout.qml b/quickshell/Modals/Clipboard/ClipboardHistoryPopout.qml index 8ccf6ae07..915035b84 100644 --- a/quickshell/Modals/Clipboard/ClipboardHistoryPopout.qml +++ b/quickshell/Modals/Clipboard/ClipboardHistoryPopout.qml @@ -141,6 +141,7 @@ DankPopout { LayoutMirroring.childrenInherit: true surfaceHost: root + transientSurfaceTracker: root.transientSurfaceTracker clearConfirmDialog: clearConfirmDialog onCloseRequested: root.hide() onInstantCloseRequested: root.hide() diff --git a/quickshell/Modals/Common/DankModal.qml b/quickshell/Modals/Common/DankModal.qml index f813ed670..81592ad43 100644 --- a/quickshell/Modals/Common/DankModal.qml +++ b/quickshell/Modals/Common/DankModal.qml @@ -2,6 +2,7 @@ import QtQuick import Quickshell.Hyprland import qs.Common import qs.Services +import qs.Widgets Item { id: root @@ -38,6 +39,7 @@ Item { property bool keepContentLoaded: false property bool keepPopoutsOpen: false property var customKeyboardFocus: null + readonly property alias transientSurfaceTracker: _transientSurfaceTracker property bool useOverlayLayer: false signal opened @@ -47,6 +49,10 @@ Item { readonly property var contentLoader: impl.item ? impl.item.contentLoader : null readonly property alias modalFocusScope: _modalFocusScope + TransientSurfaceTracker { + id: _transientSurfaceTracker + } + FocusScope { id: _modalFocusScope objectName: "modalFocusScope" @@ -56,7 +62,7 @@ Item { // Hyprland OnDemand grab delivers keyboard focus to the modal content surface. HyprlandFocusGrab { - windows: root.contentWindow ? [root.contentWindow] : [] + windows: (root.contentWindow ? [root.contentWindow] : []).concat(root.transientSurfaceTracker?.focusWindows ?? []) active: KeyboardFocus.wantsGrab(root.shouldHaveFocus, root.customKeyboardFocus) property var restoreToplevel: null @@ -79,11 +85,13 @@ Item { } function close() { + transientSurfaceTracker?.closeAll?.(); if (impl.item) impl.item.close(); } function instantClose() { + transientSurfaceTracker?.closeAll?.(); if (impl.item && typeof impl.item.instantClose === "function") impl.item.instantClose(); } @@ -175,6 +183,8 @@ Item { Connections { target: root function onShouldBeVisibleChanged() { + if (!root.shouldBeVisible) + root.transientSurfaceTracker?.closeAll?.(); if (impl.item && impl.item.shouldBeVisible !== root.shouldBeVisible) impl.item.shouldBeVisible = root.shouldBeVisible; } diff --git a/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalConnected.qml b/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalConnected.qml index 67be72065..be516eef2 100644 --- a/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalConnected.qml +++ b/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalConnected.qml @@ -35,6 +35,10 @@ Item { property real _frozenMotionX: 0 property real _frozenMotionY: 0 + TransientSurfaceTracker { + id: transientSurfaces + } + readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab readonly property var effectiveScreen: contentWindow.screen readonly property real screenWidth: effectiveScreen?.width ?? 1920 @@ -499,10 +503,18 @@ Item { } // Handles hover dismissal grace periods for edge-hover sessions w/cursor - readonly property bool _edgeRetractEnabled: (modalHandle && modalHandle.edgeHoverManaged === true) && spotlightOpen && !isClosing + readonly property bool _edgeRetractEnabled: (modalHandle && modalHandle.edgeHoverManaged === true) && spotlightOpen && !isClosing && !transientSurfaces.active property bool _edgeBodyHover: false property bool _edgeArmed: false + on_EdgeRetractEnabledChanged: { + if (!_edgeRetractEnabled) { + _edgeRetractGrace.stop(); + } else if (_edgeArmed && !_edgeBodyHover) { + _edgeRetractGrace.restart(); + } + } + Timer { id: _edgeRetractGrace interval: 150 @@ -534,9 +546,7 @@ Item { HyprlandFocusGrab { id: focusGrab - readonly property var contextMenuWindow: root.spotlightContent?.activeContextMenu?.contextWindow ?? null - readonly property bool contextMenuActive: root.spotlightContent?.activeContextMenu?.renderActive ?? false - windows: contextMenuActive && contextMenuWindow ? [contentWindow, contextMenuWindow] : [contentWindow] + windows: [contentWindow].concat(transientSurfaces.focusWindows) active: root.useHyprlandFocusGrab && root.spotlightOpen onCleared: { @@ -869,6 +879,7 @@ Item { sourceComponent: LauncherContent { focus: true parentModal: root + transientSurfaceTracker: transientSurfaces } onLoaded: { diff --git a/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalSpotlight.qml b/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalSpotlight.qml index 62102a1a6..0dffd0c5b 100644 --- a/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalSpotlight.qml +++ b/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalSpotlight.qml @@ -26,6 +26,10 @@ Item { property string _pendingMode: "" readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab + + TransientSurfaceTracker { + id: transientSurfaces + } readonly property var effectiveScreen: launcherWindow.screen readonly property real screenWidth: effectiveScreen?.width ?? 1920 readonly property real screenHeight: effectiveScreen?.height ?? 1080 @@ -228,9 +232,7 @@ Item { HyprlandFocusGrab { id: focusGrab - readonly property var contextMenuWindow: root.spotlightContent?.activeContextMenu?.contextWindow ?? null - readonly property bool contextMenuActive: root.spotlightContent?.activeContextMenu?.renderActive ?? false - windows: contextMenuActive && contextMenuWindow ? [launcherWindow, contextMenuWindow] : [launcherWindow] + windows: [launcherWindow].concat(transientSurfaces.focusWindows) active: root.useHyprlandFocusGrab && root.keyboardActive onCleared: { if (spotlightOpen) @@ -482,6 +484,7 @@ Item { sourceComponent: SpotlightLauncherContent { focus: true parentModal: root + transientSurfaceTracker: transientSurfaces } onLoaded: { diff --git a/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalStandalone.qml b/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalStandalone.qml index 74f37bae3..89ccb4bd7 100644 --- a/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalStandalone.qml +++ b/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalStandalone.qml @@ -27,6 +27,10 @@ Item { readonly property bool unloadContentOnClose: SettingsData.dankLauncherV2UnloadOnClose readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab + + TransientSurfaceTracker { + id: transientSurfaces + } readonly property var effectiveScreen: launcherWindow.screen readonly property real screenWidth: effectiveScreen?.width ?? 1920 readonly property real screenHeight: effectiveScreen?.height ?? 1080 @@ -260,9 +264,7 @@ Item { HyprlandFocusGrab { id: focusGrab - readonly property var contextMenuWindow: root.spotlightContent?.activeContextMenu?.contextWindow ?? null - readonly property bool contextMenuActive: root.spotlightContent?.activeContextMenu?.renderActive ?? false - windows: contextMenuActive && contextMenuWindow ? [launcherWindow, contextMenuWindow] : [launcherWindow] + windows: [launcherWindow].concat(transientSurfaces.focusWindows) active: root.useHyprlandFocusGrab && root.keyboardActive onCleared: { @@ -530,6 +532,7 @@ Item { sourceComponent: LauncherContent { focus: true parentModal: root + transientSurfaceTracker: transientSurfaces } onLoaded: { diff --git a/quickshell/Modals/DankLauncherV2/LauncherContent.qml b/quickshell/Modals/DankLauncherV2/LauncherContent.qml index 5b793512a..9f1135a1e 100644 --- a/quickshell/Modals/DankLauncherV2/LauncherContent.qml +++ b/quickshell/Modals/DankLauncherV2/LauncherContent.qml @@ -18,6 +18,7 @@ FocusScope { property alias resultsList: resultsList property alias actionPanel: actionPanel readonly property alias activeContextMenu: contextMenu + property var transientSurfaceTracker: null property bool editMode: false property var editingApp: null @@ -43,7 +44,7 @@ FocusScope { } function closeTransientUi() { - contextMenu.hide(); + transientSurfaceTracker?.closeAll?.(); actionPanel.hide(); root.enabled = true; } @@ -123,6 +124,7 @@ FocusScope { controller: root.controller searchField: root.searchField parentHandler: root + transientSurfaceTracker: root.transientSurfaceTracker onEditAppRequested: app => { root.openEditMode(app); @@ -779,6 +781,7 @@ FocusScope { anchors.fill: parent controller: root.controller leadingSectionHeaderAtBottom: contentHolder.inverted + transientSurfaceTracker: root.transientSurfaceTracker onItemRightClicked: (index, item, sceneX, sceneY) => { if (item && contextMenu.hasContextMenuActions(item)) { diff --git a/quickshell/Modals/DankLauncherV2/LauncherContextMenu.qml b/quickshell/Modals/DankLauncherV2/LauncherContextMenu.qml index 5ce4aecca..7021006a1 100644 --- a/quickshell/Modals/DankLauncherV2/LauncherContextMenu.qml +++ b/quickshell/Modals/DankLauncherV2/LauncherContextMenu.qml @@ -25,6 +25,7 @@ Item { property real anchorY: 0 property bool openState: false property bool renderActive: false + property var transientSurfaceTracker: null readonly property alias contextWindow: menuWindow readonly property bool blurActive: renderActive && openState && BlurService.enabled && Theme.connectedSurfaceBlurEnabled @@ -49,6 +50,18 @@ Item { signal hideRequested signal editAppRequested(var app) + onRenderActiveChanged: transientSurfaceTracker?.setActive(root, renderActive, contextWindow) + Component.onDestruction: transientSurfaceTracker?.unregister(root) + + Connections { + target: root.transientSurfaceTracker + ignoreUnknownSignals: true + + function onCloseRequested() { + root.hide(); + } + } + TextMetrics { id: menuTextMetrics text: root.longestMenuText diff --git a/quickshell/Modals/DankLauncherV2/ResultsList.qml b/quickshell/Modals/DankLauncherV2/ResultsList.qml index 71dd4dfea..7bc096124 100644 --- a/quickshell/Modals/DankLauncherV2/ResultsList.qml +++ b/quickshell/Modals/DankLauncherV2/ResultsList.qml @@ -15,6 +15,7 @@ Item { property var _visualRows: [] property var _flatIndexToRowMap: ({}) property var _cumulativeHeights: [] + property var transientSurfaceTracker: null readonly property bool _bottomSectionHeaderActive: leadingSectionHeaderAtBottom && (controller?.sections?.length ?? 0) > 0 signal itemRightClicked(int index, var item, real mouseX, real mouseY) @@ -259,6 +260,7 @@ Item { return root.controller?.canChangeSectionViewMode(delegateRoot.modelData?.sectionId ?? "") ?? false; } canCollapse: root.controller?.canCollapseSection(delegateRoot.modelData?.sectionId ?? "") ?? false + transientSurfaceTracker: root.transientSurfaceTracker } ResultItem { @@ -447,6 +449,7 @@ Item { return root.controller?.canCollapseSection(stickyHeader.stickyHeaderSection?.id) ?? false; } isSticky: true + transientSurfaceTracker: root.transientSurfaceTracker } } @@ -472,6 +475,7 @@ Item { canCollapse: root.controller?.canCollapseSection(section?.id ?? "") ?? false isSticky: true popupAbove: true + transientSurfaceTracker: root.transientSurfaceTracker } Item { diff --git a/quickshell/Modals/DankLauncherV2/SectionHeader.qml b/quickshell/Modals/DankLauncherV2/SectionHeader.qml index f5db4ef7e..93a1439dd 100644 --- a/quickshell/Modals/DankLauncherV2/SectionHeader.qml +++ b/quickshell/Modals/DankLauncherV2/SectionHeader.qml @@ -17,9 +17,21 @@ Rectangle { property bool isSticky: false property bool popupAbove: false property Item popupAboveItem: null + property var transientSurfaceTracker: null signal viewModeToggled + Component.onDestruction: transientSurfaceTracker?.unregister(root) + + Connections { + target: root.transientSurfaceTracker + ignoreUnknownSignals: true + + function onCloseRequested() { + categoryPopup.close(); + } + } + width: parent?.width ?? 200 height: 32 color: isSticky ? Theme.withAlpha(Theme.surfaceHover, 0) : (hoverArea.containsMouse ? Theme.surfaceHover : Theme.withAlpha(Theme.surfaceHover, 0)) @@ -142,6 +154,8 @@ Rectangle { dim: false closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + onVisibleChanged: root.transientSurfaceTracker?.setActive(root, visible, null) + background: Rectangle { color: "transparent" } diff --git a/quickshell/Modals/DankLauncherV2/SpotlightLauncherContent.qml b/quickshell/Modals/DankLauncherV2/SpotlightLauncherContent.qml index 7a27f5de0..50b03a030 100644 --- a/quickshell/Modals/DankLauncherV2/SpotlightLauncherContent.qml +++ b/quickshell/Modals/DankLauncherV2/SpotlightLauncherContent.qml @@ -12,6 +12,7 @@ FocusScope { property alias searchField: searchInput property alias controller: searchController readonly property alias activeContextMenu: contextMenu + property var transientSurfaceTracker: null readonly property bool _hasQuery: searchInput.text.length > 0 readonly property real _searchBarH: 56 @@ -48,7 +49,7 @@ FocusScope { } function closeTransientUi() { - contextMenu.hide(); + transientSurfaceTracker?.closeAll?.(); root.enabled = true; } @@ -220,6 +221,7 @@ FocusScope { searchField: searchInput parentHandler: root allowEditActions: false + transientSurfaceTracker: root.transientSurfaceTracker } Connections { diff --git a/quickshell/Modals/NotificationModal.qml b/quickshell/Modals/NotificationModal.qml index 40523b777..518782302 100644 --- a/quickshell/Modals/NotificationModal.qml +++ b/quickshell/Modals/NotificationModal.qml @@ -210,12 +210,14 @@ DankModal { NotificationHeader { id: notificationHeader keyboardController: modalKeyboardController + transientSurfaceTracker: notificationModal.transientSurfaceTracker onCurrentTabChanged: notificationModal.currentTab = currentTab Component.onCompleted: notificationModal.notificationHeaderRef = notificationHeader } NotificationSettings { id: notificationSettings + transientSurfaceTracker: notificationModal.transientSurfaceTracker expanded: notificationHeader.showSettings } @@ -225,6 +227,7 @@ DankModal { height: parent.height - y visible: notificationHeader.currentTab === 0 keyboardController: modalKeyboardController + transientSurfaceTracker: notificationModal.transientSurfaceTracker Component.onCompleted: { notificationModal.notificationListRef = notificationList; if (modalKeyboardController) { diff --git a/quickshell/Modules/AppDrawer/AppDrawerPopout.qml b/quickshell/Modules/AppDrawer/AppDrawerPopout.qml index 21c308707..0cdb91afd 100644 --- a/quickshell/Modules/AppDrawer/AppDrawerPopout.qml +++ b/quickshell/Modules/AppDrawer/AppDrawerPopout.qml @@ -122,6 +122,7 @@ DankPopout { anchors.fill: parent parentModal: modalAdapter viewModeContext: "appDrawer" + transientSurfaceTracker: appDrawerPopout.transientSurfaceTracker } Keys.onEscapePressed: event => { diff --git a/quickshell/Modules/DankBar/DankBarHoverController.qml b/quickshell/Modules/DankBar/DankBarHoverController.qml index 27aaa5dba..6a29184b8 100644 --- a/quickshell/Modules/DankBar/DankBarHoverController.qml +++ b/quickshell/Modules/DankBar/DankBarHoverController.qml @@ -608,7 +608,7 @@ Item { function closeHoverSurfaces() { _closeHoverNotepad(); activeHoverTrigger = ""; - PopoutManager.closePopoutForScreen(barWindow?.screen); + PopoutManager.dismissHoverPopoutForScreen(barWindow?.screen); TrayMenuManager.closeAllMenus(); } diff --git a/quickshell/Modules/Notifications/Center/KeyboardNavigatedNotificationList.qml b/quickshell/Modules/Notifications/Center/KeyboardNavigatedNotificationList.qml index 9587e7a6e..109dad55f 100644 --- a/quickshell/Modules/Notifications/Center/KeyboardNavigatedNotificationList.qml +++ b/quickshell/Modules/Notifications/Center/KeyboardNavigatedNotificationList.qml @@ -17,6 +17,7 @@ DankListView { property int swipingCardIndex: -1 property real swipingCardOffset: 0 property bool _stableHeightUpdatePending: false + property var transientSurfaceTracker: null readonly property real shadowBlurPx: Theme.elevationEnabled ? ((Theme.elevationLevel1 && Theme.elevationLevel1.blurPx !== undefined) ? Theme.elevationLevel1.blurPx : 4) : 0 readonly property real shadowHorizontalGutter: Theme.snap(Math.max(Theme.spacingS, Math.min(32, shadowBlurPx * 1.5 + 6)), 1) readonly property real shadowVerticalGutter: Theme.snap(Math.max(Theme.spacingXS, 6), 1) @@ -173,6 +174,7 @@ DankListView { notificationGroup: modelData keyboardNavigationActive: listView.keyboardActive animateExpansion: listView.cardAnimateExpansion && listView.listInitialized + transientSurfaceTracker: listView.transientSurfaceTracker opacity: { const swipeAmount = Math.abs(delegateRoot.swipeOffset); if (swipeAmount <= delegateRoot.swipeFadeStartOffset) diff --git a/quickshell/Modules/Notifications/Center/NotificationCard.qml b/quickshell/Modules/Notifications/Center/NotificationCard.qml index 7cb824c5c..57a12fefd 100644 --- a/quickshell/Modules/Notifications/Center/NotificationCard.qml +++ b/quickshell/Modules/Notifications/Center/NotificationCard.qml @@ -30,6 +30,7 @@ Rectangle { property real swipingNotificationOffset: 0 property real listLevelAdjacentScaleInfluence: 1.0 property bool listLevelScaleAnimationsEnabled: true + property var transientSurfaceTracker: null readonly property bool compactMode: SettingsData.notificationCompactMode readonly property real cardPadding: compactMode ? Theme.notificationCardPaddingCompact : Theme.notificationCardPadding @@ -63,6 +64,20 @@ Rectangle { }); } + Component.onDestruction: { + transientSurfaceTracker?.unregister(root); + notificationCardContextMenu.close(); + } + + Connections { + target: root.transientSurfaceTracker + ignoreUnknownSignals: true + + function onCloseRequested() { + notificationCardContextMenu.close(); + } + } + function expansionMotionDuration() { if (isDescriptionToggleAnimation) return descriptionExpanded ? Theme.notificationInlineExpandDuration : Theme.notificationInlineCollapseDuration; @@ -1059,6 +1074,8 @@ Rectangle { width: 220 closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + onVisibleChanged: root.transientSurfaceTracker?.setActive(root, visible, null) + background: Rectangle { color: BlurService.enabled ? Theme.surfaceContainer : Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) radius: Theme.cornerRadius diff --git a/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml b/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml index 012a1e1fd..a87dcbeef 100644 --- a/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml +++ b/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml @@ -283,11 +283,13 @@ DankPopout { NotificationHeader { id: notificationHeader objectName: "notificationHeader" + transientSurfaceTracker: root.transientSurfaceTracker onHeightChanged: notificationContent.cachedHeaderHeight = height } NotificationSettings { id: notificationSettings + transientSurfaceTracker: root.transientSurfaceTracker expanded: notificationHeader.showSettings maxAllowedHeight: notificationContent.settingsMaxHeight } @@ -306,6 +308,7 @@ DankPopout { anchors.topMargin: -(shadowVerticalGutter + delegateShadowGutter / 2) anchors.bottomMargin: -(shadowVerticalGutter + delegateShadowGutter / 2) cardAnimateExpansion: true + transientSurfaceTracker: root.transientSurfaceTracker } } diff --git a/quickshell/Modules/Notifications/Center/NotificationHeader.qml b/quickshell/Modules/Notifications/Center/NotificationHeader.qml index 321b64ba4..1cd920f21 100644 --- a/quickshell/Modules/Notifications/Center/NotificationHeader.qml +++ b/quickshell/Modules/Notifications/Center/NotificationHeader.qml @@ -10,6 +10,19 @@ Item { property bool showSettings: false property int currentTab: 0 property bool showDndMenu: false + property var transientSurfaceTracker: null + + onShowDndMenuChanged: transientSurfaceTracker?.setActive(root, showDndMenu, null) + Component.onDestruction: transientSurfaceTracker?.unregister(root) + + Connections { + target: root.transientSurfaceTracker + ignoreUnknownSignals: true + + function onCloseRequested() { + root.showDndMenu = false; + } + } onCurrentTabChanged: { if (currentTab === 1 && !SettingsData.notificationHistoryEnabled) diff --git a/quickshell/Modules/Notifications/Center/NotificationSettings.qml b/quickshell/Modules/Notifications/Center/NotificationSettings.qml index 23ada2846..74b9657ec 100644 --- a/quickshell/Modules/Notifications/Center/NotificationSettings.qml +++ b/quickshell/Modules/Notifications/Center/NotificationSettings.qml @@ -7,6 +7,7 @@ Rectangle { property bool expanded: false property real maxAllowedHeight: 0 + property var transientSurfaceTracker: null readonly property real naturalContentHeight: contentColumn.height + Theme.spacingL * 2 width: parent.width @@ -178,6 +179,8 @@ Rectangle { } DankDropdown { + id: lowTimeoutDropdown + transientSurfaceTracker: root.transientSurfaceTracker text: I18n.tr("Low Priority") description: I18n.tr("Timeout for low priority notifications") currentValue: getTimeoutText(SettingsData.notificationTimeoutLow) @@ -193,6 +196,8 @@ Rectangle { } DankDropdown { + id: normalTimeoutDropdown + transientSurfaceTracker: root.transientSurfaceTracker text: I18n.tr("Normal Priority") description: I18n.tr("Timeout for normal priority notifications") currentValue: getTimeoutText(SettingsData.notificationTimeoutNormal) @@ -208,6 +213,8 @@ Rectangle { } DankDropdown { + id: criticalTimeoutDropdown + transientSurfaceTracker: root.transientSurfaceTracker text: I18n.tr("Critical Priority") description: I18n.tr("Timeout for critical priority notifications") currentValue: getTimeoutText(SettingsData.notificationTimeoutCritical) diff --git a/quickshell/Modules/Notifications/NotificationContextMenu.qml b/quickshell/Modules/Notifications/NotificationContextMenu.qml index d28863b8a..790b02ddf 100644 --- a/quickshell/Modules/Notifications/NotificationContextMenu.qml +++ b/quickshell/Modules/Notifications/NotificationContextMenu.qml @@ -11,12 +11,25 @@ PanelWindow { property string appName: "" property string desktopEntry: "" property point anchorPos: Qt.point(0, 0) + property var transientSurfaceTracker: null signal muted signal dismissRequested readonly property bool isMuted: SettingsData.isAppMuted(appName, desktopEntry) + onVisibleChanged: transientSurfaceTracker?.setActive(root, visible, root) + Component.onDestruction: transientSurfaceTracker?.unregister(root) + + Connections { + target: root.transientSurfaceTracker + ignoreUnknownSignals: true + + function onCloseRequested() { + root.closeMenu(); + } + } + function showAt(x, y, targetScreen) { if (targetScreen) screen = targetScreen; diff --git a/quickshell/Modules/Notifications/Popup/NotificationPopup.qml b/quickshell/Modules/Notifications/Popup/NotificationPopup.qml index f2212f09b..adfdf169b 100644 --- a/quickshell/Modules/Notifications/Popup/NotificationPopup.qml +++ b/quickshell/Modules/Notifications/Popup/NotificationPopup.qml @@ -70,6 +70,11 @@ PanelWindow { property real _storedTopMargin: 0 property real _storedBottomMargin: 0 property bool _inlineGeometryReady: false + readonly property bool contextMenuActive: transientSurfaces.active + + TransientSurfaceTracker { + id: transientSurfaces + } readonly property bool directionalEffect: Theme.isDirectionalEffect readonly property bool depthEffect: Theme.isDepthEffect readonly property real entryTravel: { @@ -122,6 +127,7 @@ PanelWindow { if (exiting || _isDestroying) { return; } + closeTransientUi(); exiting = true; exitStarted(); popupChromeGeometryChanged(); @@ -135,6 +141,7 @@ PanelWindow { if (_isDestroying) { return; } + closeTransientUi(); _isDestroying = true; exiting = true; visible = false; @@ -147,6 +154,7 @@ PanelWindow { return; } + closeTransientUi(); _finalized = true; _isDestroying = true; exitWatchdog.stop(); @@ -155,6 +163,11 @@ PanelWindow { win.exitFinished(); } + function closeTransientUi() { + transientSurfaces.closeAll(); + popupContextMenuLoader.active = false; + } + visible: !_finalized WlrLayershell.layer: { const shouldUseOverlay = notificationData && (SettingsData.notificationOverlayEnabled || notificationData.urgency === NotificationUrgency.Critical); @@ -274,6 +287,7 @@ PanelWindow { } onNotificationDataChanged: { if (!_isDestroying) { + closeTransientUi(); if (SettingsData.notificationPopupPrivacyMode) descriptionExpanded = false; wrapperConn.target = win.notificationData || null; @@ -286,6 +300,7 @@ PanelWindow { } } Component.onDestruction: { + closeTransientUi(); _isDestroying = true; exitWatchdog.stop(); if (notificationData && notificationData.timer) { @@ -716,7 +731,7 @@ PanelWindow { if (cardHoverHandler.hovered) { if (notificationData.timer) notificationData.timer.stop(); - } else if (notificationData.popup && notificationData.timer) { + } else if (!win.contextMenuActive && notificationData.popup && notificationData.timer) { notificationData.timer.restart(); } } @@ -1357,11 +1372,26 @@ PanelWindow { interval: 160 repeat: false onTriggered: { - if (notificationData && notificationData.timer && !exiting && !_isDestroying) + if (notificationData && notificationData.timer && !contextMenuActive && !exiting && !_isDestroying) notificationData.timer.start(); } } + Connections { + target: popupContextMenuLoader.item + ignoreUnknownSignals: true + + function onVisibleChanged() { + if (!notificationData?.timer || exiting || _isDestroying) + return; + if (win.contextMenuActive) { + notificationData.timer.stop(); + } else if (!cardHoverHandler.hovered && notificationData.popup) { + notificationData.timer.restart(); + } + } + } + Timer { id: exitWatchdog @@ -1387,6 +1417,7 @@ PanelWindow { active: false sourceComponent: NotificationContextMenu { + transientSurfaceTracker: transientSurfaces appName: notificationData?.appName ?? "" desktopEntry: notificationData?.desktopEntry ?? "" onMuted: { diff --git a/quickshell/Modules/ProcessList/ProcessContextMenu.qml b/quickshell/Modules/ProcessList/ProcessContextMenu.qml index 97091343b..abee67c73 100644 --- a/quickshell/Modules/ProcessList/ProcessContextMenu.qml +++ b/quickshell/Modules/ProcessList/ProcessContextMenu.qml @@ -12,10 +12,23 @@ Popup { property int selectedIndex: -1 property bool keyboardNavigation: false property var parentFocusItem: null + property var transientSurfaceTracker: null signal menuClosed signal processKilled + onVisibleChanged: transientSurfaceTracker?.setActive(root, visible, null) + Component.onDestruction: transientSurfaceTracker?.unregister(root) + + Connections { + target: processContextMenu.transientSurfaceTracker + ignoreUnknownSignals: true + + function onCloseRequested() { + processContextMenu.close(); + } + } + readonly property var menuItems: [ { text: I18n.tr("Copy PID"), diff --git a/quickshell/Modules/ProcessList/ProcessListPopout.qml b/quickshell/Modules/ProcessList/ProcessListPopout.qml index 9e4dd8013..1b7f019c0 100644 --- a/quickshell/Modules/ProcessList/ProcessListPopout.qml +++ b/quickshell/Modules/ProcessList/ProcessListPopout.qml @@ -66,6 +66,7 @@ DankPopout { ProcessContextMenu { id: processContextMenu + transientSurfaceTracker: processListPopout.transientSurfaceTracker } content: Component { diff --git a/quickshell/Widgets/DankDropdown.qml b/quickshell/Widgets/DankDropdown.qml index db6f30174..c3020d18d 100644 --- a/quickshell/Widgets/DankDropdown.qml +++ b/quickshell/Widgets/DankDropdown.qml @@ -54,11 +54,14 @@ Item { property bool addHorizontalPadding: false property string emptyText: "" property bool usePopupTransparency: !checkParentDisablesTransparency() + property var transientSurfaceTracker: null signal valueChanged(string value) property bool menuOpen: false + onMenuOpenChanged: transientSurfaceTracker?.setActive(root, menuOpen, null) + function closeDropdownMenu() { if (!root.menuOpen && !dropdownMenu.opened && !dropdownMenu.visible) return; @@ -113,10 +116,20 @@ Item { implicitHeight: !showTrigger ? 0 : (compactMode ? 40 : Math.max(60, labelColumn.implicitHeight + Theme.spacingM)) Component.onDestruction: { + transientSurfaceTracker?.unregister(root); if (root.menuOpen || dropdownMenu.opened || dropdownMenu.visible) dropdownMenu.close(); } + Connections { + target: root.transientSurfaceTracker + ignoreUnknownSignals: true + + function onCloseRequested() { + root.closeDropdownMenu(); + } + } + Column { id: labelColumn diff --git a/quickshell/Widgets/DankPopout.qml b/quickshell/Widgets/DankPopout.qml index 9d74c8e75..cc82d1fad 100644 --- a/quickshell/Widgets/DankPopout.qml +++ b/quickshell/Widgets/DankPopout.qml @@ -27,6 +27,8 @@ Item { property bool hoverDismissEnabled: false property bool hoverDismissSuspended: false property var customKeyboardFocus: null + readonly property alias transientSurfaceTracker: _transientSurfaceTracker + readonly property bool effectiveHoverDismissSuspended: hoverDismissSuspended || (transientSurfaceTracker?.active ?? false) property bool backgroundInteractive: true property bool contentHandlesKeys: false property bool fullHeightSurface: false @@ -55,6 +57,10 @@ Item { readonly property var backgroundWindow: impl.item ? impl.item.backgroundWindow : null readonly property var contentWindow: impl.item ? impl.item.contentWindow : null + TransientSurfaceTracker { + id: _transientSurfaceTracker + } + // Hyprland OnDemand grab: whitelist popout surfaces and bars so dismiss clicks still land. HyprlandFocusGrab { windows: { @@ -63,7 +69,8 @@ Item { list.push(root.contentWindow); if (root.backgroundWindow && root.backgroundWindow !== root.contentWindow) list.push(root.backgroundWindow); - return list.concat(KeyboardFocus.barWindows); + const transientWindows = root.transientSurfaceTracker?.focusWindows ?? []; + return list.concat(transientWindows).concat(KeyboardFocus.barWindows); } active: KeyboardFocus.wantsGrab(root.shouldBeVisible, root.customKeyboardFocus) @@ -170,6 +177,7 @@ Item { function close() { _pendingOpen = false; _pendingOpenTimer.stop(); + transientSurfaceTracker?.closeAll?.(); if (impl.item) impl.item.close(); } @@ -186,8 +194,9 @@ Item { } function closeFromHoverDismiss() { - if (hoverDismissSuspended) + if (effectiveHoverDismissSuspended) return; + transientSurfaceTracker?.closeAll?.(); hoverDismissEnabled = false; // Enable animations using standard Theme-bound popout motion to preserve bindings. if (impl.item) @@ -308,7 +317,7 @@ Item { it.effectiveBarPosition = Qt.binding(() => root.effectiveBarPosition); it.effectiveBarBottomGap = Qt.binding(() => root.effectiveBarBottomGap); it.hoverDismissEnabled = Qt.binding(() => root.hoverDismissEnabled); - it.hoverDismissSuspended = Qt.binding(() => root.hoverDismissSuspended); + it.hoverDismissSuspended = Qt.binding(() => root.effectiveHoverDismissSuspended); it.shouldBeVisible = root.shouldBeVisible; if (root._primeContent && typeof it.primeContent === "function") @@ -332,6 +341,8 @@ Item { Connections { target: root function onShouldBeVisibleChanged() { + if (!root.shouldBeVisible) + root.transientSurfaceTracker?.closeAll?.(); if (impl.item && impl.item.shouldBeVisible !== root.shouldBeVisible) impl.item.shouldBeVisible = root.shouldBeVisible; } diff --git a/quickshell/Widgets/TransientSurfaceTracker.qml b/quickshell/Widgets/TransientSurfaceTracker.qml new file mode 100644 index 000000000..656095bfe --- /dev/null +++ b/quickshell/Widgets/TransientSurfaceTracker.qml @@ -0,0 +1,37 @@ +pragma ComponentBehavior: Bound + +import QtQuick + +QtObject { + id: root + + property var _entries: [] + readonly property bool active: _entries.length > 0 + readonly property var focusWindows: _entries.map(entry => entry.focusWindow).filter(window => window) + + signal closeRequested + + function setActive(owner, active, focusWindow) { + if (!owner) + return; + const next = _entries.filter(entry => entry.owner !== owner); + if (active) { + next.push({ + "owner": owner, + "focusWindow": focusWindow ?? null + }); + } + _entries = next; + } + + function unregister(owner) { + setActive(owner, false, null); + } + + function closeAll() { + if (_entries.length === 0) + return; + closeRequested(); + _entries = []; + } +}