From 6bee1b2c86f2560c0c4e3caa69e78039ac71c015 Mon Sep 17 00:00:00 2001 From: purian23 Date: Sat, 27 Jun 2026 22:47:38 -0400 Subject: [PATCH] feat(HoverMode): implement hover popout & launcher functionality in all modes - New Hover toggle found in DankBar Settings - New Hover to Reveal Launcher in FrameTab Settings --- core/.pre-commit-config.yaml | 2 +- core/go.mod | 2 +- core/internal/utils/math.go | 4 +- quickshell/Common/PopoutManager.qml | 157 ++- quickshell/Common/SettingsData.qml | 52 +- quickshell/Common/settings/SettingsSpec.js | 5 +- quickshell/DMSShell.qml | 6 + .../DankLauncherV2/DankLauncherV2Modal.qml | 1 + .../DankLauncherV2ModalConnected.qml | 37 + .../ControlCenter/ControlCenterPopout.qml | 1 + quickshell/Modules/DankBar/DankBarContent.qml | 609 ++++++------ .../DankBar/DankBarHoverController.qml | 938 ++++++++++++++++++ quickshell/Modules/DankBar/DankBarWindow.qml | 32 +- quickshell/Modules/DankBar/LeftSection.qml | 1 + quickshell/Modules/DankBar/RightSection.qml | 1 + .../Modules/DankBar/Widgets/SystemTrayBar.qml | 49 + .../Modules/Frame/FrameLauncherHoverZone.qml | 92 ++ quickshell/Modules/Notepad/Notepad.qml | 9 + .../Modules/Plugins/PluginComponent.qml | 18 + .../Modules/ProcessList/ProcessListPopout.qml | 13 + quickshell/Modules/Settings/DankBarTab.qml | 46 + quickshell/Modules/Settings/FrameTab.qml | 9 + quickshell/Modules/Settings/WallpaperTab.qml | 2 +- quickshell/Services/PopoutService.qml | 17 +- quickshell/Widgets/DankPopout.qml | 50 + quickshell/Widgets/DankPopoutConnected.qml | 241 ++++- quickshell/Widgets/DankPopoutStandalone.qml | 33 + quickshell/Widgets/DankSlideout.qml | 41 +- quickshell/Widgets/HoverDismissTracker.qml | 30 + quickshell/Widgets/PopoutHoverBodyTracker.qml | 25 + quickshell/Widgets/PopoutHoverDismiss.qml | 75 ++ .../translations/settings_search_index.json | 32 +- 32 files changed, 2266 insertions(+), 364 deletions(-) create mode 100644 quickshell/Modules/DankBar/DankBarHoverController.qml create mode 100644 quickshell/Modules/Frame/FrameLauncherHoverZone.qml create mode 100644 quickshell/Widgets/HoverDismissTracker.qml create mode 100644 quickshell/Widgets/PopoutHoverBodyTracker.qml create mode 100644 quickshell/Widgets/PopoutHoverDismiss.qml diff --git a/core/.pre-commit-config.yaml b/core/.pre-commit-config.yaml index a2b5ddaf..fa729b0a 100644 --- a/core/.pre-commit-config.yaml +++ b/core/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/golangci/golangci-lint - rev: v2.10.1 + rev: v2.12.2 hooks: - id: golangci-lint-fmt require_serial: true diff --git a/core/go.mod b/core/go.mod index 6b16688e..8195a8e3 100644 --- a/core/go.mod +++ b/core/go.mod @@ -22,7 +22,6 @@ require ( github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc go.etcd.io/bbolt v1.4.3 go4.org/mem v0.0.0-20240501181205-ae6ca9944745 - golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f golang.org/x/image v0.39.0 tailscale.com v1.96.5 ) @@ -64,6 +63,7 @@ require ( github.com/yeqown/reedsolomon v1.0.0 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect golang.org/x/crypto v0.50.0 // indirect + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/net v0.53.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.zx2c4.com/wireguard/windows v1.0.1 // indirect diff --git a/core/internal/utils/math.go b/core/internal/utils/math.go index 408af9f0..d468db3f 100644 --- a/core/internal/utils/math.go +++ b/core/internal/utils/math.go @@ -1,8 +1,8 @@ package utils -import "golang.org/x/exp/constraints" +import "cmp" -func Clamp[T constraints.Ordered](val, min, max T) T { +func Clamp[T cmp.Ordered](val, min, max T) T { if val < min { return min } diff --git a/quickshell/Common/PopoutManager.qml b/quickshell/Common/PopoutManager.qml index f7dcc49a..e8b546cd 100644 --- a/quickshell/Common/PopoutManager.qml +++ b/quickshell/Common/PopoutManager.qml @@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound import Quickshell import QtQuick +import qs.Common Singleton { id: root @@ -16,8 +17,76 @@ Singleton { signal popoutOpening signal popoutChanged + property real hoverCursorGlobalX: 0 + property real hoverCursorGlobalY: 0 + + function updateHoverCursor(gx, gy) { + hoverCursorGlobalX = gx; + hoverCursorGlobalY = gy; + } + + function cursorOverBar(gx, gy, padding, excludedWindow) { + const pad = padding !== undefined ? padding : 16; + const bars = KeyboardFocus.barWindows || []; + for (let i = 0; i < bars.length; i++) { + const w = bars[i]; + if (!w?.visible || w === excludedWindow) + continue; + if (typeof w.containsGlobalPoint === "function") { + if (w.containsGlobalPoint(gx, gy, pad)) + return true; + continue; + } + const item = w.contentItem; + if (!item || typeof item.mapToItem !== "function") + continue; + const topLeft = item.mapToItem(null, 0, 0); + if (!topLeft) + continue; + if (gx >= topLeft.x - pad && gx < topLeft.x + item.width + pad && gy >= topLeft.y - pad && gy < topLeft.y + item.height + pad) + return true; + } + return false; + } + + function _isPopoutPresented(popout) { + if (!popout) + return false; + try { + if (popout.dashVisible !== undefined) + return !!popout.dashVisible; + if (popout.notificationHistoryVisible !== undefined) + return !!popout.notificationHistoryVisible; + return !!(popout.shouldBeVisible || popout.isClosing); + } catch (e) { + return false; + } + } + + function _openPopout(popout) { + if (popout.dashVisible !== undefined) { + if (popout.dashVisible && !popout.shouldBeVisible && !popout.isClosing) + popout.dashVisible = false; + popout.dashVisible = true; + return; + } + if (popout.notificationHistoryVisible !== undefined) { + popout.notificationHistoryVisible = true; + return; + } + popout.open(); + } + function _closePopout(popout) { try { + if (popout?.hoverDismissEnabled) { + if (typeof popout.closeFromHoverDismiss === "function") { + popout.closeFromHoverDismiss(); + return; + } + } + if (popout.hoverDismissEnabled !== undefined) + popout.hoverDismissEnabled = false; switch (true) { case popout.dashVisible !== undefined: popout.dashVisible = false; @@ -89,7 +158,26 @@ Singleton { continue; _closePopout(popout); } - currentPopoutsByScreen = {}; + // Keep map entries until each popout's close animation finishes (hidePopout). + } + + function closePopoutForScreen(screen) { + if (!screen) + return; + const screenName = screen.name; + const popout = currentPopoutsByScreen[screenName]; + if (!popout || _isStale(popout)) { + currentPopoutsByScreen[screenName] = null; + currentPopoutTriggers[screenName] = null; + return; + } + _closePopout(popout); + } + + function cancelHoverDismiss(screen) { + const popout = getActivePopout(screen); + if (popout?.cancelHoverDismiss) + popout.cancelHoverDismiss(); } function getActivePopout(screen) { @@ -98,23 +186,37 @@ Singleton { return currentPopoutsByScreen[screen.name] || null; } + // Checks if the active popout is pinned for auto-dismissal + function isActivePopoutPinned(screen) { + const p = getActivePopout(screen); + if (!p || !_isPopoutPresented(p)) + return false; + return p.hoverDismissEnabled === false || p.hoverDismissSuspended === true; + } + function isCurrentPopout(popout, screenName) { const name = screenName || popout?.screen?.name || ""; return !!name && currentPopoutsByScreen[name] === popout; } - function requestPopout(popout, tabIndex, triggerSource) { + function _requestPopout(popout, tabIndex, triggerSource, hoverRequest) { if (!popout || !popout.screen) return; + + // Clicking a transient popout pins it instead of toggling it closed. + const wasTransient = popout.hoverDismissEnabled === true; + if (!hoverRequest && popout.hoverDismissEnabled !== undefined) + popout.hoverDismissEnabled = false; + screenshotActive = false; const screenName = popout.screen.name; const currentPopout = currentPopoutsByScreen[screenName]; const triggerId = triggerSource !== undefined ? triggerSource : tabIndex; + const alreadyPresented = currentPopout === popout && (hoverRequest ? _isPopoutPresented(popout) : popout.shouldBeVisible); - const willOpen = !(currentPopout === popout && popout.shouldBeVisible && triggerId !== undefined && currentPopoutTriggers[screenName] === triggerId); - if (willOpen) { + const willOpen = !(alreadyPresented && triggerId !== undefined && currentPopoutTriggers[screenName] === triggerId); + if (willOpen) popoutOpening(); - } let movedFromOtherScreen = false; for (const otherScreenName in currentPopoutsByScreen) { @@ -145,18 +247,26 @@ Singleton { currentPopoutsByScreen[screenName] = null; currentPopoutTriggers[screenName] = null; } else { + if (hoverRequest && typeof currentPopout.beginSupersededClose === "function") + currentPopout.beginSupersededClose(); _closePopout(currentPopout); } } - if (currentPopout === popout && popout.shouldBeVisible && !movedFromOtherScreen) { - if (triggerId !== undefined && currentPopoutTriggers[screenName] === triggerId) { - _closePopout(popout); + if (alreadyPresented && !movedFromOtherScreen) { + const sameDefinedTrigger = triggerId !== undefined && currentPopoutTriggers[screenName] === triggerId; + if (hoverRequest && sameDefinedTrigger) return; - } - if (triggerId === undefined) { - _closePopout(popout); + if (!hoverRequest && (triggerId === undefined || sameDefinedTrigger)) { + if (!wasTransient) { + _closePopout(popout); + return; + } + if (popout.updateSurfacePosition) + popout.updateSurfacePosition(); + if (triggerId !== undefined) + currentPopoutTriggers[screenName] = triggerId; return; } @@ -166,6 +276,8 @@ Singleton { if (popout.updateSurfacePosition) popout.updateSurfacePosition(); currentPopoutTriggers[screenName] = triggerId; + if (hoverRequest && popout.hoverDismissEnabled !== undefined) + popout.hoverDismissEnabled = true; return; } @@ -181,16 +293,17 @@ Singleton { ModalManager.closeAllModalsExcept(null); } - if (movedFromOtherScreen) { - popout.open(); - } else { - if (popout.dashVisible !== undefined) { - popout.dashVisible = true; - } else if (popout.notificationHistoryVisible !== undefined) { - popout.notificationHistoryVisible = true; - } else { - popout.open(); - } - } + if (hoverRequest && popout.hoverDismissEnabled !== undefined) + popout.hoverDismissEnabled = true; + + _openPopout(popout); + } + + function requestPopout(popout, tabIndex, triggerSource) { + _requestPopout(popout, tabIndex, triggerSource, false); + } + + function requestHoverPopout(popout, tabIndex, triggerSource) { + _requestPopout(popout, tabIndex, triggerSource, true); } } diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 40509645..5619f754 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -291,6 +291,8 @@ Singleton { onFrameLauncherEmergeSideChanged: saveSettings() property bool frameLauncherArcExtender: false onFrameLauncherArcExtenderChanged: saveSettings() + property bool frameLauncherEdgeHover: false + onFrameLauncherEdgeHoverChanged: saveSettings() readonly property string frameModalEmergeSide: frameLauncherEmergeSide === "top" ? "bottom" : "top" property string frameMode: "connected" onFrameModeChanged: saveSettings() @@ -603,9 +605,9 @@ Singleton { if (!on && id !== "settings" && current.filter(t => t.enabled && t.id !== "settings").length <= 1) return; dashTabs = current.map(t => t.id === id ? { - "id": t.id, - "enabled": on - } : t); + "id": t.id, + "enabled": on + } : t); } function resetDashTabs() { @@ -999,7 +1001,9 @@ Singleton { "shadowOpacity": 60, "shadowColorMode": "default", "shadowCustomColor": "#000000", - "clickThrough": false + "clickThrough": false, + "hoverPopouts": false, + "hoverPopoutDelay": 150 } ] @@ -2436,6 +2440,46 @@ Singleton { return barConfigs.filter(cfg => cfg.enabled); } + function _sideToPosition(side) { + switch (side) { + case "top": + return SettingsData.Position.Top; + case "bottom": + return SettingsData.Position.Bottom; + case "left": + return SettingsData.Position.Left; + case "right": + return SettingsData.Position.Right; + } + return -1; + } + + // Check if a bar occupies the specified screen edge + function barOccupiesSide(screen, side) { + if (!screen) + return false; + const sidePos = _sideToPosition(side); + if (sidePos < 0) + return false; + const bars = getEnabledBarConfigs(); + for (var i = 0; i < bars.length; i++) { + const bc = bars[i]; + if (bc.position !== sidePos) + continue; + const prefs = bc.screenPreferences || ["all"]; + if (prefs.includes("all") || isScreenInPreferences(screen, prefs)) + return true; + } + return false; + } + + // Check if the dock occupies the specified screen edge. + function dockOccupiesSide(side) { + if (!showDock) + return false; + return dockPosition === _sideToPosition(side); + } + function getScreensSortedByPosition() { const screens = []; for (var i = 0; i < Quickshell.screens.length; i++) { diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index f137e6f7..97570e2f 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -569,7 +569,9 @@ var SPEC = { shadowOpacity: 60, shadowColorMode: "default", shadowCustomColor: "#000000", - clickThrough: false + clickThrough: false, + hoverPopouts: false, + hoverPopoutDelay: 150 }], onChange: "updateBarConfigs" }, @@ -642,6 +644,7 @@ var SPEC = { frameCloseGaps: { def: true }, frameLauncherEmergeSide: { def: "bottom" }, frameLauncherArcExtender: { def: false }, + frameLauncherEdgeHover: { def: false }, frameMode: { def: "connected" }, barInsetPaddingShared: { def: -1 }, barInsetPaddingSyncAll: { def: false }, diff --git a/quickshell/DMSShell.qml b/quickshell/DMSShell.qml index 66289838..23bd64b0 100644 --- a/quickshell/DMSShell.qml +++ b/quickshell/DMSShell.qml @@ -233,6 +233,12 @@ Item { sourceComponent: Frame {} } + Loader { + active: SettingsData.frameEnabled && SettingsData.frameLauncherEdgeHover + asynchronous: false + sourceComponent: FrameLauncherHoverZone {} + } + DeferredAction { id: frameSurfaceReloadAction onTriggered: root.frameSurfacesLoaded = true diff --git a/quickshell/Modals/DankLauncherV2/DankLauncherV2Modal.qml b/quickshell/Modals/DankLauncherV2/DankLauncherV2Modal.qml index 890f6a00..f6476005 100644 --- a/quickshell/Modals/DankLauncherV2/DankLauncherV2Modal.qml +++ b/quickshell/Modals/DankLauncherV2/DankLauncherV2Modal.qml @@ -24,6 +24,7 @@ Item { readonly property string resolvedConnectedBarSide: impl.item ? (impl.item.resolvedConnectedBarSide ?? "") : "" readonly property bool launcherArcExtenderActive: impl.item ? (impl.item.launcherArcExtenderActive ?? false) : false property bool triggerUsesOverlayLayer: false + property bool edgeHoverManaged: false signal dialogClosed diff --git a/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalConnected.qml b/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalConnected.qml index 72c87023..8003f986 100644 --- a/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalConnected.qml +++ b/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalConnected.qml @@ -394,6 +394,8 @@ Item { closeCleanupTimer.stop(); isClosing = false; openedFromOverview = false; + _edgeArmed = false; + _edgeBodyHover = false; animationsEnabled = false; @@ -447,6 +449,9 @@ Item { keyboardActive = false; spotlightOpen = false; + _edgeRetractGrace.stop(); + _edgeArmed = false; + _edgeBodyHover = false; ModalManager.closeModal(modalHandle); closeCleanupTimer.start(); } @@ -489,6 +494,31 @@ Item { } } + // Handles hover dismissal grace periods for edge-hover sessions w/cursor + readonly property bool _edgeRetractEnabled: (modalHandle && modalHandle.edgeHoverManaged === true) && spotlightOpen && !isClosing + property bool _edgeBodyHover: false + property bool _edgeArmed: false + + Timer { + id: _edgeRetractGrace + interval: 150 + repeat: false + onTriggered: { + if (root._edgeRetractEnabled && root._edgeArmed && !root._edgeBodyHover) + root.hide(); + } + } + + function _onEdgeBodyHoverChanged(over) { + root._edgeBodyHover = over; + if (over) { + root._edgeArmed = true; + _edgeRetractGrace.stop(); + } else if (root._edgeRetractEnabled) { + _edgeRetractGrace.restart(); + } + } + Connections { target: spotlightContent?.controller ?? null function onModeChanged(mode, userInitiated) { @@ -628,6 +658,13 @@ Item { width: root.alignedWidth height: root.contentSurfaceHeight + // Passive tracker for edge-hover dismissal that preserves input events. + HoverHandler { + id: edgeBodyHoverHandler + enabled: root._edgeRetractEnabled + onHoveredChanged: root._onEdgeBodyHoverChanged(hovered) + } + MouseArea { anchors.fill: parent enabled: root.spotlightOpen diff --git a/quickshell/Modules/ControlCenter/ControlCenterPopout.qml b/quickshell/Modules/ControlCenter/ControlCenterPopout.qml index 5bff6aa0..9f7e8e03 100644 --- a/quickshell/Modules/ControlCenter/ControlCenterPopout.qml +++ b/quickshell/Modules/ControlCenter/ControlCenterPopout.qml @@ -98,6 +98,7 @@ DankPopout { property bool anyModalOpen: credentialsPromptOpen || wifiPasswordModalOpen || polkitModalOpen || powerMenuOpen backgroundInteractive: !anyModalOpen + hoverDismissSuspended: editMode || anyModalOpen onCredentialsPromptOpenChanged: { if (credentialsPromptOpen && shouldBeVisible) diff --git a/quickshell/Modules/DankBar/DankBarContent.qml b/quickshell/Modules/DankBar/DankBarContent.qml index c06b9e88..5da210ba 100644 --- a/quickshell/Modules/DankBar/DankBarContent.qml +++ b/quickshell/Modules/DankBar/DankBarContent.qml @@ -95,6 +95,14 @@ Item { enableFrameInsetAnimation.schedule(); } + Connections { + target: topBarContent._hasBarWindow ? topBarContent.barWindow.axis : null + + function onEdgeChanged() { + topBarContent.resetHoverForBarGeometryChange(); + } + } + Behavior on anchors.leftMargin { enabled: _animateFrameInsets && _usesFrameBarChrome NumberAnimation { @@ -380,6 +388,173 @@ Item { return "left"; } + DankBarHoverController { + id: hoverController + barContent: topBarContent + barWindow: topBarContent.barWindow + barConfig: topBarContent.barConfig + hLeftSection: topBarContent.hLeftSection + hCenterSection: topBarContent.hCenterSection + hRightSection: topBarContent.hRightSection + vLeftSection: topBarContent.vLeftSection + vCenterSection: topBarContent.vCenterSection + vRightSection: topBarContent.vRightSection + leftWidgetsModel: topBarContent.leftWidgetsModel + centerWidgetsModel: topBarContent.centerWidgetsModel + rightWidgetsModel: topBarContent.rightWidgetsModel + } + + readonly property string activeHoverTrigger: hoverController.activeHoverTrigger + readonly property bool hoverPopoutsEnabled: hoverController.hoverPopoutsEnabled + + function queueHoverPopout(gx, gy) { + hoverController.queueHoverPoint(gx, gy); + } + + function checkHoverPopout(gx, gy) { + hoverController.checkHoverPopout(gx, gy); + } + + function findWidgetAtGlobalPoint(gx, gy) { + return hoverController.findWidgetAtGlobalPoint(gx, gy); + } + + function scheduleHoverClose(gx, gy) { + hoverController.scheduleHoverClose(gx, gy); + } + + function updateHoverBarHovered(hovered) { + hoverController.updateBarHovered(hovered); + } + + function resetHoverForBarGeometryChange() { + hoverController.resetForBarGeometryChange(); + } + + function _dashTriggerSource(section, tabIndex) { + return hoverController.dashTriggerSource(section, tabIndex); + } + + function getBarPosition() { + return barWindow.axis?.edge === "left" ? 2 : (barWindow.axis?.edge === "right" ? 3 : (barWindow.axis?.edge === "top" ? 0 : 1)); + } + + function resolveWidgetTriggerGeometry(widgetItem, section, opts) { + opts = opts || {}; + if (opts.useCenterSection && section === "center") { + const centerSection = barWindow.isVertical ? vCenterSection : hCenterSection; + if (centerSection) { + if (barWindow.isVertical) { + const centerY = centerSection.height / 2; + return { + triggerPos: centerSection.mapToItem(null, 0, centerY), + triggerWidth: centerSection.height + }; + } + return { + triggerPos: centerSection.mapToItem(null, 0, 0), + triggerWidth: centerSection.width + }; + } + } + const ref = opts.visualItem || widgetItem.visualContent || widgetItem; + const w = opts.triggerWidth !== undefined ? opts.triggerWidth : (widgetItem.visualWidth !== undefined ? widgetItem.visualWidth : widgetItem.width); + return { + triggerPos: ref.mapToItem(null, 0, 0), + triggerWidth: w + }; + } + + function openWidgetPopout(spec) { + if (!spec?.loader) + return false; + spec.loader.active = true; + + let popout = _resolvePopoutFromLoader(spec.loader); + if (!popout) { + _queuePopoutLoaderOpen(spec); + return false; + } + return _finishWidgetPopoutOpen(spec, popout); + } + + function _resolvePopoutFromLoader(loader) { + if (!loader) + return null; + if (loader.item) + return loader.item; + + const pairs = [[PopoutService.appDrawerLoader, PopoutService.appDrawerPopout], [PopoutService.batteryPopoutLoader, PopoutService.batteryPopout], [PopoutService.clipboardHistoryPopoutLoader, PopoutService.clipboardHistoryPopout], [PopoutService.controlCenterLoader, PopoutService.controlCenterPopout], [PopoutService.dankDashPopoutLoader, PopoutService.dankDashPopout], [PopoutService.layoutPopoutLoader, PopoutService.layoutPopout], [PopoutService.notificationCenterLoader, PopoutService.notificationCenterPopout], [PopoutService.processListPopoutLoader, PopoutService.processListPopout], [PopoutService.systemUpdateLoader, PopoutService.systemUpdatePopout], [PopoutService.vpnPopoutLoader, PopoutService.vpnPopout]]; + for (let i = 0; i < pairs.length; i++) { + if (loader === pairs[i][0] && pairs[i][1]) + return pairs[i][1]; + } + return null; + } + + property var _pendingPopoutOpenSpec: null + + function _queuePopoutLoaderOpen(spec) { + if (_pendingPopoutOpenSpec && _pendingPopoutOpenSpec.loader === spec.loader) + return; + _pendingPopoutOpenSpec = spec; + const loader = spec.loader; + const onLoaded = function () { + if (!loader.item) + return; + if (loader.loaded) + loader.loaded.disconnect(onLoaded); + const pending = topBarContent._pendingPopoutOpenSpec; + if (!pending || pending.loader !== loader) + return; + topBarContent._pendingPopoutOpenSpec = null; + topBarContent._finishWidgetPopoutOpen(pending, loader.item); + if (pending.mode === "hover") + hoverController.recheckLatestPoint(); + }; + if (loader.item) { + onLoaded(); + return; + } + if (loader.loaded) + loader.loaded.connect(onLoaded); + } + + function _finishWidgetPopoutOpen(spec, popout) { + const effectiveBarConfig = barConfig; + const barPosition = getBarPosition(); + const widgetSection = spec.section || "right"; + const mode = spec.mode || "click"; + + if (popout.setBarContext) + popout.setBarContext(barPosition, effectiveBarConfig?.bottomGap ?? 0); + + if (spec.setTriggerScreen) + popout.triggerScreen = barWindow.screen; + + if (popout.setTriggerPosition && spec.widgetItem) { + const geom = resolveWidgetTriggerGeometry(spec.widgetItem, widgetSection, { + useCenterSection: spec.useCenterSection, + visualItem: spec.visualItem, + triggerWidth: spec.triggerWidth + }); + if (geom.triggerPos) { + const pos = SettingsData.getPopupTriggerPosition(geom.triggerPos, barWindow.screen, barWindow.effectiveBarThickness, geom.triggerWidth, effectiveBarConfig?.spacing ?? 4, barPosition, effectiveBarConfig); + popout.setTriggerPosition(pos.x, pos.y, pos.width, widgetSection, barWindow.screen, barPosition, barWindow.effectiveBarThickness, effectiveBarConfig?.spacing ?? 4, effectiveBarConfig); + } + } + + if (typeof popout.prepareForTrigger === "function") + popout.prepareForTrigger(spec.triggerSource, mode); + + if (spec.prepare) + spec.prepare(popout); + + const request = mode === "hover" ? PopoutManager.requestHoverPopout : PopoutManager.requestPopout; + request(popout, spec.tabIndex, spec.triggerSource); + return true; + } + readonly property var widgetVisibility: ({ "cpuUsage": DgopService.dgopAvailable, "memUsage": DgopService.dgopAvailable, @@ -702,27 +877,18 @@ Item { parentScreen: barWindow.screen popoutTarget: clipboardHistoryPopoutLoader.item ?? null - function openClipboardPopout(initialTab) { - clipboardHistoryPopoutLoader.active = true; - if (!clipboardHistoryPopoutLoader.item) { - return; - } - const popout = clipboardHistoryPopoutLoader.item; - const effectiveBarConfig = topBarContent.barConfig; - const barPosition = barWindow.axis?.edge === "left" ? 2 : (barWindow.axis?.edge === "right" ? 3 : (barWindow.axis?.edge === "top" ? 0 : 1)); - if (popout.setBarContext) { - popout.setBarContext(barPosition, effectiveBarConfig?.bottomGap ?? 0); - } - if (popout.setTriggerPosition) { - const globalPos = clipboardWidget.mapToItem(null, 0, 0); - const pos = SettingsData.getPopupTriggerPosition(globalPos, barWindow.screen, barWindow.effectiveBarThickness, clipboardWidget.width, effectiveBarConfig?.spacing ?? 4, barPosition, effectiveBarConfig); - const widgetSection = topBarContent.getWidgetSection(parent) || "right"; - popout.setTriggerPosition(pos.x, pos.y, pos.width, widgetSection, barWindow.screen, barPosition, barWindow.effectiveBarThickness, effectiveBarConfig?.spacing ?? 4, effectiveBarConfig); - } - if (initialTab) { - popout.activeTab = initialTab; - } - PopoutManager.requestPopout(popout, undefined, "clipboard"); + function openClipboardPopout(initialTab, mode) { + openWidgetPopout({ + loader: clipboardHistoryPopoutLoader, + widgetItem: clipboardWidget, + section: topBarContent.getWidgetSection(parent) || "right", + triggerSource: "clipboard", + mode: mode || "click", + prepare: popout => { + if (initialTab) + popout.activeTab = initialTab; + } + }); } onClipboardClicked: openClipboardPopout("recents") @@ -821,9 +987,14 @@ Item { } onClicked: { - if (!_preparePopout()) - return; - PopoutManager.requestPopout(appDrawerLoader.item, undefined, "appDrawer"); + topBarContent.openWidgetPopout({ + loader: appDrawerLoader, + widgetItem: launcherButton, + section: launcherButton.section, + triggerSource: "appDrawer", + mode: "click", + visualItem: launcherButton + }); } } } @@ -890,6 +1061,7 @@ Item { id: clockComponent Clock { + id: clockWidget axis: barWindow.axis compactMode: topBarContent.overlapping barThickness: barWindow.effectiveBarThickness @@ -909,43 +1081,17 @@ Item { } onClockClicked: { - dankDashPopoutLoader.active = true; - if (dankDashPopoutLoader.item) { - const effectiveBarConfig = topBarContent.barConfig; - const barPosition = barWindow.axis?.edge === "left" ? 2 : (barWindow.axis?.edge === "right" ? 3 : (barWindow.axis?.edge === "top" ? 0 : 1)); - if (dankDashPopoutLoader.item.setBarContext) { - dankDashPopoutLoader.item.setBarContext(barPosition, effectiveBarConfig?.bottomGap ?? 0); - } - if (dankDashPopoutLoader.item.setTriggerPosition) { - let triggerPos, triggerWidth; - if (section === "center") { - const centerSection = barWindow.isVertical ? (barWindow.axis?.edge === "left" ? vCenterSection : vCenterSection) : hCenterSection; - if (centerSection) { - if (barWindow.isVertical) { - const centerY = centerSection.height / 2; - const centerGlobalPos = centerSection.mapToItem(null, 0, centerY); - triggerPos = centerGlobalPos; - triggerWidth = centerSection.height; - } else { - const centerGlobalPos = centerSection.mapToItem(null, 0, 0); - triggerPos = centerGlobalPos; - triggerWidth = centerSection.width; - } - } else { - triggerPos = visualContent.mapToItem(null, 0, 0); - triggerWidth = visualWidth; - } - } else { - triggerPos = visualContent.mapToItem(null, 0, 0); - triggerWidth = visualWidth; - } - const pos = SettingsData.getPopupTriggerPosition(triggerPos, barWindow.screen, barWindow.effectiveBarThickness, triggerWidth, effectiveBarConfig?.spacing ?? 4, barPosition, effectiveBarConfig); - dankDashPopoutLoader.item.setTriggerPosition(pos.x, pos.y, pos.width, section, barWindow.screen, barPosition, barWindow.effectiveBarThickness, effectiveBarConfig?.spacing ?? 4, effectiveBarConfig); - } else { - dankDashPopoutLoader.item.triggerScreen = barWindow.screen; - } - PopoutManager.requestPopout(dankDashPopoutLoader.item, 0, (effectiveBarConfig?.id ?? "default") + "-" + section + "-0"); - } + const section = topBarContent.getWidgetSection(parent) || "center"; + topBarContent.openWidgetPopout({ + loader: dankDashPopoutLoader, + widgetItem: clockWidget, + section, + tabIndex: 0, + triggerSource: topBarContent._dashTriggerSource(section, 0), + mode: "click", + useCenterSection: true, + setTriggerScreen: true + }); } } } @@ -954,6 +1100,7 @@ Item { id: mediaComponent Media { + id: mediaWidget axis: barWindow.axis compactMode: topBarContent.spacingTight || topBarContent.overlapping barThickness: barWindow.effectiveBarThickness @@ -962,43 +1109,17 @@ Item { popoutTarget: dankDashPopoutLoader.item ?? null parentScreen: barWindow.screen onClicked: { - dankDashPopoutLoader.active = true; - if (dankDashPopoutLoader.item) { - const effectiveBarConfig = topBarContent.barConfig; - const barPosition = barWindow.axis?.edge === "left" ? 2 : (barWindow.axis?.edge === "right" ? 3 : (barWindow.axis?.edge === "top" ? 0 : 1)); - if (dankDashPopoutLoader.item.setBarContext) { - dankDashPopoutLoader.item.setBarContext(barPosition, effectiveBarConfig?.bottomGap ?? 0); - } - if (dankDashPopoutLoader.item.setTriggerPosition) { - let triggerPos, triggerWidth; - if (section === "center") { - const centerSection = barWindow.isVertical ? (barWindow.axis?.edge === "left" ? vCenterSection : vCenterSection) : hCenterSection; - if (centerSection) { - if (barWindow.isVertical) { - const centerY = centerSection.height / 2; - const centerGlobalPos = centerSection.mapToItem(null, 0, centerY); - triggerPos = centerGlobalPos; - triggerWidth = centerSection.height; - } else { - const centerGlobalPos = centerSection.mapToItem(null, 0, 0); - triggerPos = centerGlobalPos; - triggerWidth = centerSection.width; - } - } else { - triggerPos = visualContent.mapToItem(null, 0, 0); - triggerWidth = visualWidth; - } - } else { - triggerPos = visualContent.mapToItem(null, 0, 0); - triggerWidth = visualWidth; - } - const pos = SettingsData.getPopupTriggerPosition(triggerPos, barWindow.screen, barWindow.effectiveBarThickness, triggerWidth, effectiveBarConfig?.spacing ?? 4, barPosition, effectiveBarConfig); - dankDashPopoutLoader.item.setTriggerPosition(pos.x, pos.y, pos.width, section, barWindow.screen, barPosition, barWindow.effectiveBarThickness, effectiveBarConfig?.spacing ?? 4, effectiveBarConfig); - } else { - dankDashPopoutLoader.item.triggerScreen = barWindow.screen; - } - PopoutManager.requestPopout(dankDashPopoutLoader.item, 1, (effectiveBarConfig?.id ?? "default") + "-" + section + "-1"); - } + const section = topBarContent.getWidgetSection(parent) || "center"; + topBarContent.openWidgetPopout({ + loader: dankDashPopoutLoader, + widgetItem: mediaWidget, + section, + tabIndex: 1, + triggerSource: topBarContent._dashTriggerSource(section, 1), + mode: "click", + useCenterSection: true, + setTriggerScreen: true + }); } } } @@ -1007,6 +1128,7 @@ Item { id: weatherComponent Weather { + id: weatherWidget axis: barWindow.axis barThickness: barWindow.effectiveBarThickness widgetThickness: barWindow.widgetThickness @@ -1014,47 +1136,17 @@ Item { popoutTarget: dankDashPopoutLoader.item ?? null parentScreen: barWindow.screen onClicked: { - dankDashPopoutLoader.active = true; - if (dankDashPopoutLoader.item) { - const effectiveBarConfig = topBarContent.barConfig; - // Calculate barPosition from axis.edge - const barPosition = barWindow.axis?.edge === "left" ? 2 : (barWindow.axis?.edge === "right" ? 3 : (barWindow.axis?.edge === "top" ? 0 : 1)); - if (dankDashPopoutLoader.item.setBarContext) { - dankDashPopoutLoader.item.setBarContext(barPosition, effectiveBarConfig?.bottomGap ?? 0); - } - if (dankDashPopoutLoader.item.setTriggerPosition) { - // For center section widgets, use center section bounds for DankDash centering - let triggerPos, triggerWidth; - if (section === "center") { - const centerSection = barWindow.isVertical ? (barWindow.axis?.edge === "left" ? vCenterSection : vCenterSection) : hCenterSection; - if (centerSection) { - // For vertical bars, use center Y of section; for horizontal, use left edge - if (barWindow.isVertical) { - const centerY = centerSection.height / 2; - const centerGlobalPos = centerSection.mapToItem(null, 0, centerY); - triggerPos = centerGlobalPos; - triggerWidth = centerSection.height; - } else { - // For horizontal bars, use left edge (DankPopout will center it) - const centerGlobalPos = centerSection.mapToItem(null, 0, 0); - triggerPos = centerGlobalPos; - triggerWidth = centerSection.width; - } - } else { - triggerPos = visualContent.mapToItem(null, 0, 0); - triggerWidth = visualWidth; - } - } else { - triggerPos = visualContent.mapToItem(null, 0, 0); - triggerWidth = visualWidth; - } - const pos = SettingsData.getPopupTriggerPosition(triggerPos, barWindow.screen, barWindow.effectiveBarThickness, triggerWidth, effectiveBarConfig?.spacing ?? 4, barPosition, effectiveBarConfig); - dankDashPopoutLoader.item.setTriggerPosition(pos.x, pos.y, pos.width, section, barWindow.screen, barPosition, barWindow.effectiveBarThickness, effectiveBarConfig?.spacing ?? 4, effectiveBarConfig); - } else { - dankDashPopoutLoader.item.triggerScreen = barWindow.screen; - } - PopoutManager.requestPopout(dankDashPopoutLoader.item, 3, (effectiveBarConfig?.id ?? "default") + "-" + section + "-3"); - } + const section = topBarContent.getWidgetSection(parent) || "center"; + topBarContent.openWidgetPopout({ + loader: dankDashPopoutLoader, + widgetItem: weatherWidget, + section, + tabIndex: 3, + triggerSource: topBarContent._dashTriggerSource(section, 3), + mode: "click", + useCenterSection: true, + setTriggerScreen: true + }); } } } @@ -1100,22 +1192,13 @@ Item { parentScreen: barWindow.screen widgetData: parent.widgetData onCpuClicked: { - processListPopoutLoader.active = true; - if (!processListPopoutLoader.item) { - return; - } - const effectiveBarConfig = topBarContent.barConfig; - const barPosition = barWindow.axis?.edge === "left" ? 2 : (barWindow.axis?.edge === "right" ? 3 : (barWindow.axis?.edge === "top" ? 0 : 1)); - if (processListPopoutLoader.item.setBarContext) { - processListPopoutLoader.item.setBarContext(barPosition, effectiveBarConfig?.bottomGap ?? 0); - } - if (processListPopoutLoader.item.setTriggerPosition) { - const globalPos = cpuWidget.mapToItem(null, 0, 0); - const pos = SettingsData.getPopupTriggerPosition(globalPos, barWindow.screen, barWindow.effectiveBarThickness, cpuWidget.width, effectiveBarConfig?.spacing ?? 4, barPosition, effectiveBarConfig); - const widgetSection = topBarContent.getWidgetSection(parent) || "right"; - processListPopoutLoader.item.setTriggerPosition(pos.x, pos.y, pos.width, widgetSection, barWindow.screen, barPosition, barWindow.effectiveBarThickness, effectiveBarConfig?.spacing ?? 4, effectiveBarConfig); - } - PopoutManager.requestPopout(processListPopoutLoader.item, undefined, "cpu"); + topBarContent.openWidgetPopout({ + loader: processListPopoutLoader, + widgetItem: cpuWidget, + section: topBarContent.getWidgetSection(parent) || "right", + triggerSource: "cpu", + mode: "click" + }); } } } @@ -1133,22 +1216,13 @@ Item { parentScreen: barWindow.screen widgetData: parent.widgetData onRamClicked: { - processListPopoutLoader.active = true; - if (!processListPopoutLoader.item) { - return; - } - const effectiveBarConfig = topBarContent.barConfig; - const barPosition = barWindow.axis?.edge === "left" ? 2 : (barWindow.axis?.edge === "right" ? 3 : (barWindow.axis?.edge === "top" ? 0 : 1)); - if (processListPopoutLoader.item.setBarContext) { - processListPopoutLoader.item.setBarContext(barPosition, effectiveBarConfig?.bottomGap ?? 0); - } - if (processListPopoutLoader.item.setTriggerPosition) { - const globalPos = ramWidget.mapToItem(null, 0, 0); - const pos = SettingsData.getPopupTriggerPosition(globalPos, barWindow.screen, barWindow.effectiveBarThickness, ramWidget.width, effectiveBarConfig?.spacing ?? 4, barPosition, effectiveBarConfig); - const widgetSection = topBarContent.getWidgetSection(parent) || "right"; - processListPopoutLoader.item.setTriggerPosition(pos.x, pos.y, pos.width, widgetSection, barWindow.screen, barPosition, barWindow.effectiveBarThickness, effectiveBarConfig?.spacing ?? 4, effectiveBarConfig); - } - PopoutManager.requestPopout(processListPopoutLoader.item, undefined, "memory"); + topBarContent.openWidgetPopout({ + loader: processListPopoutLoader, + widgetItem: ramWidget, + section: topBarContent.getWidgetSection(parent) || "right", + triggerSource: "memory", + mode: "click" + }); } } } @@ -1180,22 +1254,13 @@ Item { parentScreen: barWindow.screen widgetData: parent.widgetData onCpuTempClicked: { - processListPopoutLoader.active = true; - if (!processListPopoutLoader.item) { - return; - } - const effectiveBarConfig = topBarContent.barConfig; - const barPosition = barWindow.axis?.edge === "left" ? 2 : (barWindow.axis?.edge === "right" ? 3 : (barWindow.axis?.edge === "top" ? 0 : 1)); - if (processListPopoutLoader.item.setBarContext) { - processListPopoutLoader.item.setBarContext(barPosition, effectiveBarConfig?.bottomGap ?? 0); - } - if (processListPopoutLoader.item.setTriggerPosition) { - const globalPos = cpuTempWidget.mapToItem(null, 0, 0); - const pos = SettingsData.getPopupTriggerPosition(globalPos, barWindow.screen, barWindow.effectiveBarThickness, cpuTempWidget.width, effectiveBarConfig?.spacing ?? 4, barPosition, effectiveBarConfig); - const widgetSection = topBarContent.getWidgetSection(parent) || "right"; - processListPopoutLoader.item.setTriggerPosition(pos.x, pos.y, pos.width, widgetSection, barWindow.screen, barPosition, barWindow.effectiveBarThickness, effectiveBarConfig?.spacing ?? 4, effectiveBarConfig); - } - PopoutManager.requestPopout(processListPopoutLoader.item, undefined, "cpu_temp"); + topBarContent.openWidgetPopout({ + loader: processListPopoutLoader, + widgetItem: cpuTempWidget, + section: topBarContent.getWidgetSection(parent) || "right", + triggerSource: "cpu_temp", + mode: "click" + }); } } } @@ -1213,22 +1278,13 @@ Item { parentScreen: barWindow.screen widgetData: parent.widgetData onGpuTempClicked: { - processListPopoutLoader.active = true; - if (!processListPopoutLoader.item) { - return; - } - const effectiveBarConfig = topBarContent.barConfig; - const barPosition = barWindow.axis?.edge === "left" ? 2 : (barWindow.axis?.edge === "right" ? 3 : (barWindow.axis?.edge === "top" ? 0 : 1)); - if (processListPopoutLoader.item.setBarContext) { - processListPopoutLoader.item.setBarContext(barPosition, effectiveBarConfig?.bottomGap ?? 0); - } - if (processListPopoutLoader.item.setTriggerPosition) { - const globalPos = gpuTempWidget.mapToItem(null, 0, 0); - const pos = SettingsData.getPopupTriggerPosition(globalPos, barWindow.screen, barWindow.effectiveBarThickness, gpuTempWidget.width, effectiveBarConfig?.spacing ?? 4, barPosition, effectiveBarConfig); - const widgetSection = topBarContent.getWidgetSection(parent) || "right"; - processListPopoutLoader.item.setTriggerPosition(pos.x, pos.y, pos.width, widgetSection, barWindow.screen, barPosition, barWindow.effectiveBarThickness, effectiveBarConfig?.spacing ?? 4, effectiveBarConfig); - } - PopoutManager.requestPopout(processListPopoutLoader.item, undefined, "gpu_temp"); + topBarContent.openWidgetPopout({ + loader: processListPopoutLoader, + widgetItem: gpuTempWidget, + section: topBarContent.getWidgetSection(parent) || "right", + triggerSource: "gpu_temp", + mode: "click" + }); } } } @@ -1253,23 +1309,14 @@ Item { popoutTarget: notificationCenterLoader.item ?? null parentScreen: barWindow.screen onClicked: { - notificationCenterLoader.active = true; - if (!notificationCenterLoader.item) { - return; - } - notificationCenterLoader.item.triggerScreen = barWindow.screen; - const effectiveBarConfig = topBarContent.barConfig; - const barPosition = barWindow.axis?.edge === "left" ? 2 : (barWindow.axis?.edge === "right" ? 3 : (barWindow.axis?.edge === "top" ? 0 : 1)); - if (notificationCenterLoader.item.setBarContext) { - notificationCenterLoader.item.setBarContext(barPosition, effectiveBarConfig?.bottomGap ?? 0); - } - if (notificationCenterLoader.item.setTriggerPosition) { - const globalPos = notificationButton.mapToItem(null, 0, 0); - const pos = SettingsData.getPopupTriggerPosition(globalPos, barWindow.screen, barWindow.effectiveBarThickness, notificationButton.width, effectiveBarConfig?.spacing ?? 4, barPosition, effectiveBarConfig); - const widgetSection = topBarContent.getWidgetSection(parent) || "right"; - notificationCenterLoader.item.setTriggerPosition(pos.x, pos.y, pos.width, widgetSection, barWindow.screen, barPosition, barWindow.effectiveBarThickness, effectiveBarConfig?.spacing ?? 4, effectiveBarConfig); - } - PopoutManager.requestPopout(notificationCenterLoader.item, undefined, "notifications"); + topBarContent.openWidgetPopout({ + loader: notificationCenterLoader, + widgetItem: notificationButton, + section: topBarContent.getWidgetSection(parent) || "right", + triggerSource: "notifications", + mode: "click", + setTriggerScreen: true + }); } } } @@ -1289,22 +1336,13 @@ Item { popoutTarget: batteryPopoutLoader.item ?? null parentScreen: barWindow.screen onToggleBatteryPopup: { - batteryPopoutLoader.active = true; - if (!batteryPopoutLoader.item) { - return; - } - const effectiveBarConfig = topBarContent.barConfig; - const barPosition = barWindow.axis?.edge === "left" ? 2 : (barWindow.axis?.edge === "right" ? 3 : (barWindow.axis?.edge === "top" ? 0 : 1)); - if (batteryPopoutLoader.item.setBarContext) { - batteryPopoutLoader.item.setBarContext(barPosition, effectiveBarConfig?.bottomGap ?? 0); - } - if (batteryPopoutLoader.item.setTriggerPosition) { - const globalPos = batteryWidget.mapToItem(null, 0, 0); - const pos = SettingsData.getPopupTriggerPosition(globalPos, barWindow.screen, barWindow.effectiveBarThickness, batteryWidget.width, effectiveBarConfig?.spacing ?? 4, barPosition, effectiveBarConfig); - const widgetSection = topBarContent.getWidgetSection(parent) || "right"; - batteryPopoutLoader.item.setTriggerPosition(pos.x, pos.y, pos.width, widgetSection, barWindow.screen, barPosition, barWindow.effectiveBarThickness, effectiveBarConfig?.spacing ?? 4, effectiveBarConfig); - } - PopoutManager.requestPopout(batteryPopoutLoader.item, undefined, "battery"); + topBarContent.openWidgetPopout({ + loader: batteryPopoutLoader, + widgetItem: batteryWidget, + section: topBarContent.getWidgetSection(parent) || "right", + triggerSource: "battery", + mode: "click" + }); } } } @@ -1322,20 +1360,13 @@ Item { popoutTarget: layoutPopoutLoader.item ?? null parentScreen: barWindow.screen onToggleLayoutPopup: { - layoutPopoutLoader.active = true; - if (!layoutPopoutLoader.item) - return; - const effectiveBarConfig = topBarContent.barConfig; - const barPosition = barWindow.axis?.edge === "left" ? 2 : (barWindow.axis?.edge === "right" ? 3 : (barWindow.axis?.edge === "top" ? 0 : 1)); - - if (layoutPopoutLoader.item.setTriggerPosition) { - const globalPos = layoutWidget.mapToItem(null, 0, 0); - const pos = SettingsData.getPopupTriggerPosition(globalPos, barWindow.screen, barWindow.effectiveBarThickness, layoutWidget.width, effectiveBarConfig?.spacing ?? 4, barPosition, effectiveBarConfig); - const widgetSection = topBarContent.getWidgetSection(parent) || "center"; - layoutPopoutLoader.item.setTriggerPosition(pos.x, pos.y, pos.width, widgetSection, barWindow.screen, barPosition, barWindow.effectiveBarThickness, effectiveBarConfig?.spacing ?? 4, effectiveBarConfig); - } - - PopoutManager.requestPopout(layoutPopoutLoader.item, undefined, "layout"); + topBarContent.openWidgetPopout({ + loader: layoutPopoutLoader, + widgetItem: layoutWidget, + section: topBarContent.getWidgetSection(parent) || "center", + triggerSource: "layout", + mode: "click" + }); } } } @@ -1355,24 +1386,13 @@ Item { popoutTarget: vpnPopoutLoader.item ?? null parentScreen: barWindow.screen onToggleVpnPopup: { - vpnPopoutLoader.active = true; - if (!vpnPopoutLoader.item) - return; - const effectiveBarConfig = topBarContent.barConfig; - const barPosition = barWindow.axis?.edge === "left" ? 2 : (barWindow.axis?.edge === "right" ? 3 : (barWindow.axis?.edge === "top" ? 0 : 1)); - - if (vpnPopoutLoader.item.setBarContext) { - vpnPopoutLoader.item.setBarContext(barPosition, effectiveBarConfig?.bottomGap ?? 0); - } - - if (vpnPopoutLoader.item.setTriggerPosition) { - const globalPos = vpnWidget.mapToItem(null, 0, 0); - const pos = SettingsData.getPopupTriggerPosition(globalPos, barWindow.screen, barWindow.effectiveBarThickness, vpnWidget.width, effectiveBarConfig?.spacing ?? 4, barPosition, effectiveBarConfig); - const widgetSection = topBarContent.getWidgetSection(parent) || "right"; - vpnPopoutLoader.item.setTriggerPosition(pos.x, pos.y, pos.width, widgetSection, barWindow.screen, barPosition, barWindow.effectiveBarThickness, effectiveBarConfig?.spacing ?? 4, effectiveBarConfig); - } - - PopoutManager.requestPopout(vpnPopoutLoader.item, undefined, "vpn"); + topBarContent.openWidgetPopout({ + loader: vpnPopoutLoader, + widgetItem: vpnWidget, + section: topBarContent.getWidgetSection(parent) || "right", + triggerSource: "vpn", + mode: "click" + }); } } } @@ -1381,6 +1401,7 @@ Item { id: controlCenterButtonComponent ControlCenterButton { + id: controlCenterButton isActive: controlCenterLoader.item ? controlCenterLoader.item.shouldBeVisible : false widgetThickness: barWindow.widgetThickness barThickness: barWindow.effectiveBarThickness @@ -1403,25 +1424,16 @@ Item { } onClicked: { - controlCenterLoader.active = true; - if (!controlCenterLoader.item) { - return; - } - controlCenterLoader.item.triggerScreen = barWindow.screen; - if (controlCenterLoader.item.setTriggerPosition) { - const globalPos = mapToItem(null, 0, 0); - // Use topBarContent.barConfig directly - const effectiveBarConfig = topBarContent.barConfig; - // Calculate barPosition from axis.edge like Battery widget does - const barPosition = barWindow.axis?.edge === "left" ? 2 : (barWindow.axis?.edge === "right" ? 3 : (barWindow.axis?.edge === "top" ? 0 : 1)); - const pos = SettingsData.getPopupTriggerPosition(globalPos, barWindow.screen, barWindow.effectiveBarThickness, width, effectiveBarConfig?.spacing ?? 4, barPosition, effectiveBarConfig); - const section = topBarContent.getWidgetSection(parent) || "right"; - controlCenterLoader.item.setTriggerPosition(pos.x, pos.y, pos.width, section, barWindow.screen, barPosition, barWindow.effectiveBarThickness, effectiveBarConfig?.spacing ?? 4, effectiveBarConfig); - } - PopoutManager.requestPopout(controlCenterLoader.item, undefined, "controlCenter"); - if (controlCenterLoader.item.shouldBeVisible && NetworkService.wifiEnabled) { + topBarContent.openWidgetPopout({ + loader: controlCenterLoader, + widgetItem: controlCenterButton, + section: topBarContent.getWidgetSection(parent) || "right", + triggerSource: "controlCenter", + mode: "click", + setTriggerScreen: true + }); + if (controlCenterLoader.item?.shouldBeVisible && NetworkService.wifiEnabled) NetworkService.scanWifi(); - } } } } @@ -1531,6 +1543,7 @@ Item { id: systemUpdateComponent SystemUpdate { + id: systemUpdateWidget isActive: systemUpdateLoader.item ? systemUpdateLoader.item.shouldBeVisible : false widgetThickness: barWindow.widgetThickness barThickness: barWindow.effectiveBarThickness @@ -1549,22 +1562,14 @@ Item { } onClicked: { - systemUpdateLoader.active = true; - if (!systemUpdateLoader.item) - return; - const popout = systemUpdateLoader.item; - const effectiveBarConfig = topBarContent.barConfig; - const barPosition = barWindow.axis?.edge === "left" ? 2 : (barWindow.axis?.edge === "right" ? 3 : (barWindow.axis?.edge === "top" ? 0 : 1)); - if (popout.setBarContext) { - popout.setBarContext(barPosition, effectiveBarConfig?.bottomGap ?? 0); - } - if (popout.setTriggerPosition) { - const globalPos = visualContent.mapToItem(null, 0, 0); - const currentScreen = parentScreen || Screen; - const pos = SettingsData.getPopupTriggerPosition(globalPos, currentScreen, barWindow.effectiveBarThickness, visualWidth, effectiveBarConfig?.spacing ?? 4, barPosition, effectiveBarConfig); - popout.setTriggerPosition(pos.x, pos.y, pos.width, section, currentScreen, barPosition, barWindow.effectiveBarThickness, effectiveBarConfig?.spacing ?? 4, effectiveBarConfig); - } - PopoutManager.requestPopout(popout, undefined, "systemUpdate"); + topBarContent.openWidgetPopout({ + loader: systemUpdateLoader, + widgetItem: systemUpdateWidget, + section: topBarContent.getWidgetSection(parent) || "right", + triggerSource: "systemUpdate", + mode: "click", + visualItem: systemUpdateWidget + }); } } } diff --git a/quickshell/Modules/DankBar/DankBarHoverController.qml b/quickshell/Modules/DankBar/DankBarHoverController.qml new file mode 100644 index 00000000..27aaa5db --- /dev/null +++ b/quickshell/Modules/DankBar/DankBarHoverController.qml @@ -0,0 +1,938 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Services + +Item { + id: root + + required property var barContent + required property var barWindow + required property var barConfig + required property var hLeftSection + required property var hCenterSection + required property var hRightSection + required property var vLeftSection + required property var vCenterSection + required property var vRightSection + + property var leftWidgetsModel + property var centerWidgetsModel + property var rightWidgetsModel + + property string activeHoverTrigger: "" + readonly property bool hoverPopoutsEnabled: barConfig?.hoverPopouts ?? false + readonly property int hoverPopoutDelay: Math.max(0, barConfig?.hoverPopoutDelay ?? 150) + + property real _lastHoverGlobalX: 0 + property real _lastHoverGlobalY: 0 + property bool _hitTestPending: false + property bool _barHovered: false + property bool _barExitPending: false + property var _pendingHoverHit: null + property string _pendingHoverTrigger: "" + + property bool _candidateCacheValid: false + property var _candidateCache: [] + property var _candidateWatchers: [] + property bool _lastLookupWasMiss: false + + width: 0 + height: 0 + + onLeftWidgetsModelChanged: invalidateCandidateCache() + onCenterWidgetsModelChanged: invalidateCandidateCache() + onRightWidgetsModelChanged: invalidateCandidateCache() + + onHoverPopoutsEnabledChanged: { + if (hoverPopoutsEnabled) + return; + cancelQueuedHitTest(); + _cancelPendingHover(); + _hoverCloseTimer.stop(); + if (hasOpenHoverSurface() && !isActiveHoverSurfacePinned()) + closeHoverSurfaces(); + activeHoverTrigger = ""; + } + + Component.onDestruction: _disconnectCandidateWatchers() + + Connections { + target: root.barContent + + function onWidthChanged() { + root.invalidateCandidateCache(); + } + + function onHeightChanged() { + root.invalidateCandidateCache(); + } + } + + Connections { + target: root.barWindow + + function onScreenChanged() { + root.invalidateCandidateCache(); + } + } + + Connections { + target: BarWidgetService + + function onWidgetRegistered(_widgetId, screenName) { + if (screenName === root.barWindow?.screen?.name) + root.invalidateCandidateCache(); + } + + function onWidgetUnregistered(_widgetId, screenName) { + if (screenName === root.barWindow?.screen?.name) + root.invalidateCandidateCache(); + } + } + + FrameAnimation { + running: root._hitTestPending + onTriggered: { + root._hitTestPending = false; + root.checkHoverPopout(root._lastHoverGlobalX, root._lastHoverGlobalY); + } + } + + Timer { + id: _hoverIntentTimer + interval: root.hoverPopoutDelay + repeat: false + onTriggered: root._commitPendingHover() + } + + // Grace timer to prevent flicker when crossing gaps. + Timer { + id: _hoverCloseTimer + interval: 120 + repeat: false + onTriggered: root._commitHoverClose() + } + + function queueHoverPoint(gx, gy) { + _lastHoverGlobalX = gx; + _lastHoverGlobalY = gy; + _barHovered = true; + _barExitPending = false; + PopoutManager.updateHoverCursor(gx, gy); + if (hoverPopoutsEnabled) + _hitTestPending = true; + } + + function updateBarHovered(hovered) { + _barHovered = hovered; + if (hovered) { + _barExitPending = false; + _hoverCloseTimer.stop(); + return; + } + + cancelQueuedHitTest(); + _cancelPendingHover(); + if (!hoverPopoutsEnabled || isActiveHoverSurfacePinned()) + return; + _barExitPending = true; + _hoverCloseTimer.restart(); + } + + function cancelQueuedHitTest() { + _hitTestPending = false; + } + + function recheckLatestPoint() { + checkHoverPopout(_lastHoverGlobalX, _lastHoverGlobalY); + } + + function resetForBarGeometryChange() { + invalidateCandidateCache(); + cancelQueuedHitTest(); + _cancelPendingHover(); + _hoverCloseTimer.stop(); + barContent._pendingPopoutOpenSpec = null; + + const activePopout = PopoutManager.getActivePopout(barWindow?.screen); + const hasTransientSurface = activeHoverTrigger !== "" || activePopout?.hoverDismissEnabled === true; + if (hasTransientSurface && !isActiveHoverSurfacePinned()) + closeHoverSurfaces(); + else + activeHoverTrigger = ""; + } + + function invalidateCandidateCache() { + _candidateCacheValid = false; + _candidateCache = []; + _lastLookupWasMiss = false; + _disconnectCandidateWatchers(); + } + + function _disconnectCandidateWatchers() { + const watchers = _candidateWatchers; + _candidateWatchers = []; + for (let i = 0; i < watchers.length; i++) { + const watcher = watchers[i]; + try { + const signal = watcher.object?.[watcher.signalName]; + if (signal && typeof signal.disconnect === "function") + signal.disconnect(watcher.callback); + } catch (e) {} + } + } + + function _watchCandidateObject(object) { + if (!object) + return; + for (let i = 0; i < _candidateWatchers.length; i++) { + if (_candidateWatchers[i].object === object) + return; + } + + const signalNames = ["xChanged", "yChanged", "widthChanged", "heightChanged", "visibleChanged", "parentChanged", "childrenChanged", "itemChanged", "activeChanged", "destroyed"]; + for (let i = 0; i < signalNames.length; i++) { + const signalName = signalNames[i]; + try { + const signal = object[signalName]; + if (!signal || typeof signal.connect !== "function") + continue; + const callback = function () { + root.invalidateCandidateCache(); + }; + signal.connect(callback); + _candidateWatchers.push({ + object, + signalName, + callback + }); + } catch (e) {} + } + } + + function _getBarSections() { + if (barWindow.isVertical) { + return [ + { + section: vLeftSection, + name: "left" + }, + { + section: vCenterSection, + name: "center" + }, + { + section: vRightSection, + name: "right" + } + ]; + } + return [ + { + section: hLeftSection, + name: "left" + }, + { + section: hCenterSection, + name: "center" + }, + { + section: hRightSection, + name: "right" + } + ]; + } + + // The widget registry is keyed by (widgetId, screenName) + function _itemBelongsToThisBar(item) { + const owner = barContent; + if (!owner || !item) + return true; + let node = item; + let guard = 0; + while (node && guard < 100) { + if (node === owner) + return true; + node = node.parent; + guard++; + } + return false; + } + + function _findWidgetHostInWrapper(wrapper) { + if (wrapper.widgetId !== undefined) + return wrapper; + const children = wrapper.children || []; + for (let i = 0; i < children.length; i++) { + if (children[i].widgetId !== undefined) + return children[i]; + } + return null; + } + + function _collectSectionWrappers(section) { + _watchCandidateObject(section); + const layoutLoader = section.widgetLayoutLoader; + _watchCandidateObject(layoutLoader); + const layout = layoutLoader?.item; + if (layout) { + _watchCandidateObject(layout); + return layout.children || []; + } + const children = section.children || []; + const wrappers = []; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (!child || child === layoutLoader) + continue; + if (child.itemData !== undefined || child.widgetId !== undefined || _findWidgetHostInWrapper(child)) + wrappers.push(child); + } + return wrappers; + } + + function _widgetSupportsHoverPopout(widgetId, widgetItem) { + if (!widgetId || !widgetItem) + return false; + if (typeof widgetItem.triggerHoverPopout === "function") + return true; + if (widgetId === "systemTray" && typeof widgetItem.openHoverAtGlobalPoint === "function") + return true; + switch (widgetId) { + case "launcherButton": + case "clipboard": + case "clock": + case "music": + case "weather": + case "cpuUsage": + case "memUsage": + case "cpuTemp": + case "gpuTemp": + case "notificationButton": + case "battery": + case "layout": + case "vpn": + case "controlCenterButton": + case "systemUpdate": + case "notepadButton": + case "systemTray": + return true; + default: + return false; + } + } + + function _enumerateWidgetHosts() { + const hosts = []; + const sections = _getBarSections(); + for (let s = 0; s < sections.length; s++) { + const sectionEntry = sections[s]; + const section = sectionEntry.section; + if (!section) + continue; + const wrappers = _collectSectionWrappers(section); + for (let i = 0; i < wrappers.length; i++) { + const wrapper = wrappers[i]; + const host = _findWidgetHostInWrapper(wrapper); + if (!host?.widgetId) + continue; + _watchCandidateObject(wrapper); + _watchCandidateObject(host); + hosts.push({ + host, + wrapper, + section: sectionEntry.name + }); + } + } + return hosts; + } + + function _collectHoverCandidates() { + const screenName = barWindow.screen?.name; + const candidates = []; + const seen = new Set(); + + function addCandidate(widgetId, widgetItem, sectionHint) { + if (!widgetId || !widgetItem || seen.has(widgetItem)) + return; + if (!root._itemBelongsToThisBar(widgetItem)) + return; + if (!root._widgetSupportsHoverPopout(widgetId, widgetItem)) + return; + if (!root.barContent.getWidgetVisible(widgetId)) + return; + seen.add(widgetItem); + candidates.push({ + widgetId, + widgetItem, + section: widgetItem.section || sectionHint || "right", + wrapper: null, + host: null + }); + } + + if (screenName) { + const registry = BarWidgetService.widgetRegistry; + if (registry && typeof registry === "object") { + for (const widgetId in registry) { + const screenMap = registry[widgetId]; + if (!screenMap || typeof screenMap !== "object") + continue; + const widgetItem = screenMap[screenName]; + if (widgetItem) + addCandidate(widgetId, widgetItem, widgetItem.section); + } + } + } + + const hosts = _enumerateWidgetHosts(); + for (let i = 0; i < hosts.length; i++) { + const entry = hosts[i]; + if (!entry.host?.item) + continue; + const existing = candidates.find(candidate => candidate.widgetItem === entry.host.item); + if (existing) { + existing.wrapper = entry.wrapper; + existing.host = entry.host; + if (!existing.section) + existing.section = entry.section; + continue; + } + if (!_widgetSupportsHoverPopout(entry.host.widgetId, entry.host.item)) + continue; + candidates.push({ + widgetId: entry.host.widgetId, + widgetItem: entry.host.item, + section: entry.host.item.section || entry.section, + wrapper: entry.wrapper, + host: entry.host + }); + } + + return candidates; + } + + function _globalItemBounds(item) { + try { + const topLeft = item.mapToItem(null, 0, 0); + return { + x: topLeft.x, + y: topLeft.y, + width: item.width, + height: item.height + }; + } catch (e) { + return null; + } + } + + function _hitBoundsForWidget(widgetItem, wrapper) { + try { + if (!widgetItem?.visible) + return null; + + if (widgetItem.visualContent !== undefined) { + const visual = widgetItem.visualContent; + if (visual && visual.width > 0 && visual.height > 0) + return _globalItemBounds(visual); + } + + if (widgetItem.width > 0 && widgetItem.height > 0) + return _globalItemBounds(widgetItem); + + if (wrapper && wrapper.width > 0 && wrapper.height > 0) + return _globalItemBounds(wrapper); + } catch (e) {} + return null; + } + + function _pointInBounds(gx, gy, bounds) { + return gx >= bounds.x && gx < bounds.x + bounds.width && gy >= bounds.y && gy < bounds.y + bounds.height; + } + + function _sameBounds(a, b) { + return !!a && !!b && a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; + } + + function _buildCandidateCache() { + _disconnectCandidateWatchers(); + const candidates = _collectHoverCandidates(); + const cache = []; + for (let i = 0; i < candidates.length; i++) { + const entry = candidates[i]; + const bounds = _hitBoundsForWidget(entry.widgetItem, entry.wrapper); + _watchCandidateObject(entry.widgetItem); + _watchCandidateObject(entry.wrapper); + _watchCandidateObject(entry.host); + try { + _watchCandidateObject(entry.widgetItem?.visualContent); + } catch (e) {} + if (!bounds || bounds.width <= 0 || bounds.height <= 0) + continue; + cache.push({ + widgetId: entry.widgetId, + widgetItem: entry.widgetItem, + section: entry.section, + wrapper: entry.wrapper, + bounds + }); + } + _candidateCache = cache; + _candidateCacheValid = true; + _lastLookupWasMiss = false; + } + + function _scanCandidateCache(gx, gy) { + let best = null; + let bestArea = Infinity; + for (let i = 0; i < _candidateCache.length; i++) { + const entry = _candidateCache[i]; + const bounds = entry.bounds; + if (!_pointInBounds(gx, gy, bounds)) + continue; + const area = bounds.width * bounds.height; + if (area < bestArea) { + bestArea = area; + best = entry; + } + } + return best; + } + + function _validatedHit(entry, gx, gy) { + if (!entry) + return null; + const liveBounds = _hitBoundsForWidget(entry.widgetItem, entry.wrapper); + if (!liveBounds || !_pointInBounds(gx, gy, liveBounds)) + return null; + if (!_sameBounds(entry.bounds, liveBounds)) + return null; + return { + widgetId: entry.widgetId, + widgetItem: entry.widgetItem, + section: entry.section + }; + } + + function findWidgetAtGlobalPoint(gx, gy) { + if (!_candidateCacheValid) + _buildCandidateCache(); + + let entry = _scanCandidateCache(gx, gy); + let hit = _validatedHit(entry, gx, gy); + if (entry && !hit) { + invalidateCandidateCache(); + _buildCandidateCache(); + entry = _scanCandidateCache(gx, gy); + hit = _validatedHit(entry, gx, gy); + } else if (!entry && !_lastLookupWasMiss) { + // One live rebuild on entry into an empty gap covers layout changes whose + // source did not expose a QML geometry signal without rescanning every frame. + invalidateCandidateCache(); + _buildCandidateCache(); + entry = _scanCandidateCache(gx, gy); + hit = _validatedHit(entry, gx, gy); + } + + _lastLookupWasMiss = !hit; + return hit; + } + + function dashTriggerSource(section, tabIndex) { + return (barConfig?.id ?? "default") + "-" + section + "-" + tabIndex; + } + + function _notepadWidgetForScreen() { + // Prefer this bar's own enumerated candidates; the registry is screen-keyed and a + // sibling bar on the same screen can shadow it. + if (!_candidateCacheValid) + _buildCandidateCache(); + for (let i = 0; i < _candidateCache.length; i++) { + if (_candidateCache[i].widgetId === "notepadButton") + return _candidateCache[i].widgetItem; + } + const screenName = barWindow?.screen?.name; + const fromRegistry = screenName ? BarWidgetService.getWidget("notepadButton", screenName) : null; + if (fromRegistry && _itemBelongsToThisBar(fromRegistry)) + return fromRegistry; + return null; + } + + function notepadContainsGlobalPoint(gx, gy) { + const instance = _notepadWidgetForScreen()?.notepadInstance; + if (!instance?.isVisible || typeof instance.containsGlobalPoint !== "function") + return false; + return instance.containsGlobalPoint(gx, gy); + } + + function isActiveHoverSurfacePinned() { + if (activeHoverTrigger === "notepadButton") { + const instance = _notepadWidgetForScreen()?.notepadInstance; + if (instance?.hoverDismissSuspended === true) + return true; + } + return PopoutManager.isActivePopoutPinned(barWindow?.screen); + } + + function cursorOverHoverChain(gx, gy, excludedBarWindow) { + if (PopoutManager.cursorOverBar(gx, gy, undefined, excludedBarWindow)) + return true; + const popout = PopoutManager.getActivePopout(barWindow?.screen); + if (popout?.containsGlobalPoint?.(gx, gy)) + return true; + if (notepadContainsGlobalPoint(gx, gy)) + return true; + const screenName = barWindow.screen?.name; + if (screenName && TrayMenuManager.activeTrayMenus[screenName]) + return true; + return false; + } + + function _closeHoverNotepad() { + if (activeHoverTrigger !== "notepadButton") + return; + const instance = _notepadWidgetForScreen()?.notepadInstance; + if (!instance) + return; + if (instance.hoverDismissEnabled !== undefined) + instance.hoverDismissEnabled = false; + if (typeof instance.hideFromHoverDismiss === "function") + instance.hideFromHoverDismiss(); + else if (typeof instance.hide === "function") + instance.hide(); + } + + function closeHoverSurfaces() { + _closeHoverNotepad(); + activeHoverTrigger = ""; + PopoutManager.closePopoutForScreen(barWindow?.screen); + TrayMenuManager.closeAllMenus(); + } + + function _beginSupersededCloseForActive() { + const popout = PopoutManager.getActivePopout(barWindow?.screen); + if (popout && typeof popout.beginSupersededClose === "function") + popout.beginSupersededClose(); + } + + function openNotepadHover(widgetItem) { + const instance = widgetItem.prepareNotepadInstance?.(widgetItem.notepadInstance) ?? widgetItem.notepadInstance; + if (!instance || typeof instance.show !== "function") + return false; + if (instance.hoverDismissEnabled !== undefined) + instance.hoverDismissEnabled = true; + instance.show(); + return true; + } + + function _syncHoverTriggerState() { + if (activeHoverTrigger === "notepadButton") { + const instance = _notepadWidgetForScreen()?.notepadInstance; + if (!instance?.isVisible) + activeHoverTrigger = ""; + return; + } + if (activeHoverTrigger !== "" && !hasOpenHoverSurface()) + activeHoverTrigger = ""; + } + + function hasOpenHoverSurface() { + if (activeHoverTrigger === "") + return false; + if (activeHoverTrigger === "notepadButton") { + const instance = _notepadWidgetForScreen()?.notepadInstance; + return instance?.isVisible ?? false; + } + if (activeHoverTrigger.startsWith("tray-")) { + const screenName = barWindow.screen?.name; + return !!(screenName && TrayMenuManager.activeTrayMenus[screenName]); + } + const popout = PopoutManager.getActivePopout(barWindow?.screen); + if (!popout) + return false; + if (popout.dashVisible !== undefined) + return !!popout.dashVisible || !!popout.isClosing; + if (popout.notificationHistoryVisible !== undefined) + return !!popout.notificationHistoryVisible || !!popout.isClosing; + return !!(popout.shouldBeVisible || popout.isClosing); + } + + function _loaderForWidgetId(widgetId) { + switch (widgetId) { + case "launcherButton": + return PopoutService.appDrawerLoader; + case "clipboard": + return PopoutService.clipboardHistoryPopoutLoader; + case "clock": + case "music": + case "weather": + return PopoutService.dankDashPopoutLoader; + case "cpuUsage": + case "memUsage": + case "cpuTemp": + case "gpuTemp": + return PopoutService.processListPopoutLoader; + case "notificationButton": + return PopoutService.notificationCenterLoader; + case "battery": + return PopoutService.batteryPopoutLoader; + case "layout": + return PopoutService.layoutPopoutLoader; + case "vpn": + return PopoutService.vpnPopoutLoader; + case "controlCenterButton": + return PopoutService.controlCenterLoader; + case "systemUpdate": + return PopoutService.systemUpdateLoader; + default: + return null; + } + } + + function openHoverPopoutForHit(hit) { + if (!hit?.widgetItem) + return false; + + const widgetId = hit.widgetId; + const widgetItem = hit.widgetItem; + const section = hit.section; + const base = { + widgetItem, + section, + mode: "hover" + }; + + if (widgetId === "systemTray") { + if (typeof widgetItem.openHoverAtGlobalPoint !== "function") + return false; + return !!widgetItem.openHoverAtGlobalPoint(hit.globalX, hit.globalY); + } + + if (typeof widgetItem.triggerHoverPopout === "function") { + widgetItem.triggerHoverPopout(hit.widgetId); + return true; + } + + const loader = _loaderForWidgetId(widgetId); + switch (widgetId) { + case "launcherButton": + return barContent.openWidgetPopout(Object.assign({}, base, { + loader, + triggerSource: "appDrawer", + visualItem: widgetItem + })); + case "clipboard": + return barContent.openWidgetPopout(Object.assign({}, base, { + loader, + triggerSource: "clipboard", + prepare: popout => { + popout.activeTab = "recents"; + } + })); + case "clock": + case "music": + case "weather": + { + const tabIndex = widgetId === "clock" ? 0 : (widgetId === "music" ? 1 : 3); + return barContent.openWidgetPopout(Object.assign({}, base, { + loader, + tabIndex, + triggerSource: dashTriggerSource(section, tabIndex), + useCenterSection: true, + setTriggerScreen: true + })); + } + case "cpuUsage": + case "memUsage": + case "cpuTemp": + case "gpuTemp": + { + const triggerSources = { + cpuUsage: "cpu", + memUsage: "memory", + cpuTemp: "cpu_temp", + gpuTemp: "gpu_temp" + }; + return barContent.openWidgetPopout(Object.assign({}, base, { + loader, + triggerSource: triggerSources[widgetId] + })); + } + case "notificationButton": + return barContent.openWidgetPopout(Object.assign({}, base, { + loader, + triggerSource: "notifications", + setTriggerScreen: true + })); + case "battery": + case "layout": + case "vpn": + { + const triggerSources = { + battery: "battery", + layout: "layout", + vpn: "vpn" + }; + return barContent.openWidgetPopout(Object.assign({}, base, { + loader, + triggerSource: triggerSources[widgetId] + })); + } + case "controlCenterButton": + if (barContent.openWidgetPopout(Object.assign({}, base, { + loader, + triggerSource: "controlCenter", + setTriggerScreen: true + }))) { + if (loader.item?.shouldBeVisible && NetworkService.wifiEnabled) + NetworkService.scanWifi(); + return true; + } + return false; + case "systemUpdate": + return barContent.openWidgetPopout(Object.assign({}, base, { + loader, + triggerSource: "systemUpdate", + visualItem: widgetItem + })); + case "notepadButton": + return openNotepadHover(widgetItem); + default: + return false; + } + } + + function checkHoverPopout(gx, gy) { + if (!hoverPopoutsEnabled) + return; + + _lastHoverGlobalX = gx; + _lastHoverGlobalY = gy; + PopoutManager.updateHoverCursor(gx, gy); + _syncHoverTriggerState(); + + if (isActiveHoverSurfacePinned()) + return; + + const hit = findWidgetAtGlobalPoint(gx, gy); + if (!hit) { + _cancelPendingHover(); + scheduleHoverClose(gx, gy); + return; + } + + hit.globalX = gx; + hit.globalY = gy; + + let triggerKey = hit.widgetId; + if (hit.widgetId === "systemTray") + triggerKey = hit.widgetItem.hoverTriggerAtGlobalPoint?.(gx, gy) || ""; + else if (hit.widgetId === "clock") + triggerKey = dashTriggerSource(hit.section, 0); + else if (hit.widgetId === "music") + triggerKey = dashTriggerSource(hit.section, 1); + else if (hit.widgetId === "weather") + triggerKey = dashTriggerSource(hit.section, 3); + + if (!triggerKey) { + _cancelPendingHover(); + scheduleHoverClose(gx, gy); + return; + } + + _hoverCloseTimer.stop(); + + if (triggerKey === activeHoverTrigger && hasOpenHoverSurface()) { + _cancelPendingHover(); + return; + } + + _pendingHoverHit = hit; + if (_pendingHoverTrigger !== triggerKey) { + _pendingHoverTrigger = triggerKey; + if (hoverPopoutDelay <= 0) + _commitPendingHover(); + else + _hoverIntentTimer.restart(); + } + } + + function _cancelPendingHover() { + _hoverIntentTimer.stop(); + _pendingHoverHit = null; + _pendingHoverTrigger = ""; + } + + function _hitTargetsActivePopout(hit) { + const active = PopoutManager.getActivePopout(barWindow?.screen); + if (!active || !hit) + return false; + const loader = _loaderForWidgetId(hit.widgetId); + if (!loader) + return false; + return barContent._resolvePopoutFromLoader(loader) === active; + } + + function _commitPendingHover() { + const hit = _pendingHoverHit; + const triggerKey = _pendingHoverTrigger; + _pendingHoverHit = null; + _pendingHoverTrigger = ""; + if (!hit || !hoverPopoutsEnabled) + return; + if (isActiveHoverSurfacePinned()) + return; + if (!PopoutManager.cursorOverBar(_lastHoverGlobalX, _lastHoverGlobalY)) + return; + + const activePopout = PopoutManager.getActivePopout(barWindow?.screen); + const targetLoader = _loaderForWidgetId(hit.widgetId); + const targetPopout = barContent._resolvePopoutFromLoader(targetLoader); + const managerOwnsTransition = !!(activePopout && targetPopout); + + if (triggerKey !== activeHoverTrigger && activeHoverTrigger !== "" && !_hitTargetsActivePopout(hit)) { + if (!managerOwnsTransition) { + _beginSupersededCloseForActive(); + closeHoverSurfaces(); + } + } + + if (!openHoverPopoutForHit(hit)) { + if (activeHoverTrigger !== "") + closeHoverSurfaces(); + return; + } + + activeHoverTrigger = triggerKey; + } + + function scheduleHoverClose(gx, gy) { + cancelQueuedHitTest(); + _cancelPendingHover(); + _barExitPending = false; + if (!hoverPopoutsEnabled) + return; + if (isActiveHoverSurfacePinned()) + return; + if (cursorOverHoverChain(gx, gy)) + return; + _hoverCloseTimer.restart(); + } + + function _commitHoverClose() { + const gx = PopoutManager.hoverCursorGlobalX; + const gy = PopoutManager.hoverCursorGlobalY; + if (isActiveHoverSurfacePinned()) + return; + if (_barHovered) + return; + const excludedBar = _barExitPending ? barWindow : null; + if (cursorOverHoverChain(gx, gy, excludedBar)) + return; + _barExitPending = false; + closeHoverSurfaces(); + } +} diff --git a/quickshell/Modules/DankBar/DankBarWindow.qml b/quickshell/Modules/DankBar/DankBarWindow.qml index b325f052..4d136666 100644 --- a/quickshell/Modules/DankBar/DankBarWindow.qml +++ b/quickshell/Modules/DankBar/DankBarWindow.qml @@ -719,6 +719,14 @@ PanelWindow { readonly property var _rightSection: topBarContent ? (barWindow.isVertical ? topBarContent.vRightSection : topBarContent.hRightSection) : null readonly property real _revealProgress: topBarSlide.x + topBarSlide.y + function containsGlobalPoint(gx, gy, padding) { + const pad = padding !== undefined ? padding : 16; + if (!inputMask.showing) + return false; + const topLeft = inputMask.mapToItem(null, 0, 0); + return gx >= topLeft.x - pad && gx < topLeft.x + inputMask.width + pad && gy >= topLeft.y - pad && gy < topLeft.y + inputMask.height + pad; + } + function sectionRect(section, isCenter, _dep) { if (!section) return { @@ -1020,7 +1028,7 @@ PanelWindow { } } - onWheel: wheel => { + function processWheel(wheel) { if (!(barConfig?.scrollEnabled ?? true) || actionInProgress) { wheel.accepted = false; return; @@ -1089,6 +1097,8 @@ PanelWindow { wheel.accepted = false; } + + onWheel: wheel => processWheel(wheel) } DankBarContent { @@ -1100,6 +1110,26 @@ PanelWindow { centerWidgetsModel: barWindow.centerWidgetsModel rightWidgetsModel: barWindow.rightWidgetsModel } + + // Passive HoverHandler to track cursor without intercepting clicks or scroll events. + HoverHandler { + id: hoverPopoutHandler + enabled: (barConfig?.hoverPopouts ?? false) && !barWindow.clickThroughEnabled + + property real lastGlobalX: 0 + property real lastGlobalY: 0 + + onPointChanged: { + const gp = barUnitInset.mapToItem(null, point.position.x, point.position.y); + lastGlobalX = gp.x; + lastGlobalY = gp.y; + topBarContent.queueHoverPopout(gp.x, gp.y); + } + + onHoveredChanged: { + topBarContent.updateHoverBarHovered(hovered); + } + } } } } diff --git a/quickshell/Modules/DankBar/LeftSection.qml b/quickshell/Modules/DankBar/LeftSection.qml index 50091e77..9f01094f 100644 --- a/quickshell/Modules/DankBar/LeftSection.qml +++ b/quickshell/Modules/DankBar/LeftSection.qml @@ -19,6 +19,7 @@ Item { property bool forceVerticalLayout: false readonly property bool isVertical: overrideAxisLayout ? forceVerticalLayout : (axis?.isVertical ?? false) + property alias widgetLayoutLoader: layoutLoader implicitHeight: layoutLoader.item ? layoutLoader.item.implicitHeight : 0 implicitWidth: layoutLoader.item ? layoutLoader.item.implicitWidth : 0 diff --git a/quickshell/Modules/DankBar/RightSection.qml b/quickshell/Modules/DankBar/RightSection.qml index 2f19dff0..e9f9a73d 100644 --- a/quickshell/Modules/DankBar/RightSection.qml +++ b/quickshell/Modules/DankBar/RightSection.qml @@ -19,6 +19,7 @@ Item { property bool forceVerticalLayout: false readonly property bool isVertical: overrideAxisLayout ? forceVerticalLayout : (axis?.isVertical ?? false) + property alias widgetLayoutLoader: layoutLoader implicitHeight: layoutLoader.item ? layoutLoader.item.implicitHeight : 0 implicitWidth: layoutLoader.item ? layoutLoader.item.implicitWidth : 0 diff --git a/quickshell/Modules/DankBar/Widgets/SystemTrayBar.qml b/quickshell/Modules/DankBar/Widgets/SystemTrayBar.qml index 52e37e9d..40dc74a4 100644 --- a/quickshell/Modules/DankBar/Widgets/SystemTrayBar.qml +++ b/quickshell/Modules/DankBar/Widgets/SystemTrayBar.qml @@ -2103,4 +2103,53 @@ BasePill { return; currentTrayMenu.showForTrayItem(item, anchor, screen, atBottom, vertical ?? false, axisObj); } + + function _trayLayoutRoot() { + const contentChildren = root.visualContent?.children; + if (!contentChildren || contentChildren.length === 0) + return null; + const contentRoot = contentChildren[0]; + return contentRoot?.layoutLoader?.item || null; + } + + function _trayHitAtGlobalPoint(gx, gy) { + if (!root.visible || root.width <= 0 || root.height <= 0) + return null; + const local = root.mapFromItem(null, gx, gy); + if (local.x < 0 || local.y < 0 || local.x > root.width || local.y > root.height) + return null; + const layout = _trayLayoutRoot(); + if (!layout) + return null; + const layoutLocal = layout.mapFromItem(null, gx, gy); + const children = layout.children || []; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (!child.visible || child.width <= 0 || child.height <= 0) + continue; + if (layoutLocal.x < child.x || layoutLocal.x >= child.x + child.width) + continue; + if (layoutLocal.y < child.y || layoutLocal.y >= child.y + child.height) + continue; + if (child.trayItem) + return child; + } + return null; + } + + function hoverTriggerAtGlobalPoint(gx, gy) { + const hit = _trayHitAtGlobalPoint(gx, gy); + if (!hit?.trayItem?.hasMenu) + return ""; + return "tray-" + (hit.trayItem.id || hit.itemKey || ""); + } + + function openHoverAtGlobalPoint(gx, gy) { + const hit = _trayHitAtGlobalPoint(gx, gy); + if (!hit?.trayItem?.hasMenu) + return false; + const anchor = hit.children?.length > 0 ? hit.children[0] : hit; + showForTrayItem(hit.trayItem, anchor, parentScreen, isAtBottom, isVerticalOrientation, axis); + return true; + } } diff --git a/quickshell/Modules/Frame/FrameLauncherHoverZone.qml b/quickshell/Modules/Frame/FrameLauncherHoverZone.qml new file mode 100644 index 00000000..288c2551 --- /dev/null +++ b/quickshell/Modules/Frame/FrameLauncherHoverZone.qml @@ -0,0 +1,92 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Wayland +import qs.Common +import qs.Services + +// Edge strip to trigger launcher hover-reveal when free of panel bars and dock. +Variants { + id: root + + model: Quickshell.screens + + delegate: Loader { + id: zoneLoader + + required property var modelData + + readonly property string emergeSide: SettingsData.frameLauncherEmergeSide || "bottom" + readonly property bool eligible: SettingsData.frameEnabled && SettingsData.frameLauncherEdgeHover && Theme.isConnectedEffect && SettingsData.isScreenInPreferences(zoneLoader.modelData, SettingsData.frameScreenPreferences) && CompositorService.usesConnectedFrameChromeForScreen(zoneLoader.modelData) && !SettingsData.barOccupiesSide(zoneLoader.modelData, zoneLoader.emergeSide) && !SettingsData.dockOccupiesSide(zoneLoader.emergeSide) + + active: eligible + asynchronous: false + + sourceComponent: PanelWindow { + id: zoneWindow + + readonly property bool vertical: zoneLoader.emergeSide === "left" || zoneLoader.emergeSide === "right" + readonly property real triggerThickness: Math.max(6, SettingsData.frameThickness) + readonly property bool launcherOpen: PopoutService.dankLauncherV2Modal?.spotlightOpen ?? false + property bool _openedForCurrentHover: false + + // Hot zone dimensions centered on the emerge edge to cover the launcher footprint. + readonly property real _launcherBaseW: SettingsData.dankLauncherV2Size === "micro" ? 500 : (SettingsData.dankLauncherV2Size === "medium" ? 720 : (SettingsData.dankLauncherV2Size === "large" ? 860 : 620)) + readonly property real _launcherBaseH: SettingsData.dankLauncherV2Size === "micro" ? 480 : (SettingsData.dankLauncherV2Size === "medium" ? 720 : (SettingsData.dankLauncherV2Size === "large" ? 860 : 600)) + readonly property real screenW: zoneLoader.modelData?.width ?? 0 + readonly property real screenH: zoneLoader.modelData?.height ?? 0 + readonly property real spanW: Math.round(Math.min(_launcherBaseW, screenW - 100) * 1.1) + readonly property real spanH: Math.round(Math.min(_launcherBaseH, screenH - 100) * 1.1) + + function requestLauncherOpen() { + if (launcherOpen || _openedForCurrentHover) + return; + _openedForCurrentHover = true; + PopoutService.openDankLauncherV2(CompositorService.framePeerSurfacesUseOverlayForScreen(zoneLoader.modelData), true); + } + + screen: zoneLoader.modelData + color: "transparent" + + WlrLayershell.namespace: "dms:frame-launcher-hover" + WlrLayershell.layer: WlrLayer.Top + WlrLayershell.exclusionMode: ExclusionMode.Ignore + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + // Anchor and center the hover zone alignment with the launcher. + anchors { + top: zoneLoader.emergeSide === "top" || zoneWindow.vertical + bottom: zoneLoader.emergeSide === "bottom" + left: zoneLoader.emergeSide === "left" || !zoneWindow.vertical + right: zoneLoader.emergeSide === "right" + } + + margins { + left: zoneWindow.vertical ? 0 : Math.max(0, (zoneWindow.screenW - zoneWindow.spanW) / 2) + top: zoneWindow.vertical ? Math.max(0, (zoneWindow.screenH - zoneWindow.spanH) / 2) : 0 + } + + implicitWidth: zoneWindow.vertical ? zoneWindow.triggerThickness : zoneWindow.spanW + implicitHeight: zoneWindow.vertical ? zoneWindow.spanH : zoneWindow.triggerThickness + + MouseArea { + id: edgeHoverArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + + onContainsMouseChanged: { + if (containsMouse) + zoneWindow.requestLauncherOpen(); + else + zoneWindow._openedForCurrentHover = false; + } + onPositionChanged: { + if (containsMouse) + zoneWindow.requestLauncherOpen(); + } + } + } + } +} diff --git a/quickshell/Modules/Notepad/Notepad.qml b/quickshell/Modules/Notepad/Notepad.qml index 912b029e..c9d65656 100644 --- a/quickshell/Modules/Notepad/Notepad.qml +++ b/quickshell/Modules/Notepad/Notepad.qml @@ -23,6 +23,7 @@ Item { property bool showSettingsMenu: false property string pendingSaveContent: "" readonly property bool conflictBannerVisible: currentTab !== null && NotepadStorageService.conflictTabId === currentTab.id + readonly property bool anyModalOpen: fileDialogOpen || confirmationDialogOpen property var slideout: null property bool inPopout: false property bool surfaceVisible: slideout ? slideout.isVisible : true @@ -50,6 +51,14 @@ Item { slideout.suppressOverlayLayer = fileDialogOpen; } + Binding { + target: root.slideout + property: "hoverDismissSuspended" + value: root.anyModalOpen + when: root.slideout !== null + restoreMode: Binding.RestoreBindingOrValue + } + Connections { target: slideout enabled: slideout !== null diff --git a/quickshell/Modules/Plugins/PluginComponent.qml b/quickshell/Modules/Plugins/PluginComponent.qml index 39d9ff68..59667a2d 100644 --- a/quickshell/Modules/Plugins/PluginComponent.qml +++ b/quickshell/Modules/Plugins/PluginComponent.qml @@ -330,6 +330,24 @@ Item { pluginPopout.toggle(); } + function triggerHoverPopout(widgetHostId) { + if (pillClickAction) { + triggerPopout(); + return; + } + if (!hasPopout) + return; + + const pill = isVertical ? verticalPill : horizontalPill; + const globalPos = pill.visualContent.mapToItem(null, 0, 0); + const currentScreen = parentScreen || Screen; + const barPosition = axis?.edge === "left" ? 2 : (axis?.edge === "right" ? 3 : (axis?.edge === "top" ? 0 : 1)); + const pos = SettingsData.getPopupTriggerPosition(globalPos, currentScreen, barThickness, pill.visualWidth, barSpacing, barPosition, barConfig); + + pluginPopout.setTriggerPosition(pos.x, pos.y, pos.width, section, currentScreen, barPosition, barThickness, barSpacing, barConfig); + PopoutManager.requestHoverPopout(pluginPopout, undefined, widgetHostId || pluginId); + } + PluginPopout { id: pluginPopout contentWidth: root.popoutWidth diff --git a/quickshell/Modules/ProcessList/ProcessListPopout.qml b/quickshell/Modules/ProcessList/ProcessListPopout.qml index 31a92b62..8f540d42 100644 --- a/quickshell/Modules/ProcessList/ProcessListPopout.qml +++ b/quickshell/Modules/ProcessList/ProcessListPopout.qml @@ -26,6 +26,19 @@ DankPopout { open(); } + function prepareForTrigger(triggerSource) { + switch (triggerSource) { + case "memory": + DgopService.setSortBy("memory"); + break; + case "cpu": + case "cpu_temp": + case "gpu_temp": + DgopService.setSortBy("cpu"); + break; + } + } + popupWidth: Math.round(Theme.fontSizeMedium * 46) popupHeight: Math.round(Theme.fontSizeMedium * 39) triggerWidth: 55 diff --git a/quickshell/Modules/Settings/DankBarTab.qml b/quickshell/Modules/Settings/DankBarTab.qml index c140304c..807c10b0 100644 --- a/quickshell/Modules/Settings/DankBarTab.qml +++ b/quickshell/Modules/Settings/DankBarTab.qml @@ -171,6 +171,8 @@ Item { scrollEnabled: defaultBar.scrollEnabled ?? true, scrollXBehavior: defaultBar.scrollXBehavior ?? "column", scrollYBehavior: defaultBar.scrollYBehavior ?? "workspace", + hoverPopouts: defaultBar.hoverPopouts ?? false, + hoverPopoutDelay: defaultBar.hoverPopoutDelay ?? 150, shadowIntensity: defaultBar.shadowIntensity ?? 0, shadowOpacity: defaultBar.shadowOpacity ?? 60, shadowDirectionMode: defaultBar.shadowDirectionMode ?? "inherit", @@ -1255,6 +1257,50 @@ Item { } } + SettingsToggleCard { + iconName: "touch_app" + title: I18n.tr("Hover Popouts") + description: I18n.tr("Open widget popouts by hovering over the bar. Moving to another widget switches the popout.") + visible: !dankBarTab.appearanceOnly && selectedBarConfig?.enabled + enabled: !(selectedBarConfig?.clickThrough ?? false) + opacity: (selectedBarConfig?.clickThrough ?? false) ? 0.5 : 1.0 + checked: selectedBarConfig?.hoverPopouts ?? false + onToggled: checked => SettingsData.updateBarConfig(selectedBarId, { + hoverPopouts: checked + }) + + Column { + width: parent.width + spacing: Theme.spacingS + visible: selectedBarConfig?.hoverPopouts ?? false + leftPadding: Theme.spacingM + + SettingsSliderRow { + id: hoverDelaySlider + width: parent.width - parent.leftPadding + text: I18n.tr("Open Delay") + description: I18n.tr("Time to rest on a widget before its popout opens") + value: selectedBarConfig?.hoverPopoutDelay ?? 150 + minimum: 0 + maximum: 1000 + unit: "ms" + defaultValue: 150 + onSliderValueChanged: newValue => { + SettingsData.updateBarConfig(selectedBarId, { + hoverPopoutDelay: newValue + }); + } + + Binding { + target: hoverDelaySlider + property: "value" + value: selectedBarConfig?.hoverPopoutDelay ?? 150 + restoreMode: Binding.RestoreBinding + } + } + } + } + SettingsToggleCard { iconName: "fit_screen" title: I18n.tr("Maximize Detection") diff --git a/quickshell/Modules/Settings/FrameTab.qml b/quickshell/Modules/Settings/FrameTab.qml index 78bb5b65..73184023 100644 --- a/quickshell/Modules/Settings/FrameTab.qml +++ b/quickshell/Modules/Settings/FrameTab.qml @@ -357,6 +357,15 @@ Item { checked: SettingsData.frameLauncherArcExtender onToggled: checked => SettingsData.set("frameLauncherArcExtender", checked) } + + SettingsToggleRow { + settingKey: "frameLauncherEdgeHover" + tags: ["frame", "connected", "launcher", "hover", "edge", "reveal"] + text: I18n.tr("Edge Hover Reveal") + description: I18n.tr("Open the launcher by hovering the emerge edge (when free of bar and dock)") + checked: SettingsData.frameLauncherEdgeHover + onToggled: checked => SettingsData.set("frameLauncherEdgeHover", checked) + } } SettingsCard { diff --git a/quickshell/Modules/Settings/WallpaperTab.qml b/quickshell/Modules/Settings/WallpaperTab.qml index 65360c33..04c71925 100644 --- a/quickshell/Modules/Settings/WallpaperTab.qml +++ b/quickshell/Modules/Settings/WallpaperTab.qml @@ -359,7 +359,7 @@ Item { tags: ["background", "color", "fill", "fit", "custom"] settingKey: "wallpaperBackgroundColorMode" text: I18n.tr("Background Color") - description: I18n.tr("Color shown for areas not covered by wallpaper (e.g. Fit or Pad modes)") + description: I18n.tr("Color shown for areas not covered by wallpaper") visible: root.currentWallpaper !== "" && !root.currentWallpaper.startsWith("#") dropdownWidth: 220 options: [ diff --git a/quickshell/Services/PopoutService.qml b/quickshell/Services/PopoutService.qml index b6c1cb16..cd0d9854 100644 --- a/quickshell/Services/PopoutService.qml +++ b/quickshell/Services/PopoutService.qml @@ -502,15 +502,26 @@ Singleton { property string _dankLauncherV2PendingQuery: "" property string _dankLauncherV2PendingMode: "" property bool _dankLauncherV2TriggerUsesOverlayLayer: false + property bool _dankLauncherV2EdgeHoverManaged: false function _setDankLauncherV2TriggerUsesOverlayLayer(value) { _dankLauncherV2TriggerUsesOverlayLayer = value === true; + // Disable edge-hover by default on every open/toggle path unless explicitly enabled. + _setDankLauncherV2EdgeHoverManaged(false); if (dankLauncherV2Modal) dankLauncherV2Modal.triggerUsesOverlayLayer = _dankLauncherV2TriggerUsesOverlayLayer; } - function openDankLauncherV2(triggerUsesOverlayLayer) { + // Set edgeHoverManaged to enable hover retraction for edge-hover triggered launcher sessions. + function _setDankLauncherV2EdgeHoverManaged(value) { + _dankLauncherV2EdgeHoverManaged = value === true; + if (dankLauncherV2Modal) + dankLauncherV2Modal.edgeHoverManaged = _dankLauncherV2EdgeHoverManaged; + } + + function openDankLauncherV2(triggerUsesOverlayLayer, edgeHoverManaged) { _setDankLauncherV2TriggerUsesOverlayLayer(triggerUsesOverlayLayer); + _setDankLauncherV2EdgeHoverManaged(edgeHoverManaged); if (dankLauncherV2Modal) { dankLauncherV2Modal.show(); } else if (dankLauncherV2ModalLoader) { @@ -591,8 +602,10 @@ Singleton { } function _onDankLauncherV2ModalLoaded() { - if (dankLauncherV2Modal) + if (dankLauncherV2Modal) { dankLauncherV2Modal.triggerUsesOverlayLayer = _dankLauncherV2TriggerUsesOverlayLayer; + dankLauncherV2Modal.edgeHoverManaged = _dankLauncherV2EdgeHoverManaged; + } if (_dankLauncherV2WantsOpen) { _dankLauncherV2WantsOpen = false; if (_dankLauncherV2PendingQuery) { diff --git a/quickshell/Widgets/DankPopout.qml b/quickshell/Widgets/DankPopout.qml index 690ba66e..7ffbc1bc 100644 --- a/quickshell/Widgets/DankPopout.qml +++ b/quickshell/Widgets/DankPopout.qml @@ -24,6 +24,8 @@ Item { property list animationExitCurve: Theme.variantPopoutExitCurve property bool suspendShadowWhileResizing: false property bool shouldBeVisible: false + property bool hoverDismissEnabled: false + property bool hoverDismissSuspended: false property var customKeyboardFocus: null property bool backgroundInteractive: true property bool contentHandlesKeys: false @@ -82,6 +84,8 @@ Item { 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 renderedAlignedY: impl.item ? (impl.item.renderedAlignedY ?? impl.item.alignedY) : 0 + readonly property real renderedAlignedHeight: impl.item ? (impl.item.renderedAlignedHeight ?? 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 @@ -172,6 +176,36 @@ Item { impl.item.close(); } + function cancelHoverDismiss() { + if (impl.item?.cancelHoverDismiss) + impl.item.cancelHoverDismiss(); + } + + // Fade out in place during morph switch transitions. + function beginSupersededClose() { + if (impl.item?.beginSupersededClose) + impl.item.beginSupersededClose(); + } + + function closeFromHoverDismiss() { + if (hoverDismissSuspended) + return; + hoverDismissEnabled = false; + // Enable animations using standard Theme-bound popout motion to preserve bindings. + if (impl.item) + impl.item.animationsEnabled = true; + for (const prop of ["dashVisible", "notificationHistoryVisible"]) { + if (root[prop] !== undefined) { + root[prop] = false; + return; + } + } + if (impl.item) + impl.item.close(); + else + close(); + } + function toggle() { (shouldBeVisible || _pendingOpen) ? close() : open(); } @@ -210,6 +244,20 @@ Item { impl.item.updateSurfacePosition(); } + function containsGlobalPoint(gx, gy) { + if (!screen) + return false; + const presented = shouldBeVisible || (impl.item?.isClosing ?? false); + if (!presented) + return false; + const padding = 24; + const x = alignedX - padding; + const y = renderedAlignedY - padding; + const w = alignedWidth + padding * 2; + const h = renderedAlignedHeight + padding * 2; + return gx >= x && gx <= x + w && gy >= y && gy <= y + h; + } + Loader { id: impl active: root.screen !== null @@ -261,6 +309,8 @@ Item { it.screen = Qt.binding(() => root.screen); 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.shouldBeVisible = root.shouldBeVisible; if (root._primeContent && typeof it.primeContent === "function") diff --git a/quickshell/Widgets/DankPopoutConnected.qml b/quickshell/Widgets/DankPopoutConnected.qml index affba4dd..54025239 100644 --- a/quickshell/Widgets/DankPopoutConnected.qml +++ b/quickshell/Widgets/DankPopoutConnected.qml @@ -5,6 +5,7 @@ import Quickshell import Quickshell.Wayland import qs.Common import qs.Services +import qs.Widgets Item { id: root @@ -41,6 +42,11 @@ Item { property real _chromeAnimTravelX: 1 property real _chromeAnimTravelY: 1 property bool _fullSyncQueued: false + property bool _publishedBodyValid: false + property real _publishedBodyX: 0 + property real _publishedBodyY: 0 + property real _publishedBodyW: 0 + property real _publishedBodyH: 0 property real storedBarThickness: Theme.barHeight - 4 property real storedBarSpacing: 4 @@ -130,7 +136,11 @@ Item { updateBodyState: function(_name, ownerId, bodyX, bodyY, bodyW, bodyH) { return ConnectedModeState.setPopoutBody(ownerId, bodyX, bodyY, bodyW, bodyH); } - onRecoveryRequested: root._queueFullSync() + onClaimIdChanged: root._resetPublishedBody() + onRecoveryRequested: { + root._resetPublishedBody(); + root._queueFullSync(); + } } property var _lastOpenedScreen: null @@ -233,11 +243,15 @@ Item { const visible = visibleOverride !== undefined ? !!visibleOverride : contentWindow.visible; const presented = contentWindow.visible || root.shouldBeVisible; const phase = root.isClosing ? "closing" : (!presented ? "hidden" : (!contentWindow.visible && root.shouldBeVisible ? "opening" : "open")); + const bodyX = Theme.snap(root.pubBodyX, root.dpr); + const bodyY = Theme.snap(root.pubBodyY, root.dpr); + const bodyW = Theme.snap(root.pubBodyW, root.dpr); + const bodyH = Theme.snap(root.pubBodyH, root.dpr); const bodyRect = { - "x": root.alignedX, - "y": root.renderedAlignedY, - "width": root.alignedWidth, - "height": root.renderedAlignedHeight + "x": bodyX, + "y": bodyY, + "width": bodyW, + "height": bodyH }; const animationOffset = { "x": _connectedChromeAnimX(), @@ -254,10 +268,10 @@ Item { "animationOffset": animationOffset, "scale": 1, "opacity": Theme.connectedSurfaceColor.a, - "bodyX": root.alignedX, - "bodyY": root.renderedAlignedY, - "bodyW": root.alignedWidth, - "bodyH": root.renderedAlignedHeight, + "bodyX": bodyX, + "bodyY": bodyY, + "bodyW": bodyW, + "bodyH": bodyH, "animX": animationOffset.x, "animY": animationOffset.y, "screen": root.screen ? root.screen.name : "", @@ -269,10 +283,15 @@ Item { function _publishConnectedChromeState(forceClaim, visibleOverride) { if (!root.frameOwnsConnectedChrome || !root.screen) return false; - return chromeLease.publish(_connectedChromeState(visibleOverride), !!forceClaim); + const state = _connectedChromeState(visibleOverride); + const published = chromeLease.publish(state, !!forceClaim); + if (published) + _rememberPublishedBody(state.bodyX, state.bodyY, state.bodyW, state.bodyH); + return published; } function _releaseConnectedChromeState() { + _resetPublishedBody(); chromeLease.release(); } @@ -311,7 +330,26 @@ Item { return; if (!contentWindow.visible && !shouldBeVisible) return; - chromeLease.updateBody(root.alignedX, root.renderedAlignedY, root.alignedWidth, root.renderedAlignedHeight); + const bodyX = Theme.snap(root.pubBodyX, root.dpr); + const bodyY = Theme.snap(root.pubBodyY, root.dpr); + const bodyW = Theme.snap(root.pubBodyW, root.dpr); + const bodyH = Theme.snap(root.pubBodyH, root.dpr); + if (_publishedBodyValid && _publishedBodyX === bodyX && _publishedBodyY === bodyY && _publishedBodyW === bodyW && _publishedBodyH === bodyH) + return; + if (chromeLease.updateBody(bodyX, bodyY, bodyW, bodyH)) + _rememberPublishedBody(bodyX, bodyY, bodyW, bodyH); + } + + function _rememberPublishedBody(bodyX, bodyY, bodyW, bodyH) { + _publishedBodyX = bodyX; + _publishedBodyY = bodyY; + _publishedBodyW = bodyW; + _publishedBodyH = bodyH; + _publishedBodyValid = true; + } + + function _resetPublishedBody() { + _publishedBodyValid = false; } property bool _animSyncQueued: false @@ -356,7 +394,10 @@ Item { onContentAnimYChanged: _queueAnimSync() onRenderedAlignedYChanged: _queueBodySync() onRenderedAlignedHeightChanged: _queueBodySync() - onScreenChanged: _queueFullSync() + onScreenChanged: { + _resetPublishedBody(); + _queueFullSync(); + } onEffectiveBarPositionChanged: _queueFullSync() Connections { @@ -407,14 +448,31 @@ Item { onFrameOwnsConnectedChromeChanged: _syncPopoutChromeState() property bool animationsEnabled: true + property bool hoverDismissEnabled: false + property bool hoverDismissSuspended: false + + function cancelHoverDismiss() { + hoverDismissController.cancelPending(); + } + + function closeFromHoverDismiss() { + if (hoverDismissSuspended || isClosing || !shouldBeVisible) + return; + if (popoutHandle?.closeFromHoverDismiss) + popoutHandle.closeFromHoverDismiss(); + else + close(); + } function open() { if (!screen) return; + _resetPublishedBody(); closeTimer.stop(); isClosing = false; animationsEnabled = false; _primeContent = true; + _supersededClose = false; const screenChanged = _lastOpenedScreen !== null && _lastOpenedScreen !== screen; if (screenChanged) { @@ -429,6 +487,13 @@ Item { _captureChromeAnimTravel(); } + // Seed travel coordinates from the outgoing popout to morph continuously. + _beginMorphTravel(); + + // Skip emerge animation on morph switch. + if (morphTravelEnabled) + morph.openProgress = 1; + if (root.frameOwnsConnectedChrome) { chromeLease.beginClaim(); _publishConnectedChromeState(true, true); @@ -456,6 +521,11 @@ Item { } function close() { + if (_supersededClose && morphTravelEnabled) + _freezeMorphTravel(); + else + _endMorphTravel(); + _resetPublishedBody(); isClosing = true; shouldBeVisible = false; _primeContent = false; @@ -494,6 +564,7 @@ Item { onTriggered: { if (!shouldBeVisible) { contentWindow.visible = false; + root._endMorphTravel(); isClosing = false; PopoutManager.hidePopout(popoutHandle); popoutClosed(); @@ -642,6 +713,108 @@ Item { easing.bezierCurve: root.renderedGeometryGrowing ? root.animationEnterCurve : root.animationExitCurve } } + + // Morph transition coordinates to animate travel between popouts during switch. + property bool morphTravelEnabled: false + property real morphSeedX: 0 + property real morphSeedY: 0 + property real morphSeedW: 0 + property real morphSeedH: 0 + property real morphProgress: 1 + // Distance-scaled duration for morph travel. + property int _morphTravelDuration: animationDuration + + Behavior on morphProgress { + enabled: root.morphTravelEnabled && root.animationsEnabled + NumberAnimation { + duration: root._morphTravelDuration + easing.type: Easing.BezierSpline + // M3 Expressive spatial motion starts with momentum and settles gently, + // which keeps rapid hover retargets from pausing between surfaces. + easing.bezierCurve: Theme.variantEnterCurve + } + } + + readonly property real pubBodyX: morphSeedX + (alignedX - morphSeedX) * morphProgress + readonly property real pubBodyY: morphSeedY + (renderedAlignedY - morphSeedY) * morphProgress + readonly property real pubBodyW: morphSeedW + (alignedWidth - morphSeedW) * morphProgress + readonly property real pubBodyH: morphSeedH + (renderedAlignedHeight - morphSeedH) * morphProgress + + // One animation drives all four coordinates, so queue one coalesced state update + // per progress tick instead of reacting independently to each derived property. + onMorphProgressChanged: _queueBodySync() + + function _beginMorphTravel() { + morphTravelEnabled = false; + morphProgress = 1; + if (!root.frameOwnsConnectedChrome || !root.screen) + return; + if (!root.hoverDismissEnabled) + return; + if (ConnectedModeState.popoutScreen !== root.screen.name) + return; + if (!ConnectedModeState.popoutOwnerId || ConnectedModeState.popoutOwnerId === chromeLease.claimId) + return; + const w = ConnectedModeState.popoutBodyW; + const h = ConnectedModeState.popoutBodyH; + if (!(w > 0 && h > 0)) + return; + morphSeedX = ConnectedModeState.popoutBodyX; + morphSeedY = ConnectedModeState.popoutBodyY; + morphSeedW = w; + morphSeedH = h; + // Scale spatial motion with both travel and shape change. Never shorten the + // configured enter duration; cap long sweeps so hover switching stays responsive. + const base = Math.max(0, Theme.variantDuration(root.animationDuration, true)); + const travel = Math.hypot(root.alignedX - morphSeedX, root.renderedAlignedY - morphSeedY); + const resize = Math.hypot(root.alignedWidth - morphSeedW, root.renderedAlignedHeight - morphSeedH); + const spatialDistance = travel + resize * 0.35; + _morphTravelDuration = Math.round(Math.min(base * 1.6, base + spatialDistance * 0.15)); + morphProgress = 0; + morphTravelEnabled = true; + Qt.callLater(() => { + if (root.shouldBeVisible) + root.morphProgress = 1; + }); + } + + function _freezeMorphTravel() { + const x = pubBodyX; + const y = pubBodyY; + const w = pubBodyW; + const h = pubBodyH; + + // A third hover can supersede a morph before it settles. Freeze the outgoing + // content at the live rectangle so it fades in place while the next surface + // inherits exactly the same geometry. + morphTravelEnabled = false; + morphSeedX = x; + morphSeedY = y; + morphSeedW = w; + morphSeedH = h; + morphProgress = 0; + morphTravelEnabled = true; + _syncPopoutBody(); + } + + function _endMorphTravel() { + morphTravelEnabled = false; + morphProgress = 1; + morphSeedX = 0; + morphSeedY = 0; + morphSeedW = 0; + morphSeedH = 0; + } + + // Flag to trigger in-place fade-out during a morph switch. + property bool _supersededClose: false + + function beginSupersededClose() { + // Only set superseded flag for transient hover switches. + if (frameOwnsConnectedChrome && hoverDismissEnabled) + _supersededClose = true; + } + readonly property real connectedAnchorX: { if (!root.usesConnectedSurfaceChrome) return triggerX; @@ -761,6 +934,15 @@ Item { visible: false color: "transparent" + PopoutHoverDismiss { + id: hoverDismissController + anchors.fill: parent + dismissEnabled: root.hoverDismissEnabled + dismissSuspended: root.hoverDismissSuspended + surfaceVisible: root.shouldBeVisible + onDismissRequested: root.closeFromHoverDismiss() + } + WindowBlur { id: popoutBlur targetWindow: contentWindow @@ -842,10 +1024,11 @@ Item { Item { id: contentContainer - x: root.alignedX - y: root.renderedAlignedY - width: root.alignedWidth - height: root.renderedAlignedHeight + // Follow the morphing body bounds during transition. + x: root.morphTravelEnabled ? root.pubBodyX : root.alignedX + y: root.morphTravelEnabled ? root.pubBodyY : root.renderedAlignedY + width: root.morphTravelEnabled ? root.pubBodyW : root.alignedWidth + height: root.morphTravelEnabled ? root.pubBodyH : root.renderedAlignedHeight readonly property bool barTop: effectiveBarPosition === SettingsData.Position.Top readonly property bool barBottom: effectiveBarPosition === SettingsData.Position.Bottom @@ -914,6 +1097,11 @@ Item { readonly property real computedScaleCollapsed: root.animationScaleCollapsed + PopoutHoverBodyTracker { + controller: hoverDismissController + trackingEnabled: root.hoverDismissEnabled && root.shouldBeVisible + } + QtObject { id: morph property real openProgress: 0 @@ -941,7 +1129,8 @@ Item { target: root function onShouldBeVisibleChanged() { root._captureChromeAnimTravel(); - morph.openProgress = root.shouldBeVisible ? 1 : 0; + // Skip reverse emerge animation during a superseded close. + morph.openProgress = (root.shouldBeVisible || root._supersededClose) ? 1 : 0; } } @@ -1067,23 +1256,27 @@ Item { property bool _renderActive: Theme.isDirectionalEffect || shouldBeVisible property bool _animating: false - property real publishedOpacity: Theme.isDirectionalEffect ? 1 : (shouldBeVisible ? 1 : 0) + readonly property bool _fadeWithOpacity: !Theme.isDirectionalEffect || root._supersededClose + // Fast fade duration for superseded close. + readonly property bool _supersededFade: root._supersededClose && !root.shouldBeVisible + readonly property real _targetOpacity: root._supersededClose ? (root.shouldBeVisible ? 1 : 0) : (Theme.isDirectionalEffect ? 1 : (root.shouldBeVisible ? 1 : 0)) + property real publishedOpacity: _targetOpacity - opacity: Theme.isDirectionalEffect ? 1 : (shouldBeVisible ? 1 : 0) + opacity: _targetOpacity visible: _renderActive scale: 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: _animating || (!Theme.isDirectionalEffect && publishedOpacity < 1) + layer.enabled: _animating || (_fadeWithOpacity && publishedOpacity < 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 + enabled: contentWrapper._fadeWithOpacity NumberAnimation { - duration: Math.round(Theme.variantDuration(animationDuration, shouldBeVisible) * Theme.variantOpacityDurationScale) + duration: contentWrapper._supersededFade ? Theme.shorterDuration : Math.round(Theme.variantDuration(animationDuration, shouldBeVisible) * Theme.variantOpacityDurationScale) easing.type: Easing.BezierSpline easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve onRunningChanged: { @@ -1095,9 +1288,9 @@ Item { } Behavior on publishedOpacity { - enabled: !Theme.isDirectionalEffect + enabled: contentWrapper._fadeWithOpacity NumberAnimation { - duration: Math.round(Theme.variantDuration(animationDuration, shouldBeVisible) * Theme.variantOpacityDurationScale) + duration: contentWrapper._supersededFade ? Theme.shorterDuration : Math.round(Theme.variantDuration(animationDuration, shouldBeVisible) * Theme.variantOpacityDurationScale) easing.type: Easing.BezierSpline easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve } diff --git a/quickshell/Widgets/DankPopoutStandalone.qml b/quickshell/Widgets/DankPopoutStandalone.qml index 3f09c3f7..4fd3f3fa 100644 --- a/quickshell/Widgets/DankPopoutStandalone.qml +++ b/quickshell/Widgets/DankPopoutStandalone.qml @@ -5,6 +5,7 @@ import Quickshell import Quickshell.Wayland import qs.Common import qs.Services +import qs.Widgets Item { id: root @@ -35,6 +36,22 @@ Item { property bool shouldBeVisible: false property bool isClosing: false property bool animationsEnabled: true + property bool hoverDismissEnabled: false + property bool hoverDismissSuspended: false + + function cancelHoverDismiss() { + hoverDismissController.cancelPending(); + } + + function closeFromHoverDismiss() { + if (hoverDismissSuspended || isClosing || !shouldBeVisible) + return; + if (popoutHandle?.closeFromHoverDismiss) + popoutHandle.closeFromHoverDismiss(); + else + close(); + } + property var customKeyboardFocus: null property bool backgroundInteractive: true property bool contentHandlesKeys: false @@ -585,6 +602,17 @@ Item { color: "transparent" readonly property bool closeVisualActive: root.shouldBeVisible || root.isClosing + PopoutHoverDismiss { + id: hoverDismissController + anchors.fill: parent + dismissEnabled: root.hoverDismissEnabled + dismissSuspended: root.hoverDismissSuspended + surfaceVisible: root.shouldBeVisible + globalOffsetX: root._surfaceMarginLeft + globalOffsetY: root._fullHeight ? 0 : root._surfaceMarginTop + onDismissRequested: root.closeFromHoverDismiss() + } + WindowBlur { id: popoutBlur targetWindow: contentWindow @@ -702,6 +730,11 @@ Item { readonly property real computedScaleCollapsed: root.animationScaleCollapsed + PopoutHoverBodyTracker { + controller: hoverDismissController + trackingEnabled: root.hoverDismissEnabled && root.shouldBeVisible + } + // openProgress: 0 = closed (at offset, scaleCollapsed), 1 = open (at 0, scale 1). QtObject { id: morph diff --git a/quickshell/Widgets/DankSlideout.qml b/quickshell/Widgets/DankSlideout.qml index fb583260..baf64b07 100644 --- a/quickshell/Widgets/DankSlideout.qml +++ b/quickshell/Widgets/DankSlideout.qml @@ -13,6 +13,8 @@ PanelWindow { WlrLayershell.namespace: layerNamespace property bool isVisible: false + property bool hoverDismissEnabled: false + property bool hoverDismissSuspended: false property var targetScreen: null property var modelData: null property bool triggerUsesOverlayLayer: false @@ -25,6 +27,7 @@ PanelWindow { property real edgeGap: 0 property string slideEdge: "right" readonly property bool slideFromLeft: slideEdge === "left" + readonly property real surfaceOriginX: slideFromLeft ? 0 : Math.max(0, (modelData?.width ?? width) - width) property Component content: null property string title: "" property alias container: contentContainer @@ -46,6 +49,27 @@ PanelWindow { isVisible = false; } + function hideFromHoverDismiss() { + if (hoverDismissSuspended) + return; + hoverDismissEnabled = false; + slideAnimation.duration = Math.round(Theme.expressiveDurations.expressiveDefaultSpatial); + hide(); + } + + function cancelHoverDismiss() { + hoverDismissTracker.cancelPending(); + } + + function containsGlobalPoint(gx, gy) { + if (!isVisible || !modelData) + return false; + const padding = 24; + const topLeft = slideContainer.mapToItem(null, 0, 0); + const globalX = surfaceOriginX + topLeft.x; + return gx >= globalX - padding && gx < globalX + slideContainer.width + padding && gy >= topLeft.y - padding && gy < topLeft.y + slideContainer.height + padding; + } + function toggle() { if (isVisible) { hide(); @@ -67,6 +91,17 @@ PanelWindow { color: "transparent" + HoverDismissTracker { + id: hoverDismissTracker + parent: root.contentItem + enabled: root.hoverDismissEnabled && !root.hoverDismissSuspended && root.isVisible + shouldDismiss: function () { + return !PopoutManager.cursorOverBar(PopoutManager.hoverCursorGlobalX, PopoutManager.hoverCursorGlobalY); + } + onDismissRequested: root.hideFromHoverDismiss() + onHoverMoved: (sceneX, sceneY) => PopoutManager.updateHoverCursor(root.surfaceOriginX + sceneX, sceneY) + } + readonly property bool slideoutBlurActive: root.visible && BlurService.enabled && Theme.connectedSurfaceBlurEnabled WlrLayershell.layer: (!suppressOverlayLayer && (triggerUsesOverlayLayer || CompositorService.framePeerSurfacesUseOverlayForScreen(modelData))) ? WlrLayershell.Overlay : WlrLayershell.Top @@ -117,8 +152,10 @@ PanelWindow { easing.type: Easing.OutCubic onRunningChanged: { - if (!running && !root.isVisible) { - root.mappedVisible = false; + if (!running) { + if (!root.isVisible) + root.mappedVisible = false; + slideAnimation.duration = 450; } } } diff --git a/quickshell/Widgets/HoverDismissTracker.qml b/quickshell/Widgets/HoverDismissTracker.qml new file mode 100644 index 00000000..f4922c90 --- /dev/null +++ b/quickshell/Widgets/HoverDismissTracker.qml @@ -0,0 +1,30 @@ +pragma ComponentBehavior: Bound + +import QtQuick + +HoverHandler { + id: root + + property var shouldDismiss: null + + signal dismissRequested + // Emitted on every hover move; passive to avoid blocking overlapping MouseAreas + signal hoverMoved(real gx, real gy) + + onPointChanged: { + if (!enabled || !hovered) + return; + const gp = parent.mapToItem(null, point.position.x, point.position.y); + hoverMoved(gp.x, gp.y); + } + onHoveredChanged: { + if (hovered || !enabled) + return; + if (typeof shouldDismiss === "function" && !shouldDismiss()) + return; + dismissRequested(); + } + + function cancelPending() { + } +} diff --git a/quickshell/Widgets/PopoutHoverBodyTracker.qml b/quickshell/Widgets/PopoutHoverBodyTracker.qml new file mode 100644 index 00000000..2e73f769 --- /dev/null +++ b/quickshell/Widgets/PopoutHoverBodyTracker.qml @@ -0,0 +1,25 @@ +pragma ComponentBehavior: Bound + +import QtQuick + +HoverHandler { + id: root + + required property var controller + property bool trackingEnabled: false + + enabled: trackingEnabled + + onTrackingEnabledChanged: { + if (!trackingEnabled) + controller.updateBodyHover(false); + } + + onHoveredChanged: controller.updateBodyHover(hovered) + onPointChanged: { + if (!hovered) + return; + const gp = parent.mapToItem(null, point.position.x, point.position.y); + controller.updateCursor(gp.x, gp.y); + } +} diff --git a/quickshell/Widgets/PopoutHoverDismiss.qml b/quickshell/Widgets/PopoutHoverDismiss.qml new file mode 100644 index 00000000..4bddbc6a --- /dev/null +++ b/quickshell/Widgets/PopoutHoverDismiss.qml @@ -0,0 +1,75 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common + +Item { + id: root + + required property bool dismissEnabled + required property bool dismissSuspended + required property bool surfaceVisible + + property int graceInterval: 150 + property bool bodyHovered: false + property real globalOffsetX: 0 + property real globalOffsetY: 0 + + signal dismissRequested + + function cancelPending() { + graceTimer.stop(); + hoverTracker.cancelPending(); + } + + function updateBodyHover(over) { + bodyHovered = over; + if (over) { + graceTimer.stop(); + } else if (dismissEnabled && !dismissSuspended && surfaceVisible) { + graceTimer.restart(); + } + } + + function updateCursor(sceneX, sceneY) { + PopoutManager.updateHoverCursor(sceneX + globalOffsetX, sceneY + globalOffsetY); + } + + onDismissEnabledChanged: { + if (!dismissEnabled) + cancelPending(); + } + onDismissSuspendedChanged: { + if (dismissSuspended) + graceTimer.stop(); + else if (dismissEnabled && surfaceVisible && !bodyHovered) + graceTimer.restart(); + } + onSurfaceVisibleChanged: { + if (!surfaceVisible) + cancelPending(); + } + + Timer { + id: graceTimer + interval: root.graceInterval + repeat: false + onTriggered: { + if (!root.dismissEnabled || root.dismissSuspended || !root.surfaceVisible || root.bodyHovered) + return; + if (PopoutManager.cursorOverBar(PopoutManager.hoverCursorGlobalX, PopoutManager.hoverCursorGlobalY)) + return; + root.dismissRequested(); + } + } + + HoverDismissTracker { + id: hoverTracker + enabled: root.dismissEnabled && !root.dismissSuspended && root.surfaceVisible + shouldDismiss: function () { + return !PopoutManager.cursorOverBar(PopoutManager.hoverCursorGlobalX, PopoutManager.hoverCursorGlobalY); + } + onDismissRequested: root.dismissRequested() + onHoverMoved: (gx, gy) => root.updateCursor(gx, gy) + } +} diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index 552b93cc..11822d5c 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -362,7 +362,7 @@ "wallpaper" ], "icon": "wallpaper", - "description": "Color shown for areas not covered by wallpaper (e.g. Fit or Pad modes)" + "description": "Color shown for areas not covered by wallpaper" }, { "section": "selectedMonitor", @@ -8139,6 +8139,36 @@ ], "icon": "monitor" }, + { + "section": "frameLauncherEdgeHover", + "label": "Edge Hover Reveal", + "tabIndex": 33, + "category": "Frame", + "keywords": [ + "app drawer", + "app menu", + "applications", + "border", + "connected", + "decoration", + "edge", + "emerge", + "frame", + "free", + "hover", + "hovering", + "launcher", + "open", + "panel", + "reveal", + "start menu", + "statusbar", + "taskbar", + "topbar", + "window" + ], + "description": "Open the launcher by hovering the emerge edge (when free of bar and dock)" + }, { "section": "frameEnable", "label": "Enable Frame",