diff --git a/quickshell/Common/PopoutManager.qml b/quickshell/Common/PopoutManager.qml index 90a49444..1c821e2a 100644 --- a/quickshell/Common/PopoutManager.qml +++ b/quickshell/Common/PopoutManager.qml @@ -186,6 +186,14 @@ 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; + } + function isCurrentPopout(popout, screenName) { const name = screenName || popout?.screen?.name || ""; return !!name && currentPopoutsByScreen[name] === popout; @@ -194,6 +202,8 @@ Singleton { function requestPopout(popout, tabIndex, triggerSource) { if (!popout || !popout.screen) return; + // Clicking a hover popout pins it open rather than toggling it closed + const wasTransient = popout.hoverDismissEnabled === true; if (popout.hoverDismissEnabled !== undefined) popout.hoverDismissEnabled = false; screenshotActive = false; @@ -240,13 +250,17 @@ Singleton { } if (currentPopout === popout && popout.shouldBeVisible && !movedFromOtherScreen) { - if (triggerId !== undefined && currentPopoutTriggers[screenName] === triggerId) { - _closePopout(popout); - return; - } + const sameTrigger = triggerId === undefined || currentPopoutTriggers[screenName] === triggerId; - if (triggerId === undefined) { - _closePopout(popout); + if (sameTrigger) { + if (!wasTransient) { + _closePopout(popout); + return; + } + if (popout.updateSurfacePosition) + popout.updateSurfacePosition(); + if (triggerId !== undefined) + currentPopoutTriggers[screenName] = triggerId; return; } @@ -315,6 +329,9 @@ Singleton { currentPopoutsByScreen[screenName] = null; currentPopoutTriggers[screenName] = null; } else { + // Signal the active popout to fade in-place when morphed + if (typeof currentPopout.beginSupersededClose === "function") + currentPopout.beginSupersededClose(); _closePopout(currentPopout); } } diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 077549a1..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() { @@ -1000,7 +1002,8 @@ Singleton { "shadowColorMode": "default", "shadowCustomColor": "#000000", "clickThrough": false, - "hoverPopouts": false + "hoverPopouts": false, + "hoverPopoutDelay": 150 } ] @@ -2437,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..58f80692 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -642,6 +642,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/DankBar/DankBarContent.qml b/quickshell/Modules/DankBar/DankBarContent.qml index 74e82701..b9dde7e5 100644 --- a/quickshell/Modules/DankBar/DankBarContent.qml +++ b/quickshell/Modules/DankBar/DankBarContent.qml @@ -385,6 +385,36 @@ Item { property real _lastHoverGlobalY: 0 readonly property bool hoverPopoutsEnabled: barConfig?.hoverPopouts ?? false + readonly property int hoverPopoutDelay: Math.max(0, barConfig?.hoverPopoutDelay ?? 150) + + // Clean up hover state and close transient popouts when the hover feature is disabled. + onHoverPopoutsEnabledChanged: { + if (hoverPopoutsEnabled) + return; + _cancelPendingHover(); + _hoverCloseTimer.stop(); + if (hasOpenHoverSurface() && !PopoutManager.isActivePopoutPinned(barWindow?.screen)) + closeHoverSurfaces(); + activeHoverTrigger = ""; + } + + property var _pendingHoverHit: null + property string _pendingHoverTrigger: "" + + Timer { + id: _hoverIntentTimer + interval: topBarContent.hoverPopoutDelay + repeat: false + onTriggered: topBarContent._commitPendingHover() + } + + // Grace timer to prevent flicker when crossing gaps. + Timer { + id: _hoverCloseTimer + interval: 120 + repeat: false + onTriggered: topBarContent._commitHoverClose() + } function getBarPosition() { return barWindow.axis?.edge === "left" ? 2 : (barWindow.axis?.edge === "right" ? 3 : (barWindow.axis?.edge === "top" ? 0 : 1)); @@ -435,18 +465,7 @@ Item { 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] - ]; + 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]; @@ -803,6 +822,13 @@ Item { TrayMenuManager.closeAllMenus(); } + // Fade out the active popout in-place during morph switch transitions. + 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") @@ -981,10 +1007,14 @@ Item { PopoutManager.updateHoverCursor(gx, gy); _syncHoverTriggerState(); + // Ignore hover events when a popout is pinned open. + if (PopoutManager.isActivePopoutPinned(barWindow?.screen)) + return; + const hit = findWidgetAtGlobalPoint(gx, gy); if (!hit) { - if (!cursorOverHoverChain(gx, gy)) - closeHoverSurfaces(); + _cancelPendingHover(); + scheduleHoverClose(gx, gy); return; } @@ -1002,16 +1032,97 @@ Item { triggerKey = _dashTriggerSource(hit.section, 3); if (!triggerKey) { - if (!cursorOverHoverChain(gx, gy)) - closeHoverSurfaces(); + _cancelPendingHover(); + scheduleHoverClose(gx, gy); return; } - if (triggerKey === activeHoverTrigger && hasOpenHoverSurface()) + _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 = ""; + } + + // Maps widgets to their loaders to support in-place switching between triggers sharing a popout. + function _loaderForWidgetId(widgetId) { + switch (widgetId) { + case "launcherButton": + return appDrawerLoader; + case "clipboard": + return clipboardHistoryPopoutLoader; + case "clock": + case "music": + case "weather": + return dankDashPopoutLoader; + case "cpuUsage": + case "memUsage": + case "cpuTemp": + case "gpuTemp": + return processListPopoutLoader; + case "notificationButton": + return notificationCenterLoader; + case "battery": + return batteryPopoutLoader; + case "layout": + return layoutPopoutLoader; + case "vpn": + return vpnPopoutLoader; + case "controlCenterButton": + return controlCenterLoader; + case "systemUpdate": + return systemUpdateLoader; + default: + return null; + } + } + + function _hitTargetsActivePopout(hit) { + const active = PopoutManager.getActivePopout(barWindow?.screen); + if (!active || !hit) + return false; + const loader = _loaderForWidgetId(hit.widgetId); + if (!loader) + return false; + return _resolvePopoutFromLoader(loader) === active; + } + + function _commitPendingHover() { + const hit = _pendingHoverHit; + const triggerKey = _pendingHoverTrigger; + _pendingHoverHit = null; + _pendingHoverTrigger = ""; + if (!hit || !hoverPopoutsEnabled) + return; + if (PopoutManager.isActivePopoutPinned(barWindow?.screen)) + return; + // Cursor may have left the bar before the timer fired. + if (!PopoutManager.cursorOverBar(_lastHoverGlobalX, _lastHoverGlobalY)) return; - if (triggerKey !== activeHoverTrigger && activeHoverTrigger !== "") + // A different trigger backed by the same already-open popout swaps tab/position + // in place (requestHoverPopout handles it) — don't close+reopen the same surface. + if (triggerKey !== activeHoverTrigger && activeHoverTrigger !== "" && !_hitTargetsActivePopout(hit)) { + // Mark popout as superseded to fade in-place before closing. + _beginSupersededCloseForActive(); closeHoverSurfaces(); + } if (!openHoverPopoutForHit(hit)) { if (activeHoverTrigger !== "") @@ -1022,6 +1133,27 @@ Item { activeHoverTrigger = triggerKey; } + function scheduleHoverClose(gx, gy) { + _cancelPendingHover(); + if (!hoverPopoutsEnabled) + return; + if (PopoutManager.isActivePopoutPinned(barWindow?.screen)) + return; + if (cursorOverHoverChain(gx, gy)) + return; + _hoverCloseTimer.restart(); + } + + function _commitHoverClose() { + const gx = PopoutManager.hoverCursorGlobalX; + const gy = PopoutManager.hoverCursorGlobalY; + if (PopoutManager.isActivePopoutPinned(barWindow?.screen)) + return; + if (cursorOverHoverChain(gx, gy)) + return; + closeHoverSurfaces(); + } + readonly property var widgetVisibility: ({ "cpuUsage": DgopService.dgopAvailable, "memUsage": DgopService.dgopAvailable, diff --git a/quickshell/Modules/DankBar/DankBarWindow.qml b/quickshell/Modules/DankBar/DankBarWindow.qml index 378937c0..656bb4e1 100644 --- a/quickshell/Modules/DankBar/DankBarWindow.qml +++ b/quickshell/Modules/DankBar/DankBarWindow.qml @@ -1111,33 +1111,25 @@ PanelWindow { rightWidgetsModel: barWindow.rightWidgetsModel } - MouseArea { - id: hoverPopoutArea - anchors.fill: parent - z: 1 - hoverEnabled: barConfig?.hoverPopouts ?? false - enabled: hoverPopoutArea.hoverEnabled && !barWindow.clickThroughEnabled - acceptedButtons: Qt.NoButton - propagateComposedEvents: true + // 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 - onPositionChanged: mouse => { - const gp = mapToItem(null, mouse.x, mouse.y); + onPointChanged: { + const gp = barUnitInset.mapToItem(null, point.position.x, point.position.y); lastGlobalX = gp.x; lastGlobalY = gp.y; topBarContent.checkHoverPopout(gp.x, gp.y); } - onWheel: wheel => scrollArea.processWheel(wheel) - - onContainsMouseChanged: { - if (containsMouse) + onHoveredChanged: { + if (hovered) return; - if (topBarContent.cursorOverHoverChain(lastGlobalX, lastGlobalY)) - return; - topBarContent.closeHoverSurfaces(); + topBarContent.scheduleHoverClose(lastGlobalX, lastGlobalY); } } } 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/Settings/DankBarTab.qml b/quickshell/Modules/Settings/DankBarTab.qml index f4aa6eef..28e484fd 100644 --- a/quickshell/Modules/Settings/DankBarTab.qml +++ b/quickshell/Modules/Settings/DankBarTab.qml @@ -1811,6 +1811,37 @@ Item { 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 { 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/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 21c40657..a81904d2 100644 --- a/quickshell/Widgets/DankPopout.qml +++ b/quickshell/Widgets/DankPopout.qml @@ -180,20 +180,22 @@ Item { impl.item.cancelHoverDismiss(); } + // Fade out in place during morph switch transitions. + function beginSupersededClose() { + if (impl.item?.beginSupersededClose) + impl.item.beginSupersededClose(); + } + function closeFromHoverDismiss() { hoverDismissEnabled = false; - if (impl.item) { + // Enable animations using standard Theme-bound popout motion to preserve bindings. + if (impl.item) impl.item.animationsEnabled = true; - impl.item.animationDuration = Math.round(Theme.expressiveDurations.expressiveDefaultSpatial); - impl.item.animationExitCurve = Theme.expressiveCurves.expressiveDefaultSpatial; - } - if (dashVisible !== undefined) { - dashVisible = false; - return; - } - if (notificationHistoryVisible !== undefined) { - notificationHistoryVisible = false; - return; + for (const prop of ["dashVisible", "notificationHistoryVisible"]) { + if (root[prop] !== undefined) { + root[prop] = false; + return; + } } if (impl.item) impl.item.close(); diff --git a/quickshell/Widgets/DankPopoutConnected.qml b/quickshell/Widgets/DankPopoutConnected.qml index 25ad030d..3c39c300 100644 --- a/quickshell/Widgets/DankPopoutConnected.qml +++ b/quickshell/Widgets/DankPopoutConnected.qml @@ -235,10 +235,10 @@ Item { const presented = contentWindow.visible || root.shouldBeVisible; const phase = root.isClosing ? "closing" : (!presented ? "hidden" : (!contentWindow.visible && root.shouldBeVisible ? "opening" : "open")); const bodyRect = { - "x": root.alignedX, - "y": root.renderedAlignedY, - "width": root.alignedWidth, - "height": root.renderedAlignedHeight + "x": root.pubBodyX, + "y": root.pubBodyY, + "width": root.pubBodyW, + "height": root.pubBodyH }; const animationOffset = { "x": _connectedChromeAnimX(), @@ -255,10 +255,10 @@ Item { "animationOffset": animationOffset, "scale": 1, "opacity": Theme.connectedSurfaceColor.a, - "bodyX": root.alignedX, - "bodyY": root.renderedAlignedY, - "bodyW": root.alignedWidth, - "bodyH": root.renderedAlignedHeight, + "bodyX": root.pubBodyX, + "bodyY": root.pubBodyY, + "bodyW": root.pubBodyW, + "bodyH": root.pubBodyH, "animX": animationOffset.x, "animY": animationOffset.y, "screen": root.screen ? root.screen.name : "", @@ -312,7 +312,7 @@ Item { return; if (!contentWindow.visible && !shouldBeVisible) return; - chromeLease.updateBody(root.alignedX, root.renderedAlignedY, root.alignedWidth, root.renderedAlignedHeight); + chromeLease.updateBody(root.pubBodyX, root.pubBodyY, root.pubBodyW, root.pubBodyH); } property bool _animSyncQueued: false @@ -430,6 +430,7 @@ Item { isClosing = false; animationsEnabled = false; _primeContent = true; + _supersededClose = false; const screenChanged = _lastOpenedScreen !== null && _lastOpenedScreen !== screen; if (screenChanged) { @@ -444,6 +445,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); @@ -471,6 +479,7 @@ Item { } function close() { + _endMorphTravel(); isClosing = true; shouldBeVisible = false; _primeContent = false; @@ -657,6 +666,88 @@ 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 + // Emphasized curve for fluid morph travel. + easing.bezierCurve: Theme.expressiveCurves.emphasized + } + } + + 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 + + onPubBodyXChanged: _queueBodySync() + onPubBodyYChanged: _queueBodySync() + onPubBodyWChanged: _queueBodySync() + onPubBodyHChanged: _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 travel time with distance within ~[0.8x, 1.4x] of the popout duration: + // enough room for the emphasized curve to breathe (fluid, not abrupt), capped so + // long sweeps don't drag, and collapsing to 0 when popout animations are off. + const base = Math.max(0, root.animationDuration); + const dist = Math.hypot(root.alignedX - morphSeedX, root.renderedAlignedY - morphSeedY); + _morphTravelDuration = Math.round(Math.min(base * 1.4, base * 0.8 + dist * 0.16)); + morphProgress = 0; + morphTravelEnabled = true; + Qt.callLater(() => { + if (root.shouldBeVisible) + root.morphProgress = 1; + }); + } + + 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; @@ -758,6 +849,32 @@ Item { readonly property real maskWidth: _dismissZone.width readonly property real maskHeight: _dismissZone.height + // Track body hover to initiate grace timer for transient dismissal. + property bool _hoverOverBody: false + + function _onBodyHoverChanged(over) { + _hoverOverBody = over; + if (over) + _hoverDismissGrace.stop(); + else if (root.hoverDismissEnabled && root.shouldBeVisible) + _hoverDismissGrace.restart(); + } + + Timer { + id: _hoverDismissGrace + interval: 150 + repeat: false + onTriggered: { + if (!root.hoverDismissEnabled || !root.shouldBeVisible) + return; + if (root._hoverOverBody) + return; + if (PopoutManager.cursorOverBar(PopoutManager.hoverCursorGlobalX, PopoutManager.hoverCursorGlobalY)) + return; + root.closeFromHoverDismiss(); + } + } + DismissZone { id: _dismissZone barPosition: root.effectiveBarPosition @@ -795,6 +912,7 @@ Item { return !PopoutManager.cursorOverBar(PopoutManager.hoverCursorGlobalX, PopoutManager.hoverCursorGlobalY); } onDismissRequested: root.closeFromHoverDismiss() + onHoverMoved: (gx, gy) => PopoutManager.updateHoverCursor(gx, gy) } WindowBlur { @@ -878,10 +996,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 @@ -950,6 +1069,19 @@ Item { readonly property real computedScaleCollapsed: root.animationScaleCollapsed + // Ancestor HoverHandler to capture body hover reliably. + HoverHandler { + id: bodyHoverHandler + enabled: root.hoverDismissEnabled && root.shouldBeVisible + onHoveredChanged: root._onBodyHoverChanged(hovered) + onPointChanged: { + if (!bodyHoverHandler.hovered) + return; + const gp = contentContainer.mapToItem(null, bodyHoverHandler.point.position.x, bodyHoverHandler.point.position.y); + PopoutManager.updateHoverCursor(gp.x, gp.y); + } + } + QtObject { id: morph property real openProgress: 0 @@ -977,7 +1109,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; } } @@ -1103,23 +1236,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: { @@ -1131,9 +1268,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 a5100361..c57c7a14 100644 --- a/quickshell/Widgets/DankPopoutStandalone.qml +++ b/quickshell/Widgets/DankPopoutStandalone.qml @@ -51,6 +51,32 @@ Item { close(); } + // Track body hover to initiate grace timer for transient dismissal. + property bool _hoverOverBody: false + + function _onBodyHoverChanged(over) { + _hoverOverBody = over; + if (over) + _hoverDismissGrace.stop(); + else if (root.hoverDismissEnabled && root.shouldBeVisible) + _hoverDismissGrace.restart(); + } + + Timer { + id: _hoverDismissGrace + interval: 150 + repeat: false + onTriggered: { + if (!root.hoverDismissEnabled || !root.shouldBeVisible) + return; + if (root._hoverOverBody) + return; + if (PopoutManager.cursorOverBar(PopoutManager.hoverCursorGlobalX, PopoutManager.hoverCursorGlobalY)) + return; + root.closeFromHoverDismiss(); + } + } + property var customKeyboardFocus: null property bool backgroundInteractive: true property bool contentHandlesKeys: false @@ -620,6 +646,7 @@ Item { return !PopoutManager.cursorOverBar(PopoutManager.hoverCursorGlobalX, PopoutManager.hoverCursorGlobalY); } onDismissRequested: root.closeFromHoverDismiss() + onHoverMoved: (gx, gy) => PopoutManager.updateHoverCursor(gx, gy) } WindowBlur { @@ -739,6 +766,19 @@ Item { readonly property real computedScaleCollapsed: root.animationScaleCollapsed + // Ancestor HoverHandler to capture body hover reliably. + HoverHandler { + id: bodyHoverHandler + enabled: root.hoverDismissEnabled && root.shouldBeVisible + onHoveredChanged: root._onBodyHoverChanged(hovered) + onPointChanged: { + if (!bodyHoverHandler.hovered) + return; + const gp = contentContainer.mapToItem(null, bodyHoverHandler.point.position.x, bodyHoverHandler.point.position.y); + PopoutManager.updateHoverCursor(gp.x, gp.y); + } + } + // openProgress: 0 = closed (at offset, scaleCollapsed), 1 = open (at 0, scale 1). QtObject { id: morph diff --git a/quickshell/Widgets/HoverDismissTracker.qml b/quickshell/Widgets/HoverDismissTracker.qml index 1f2cc9d9..bb51d7ca 100644 --- a/quickshell/Widgets/HoverDismissTracker.qml +++ b/quickshell/Widgets/HoverDismissTracker.qml @@ -9,12 +9,20 @@ Item { property var shouldDismiss: null signal dismissRequested + // Emitted on every hover move; passive to avoid blocking overlapping MouseAreas + signal hoverMoved(real gx, real gy) anchors.fill: parent HoverHandler { id: hoverHandler enabled: root.enabled + onPointChanged: { + if (!root.enabled || !hoverHandler.hovered) + return; + const gp = root.mapToItem(null, hoverHandler.point.position.x, hoverHandler.point.position.y); + root.hoverMoved(gp.x, gp.y); + } onHoveredChanged: { if (hoverHandler.hovered || !root.enabled) return; @@ -24,5 +32,6 @@ Item { } } - function cancelPending() {} + function cancelPending() { + } }