From 601d4104a3616fd4c1871ec92a5cda7c0cc235a2 Mon Sep 17 00:00:00 2001 From: purian23 Date: Sat, 27 Jun 2026 01:59:45 -0400 Subject: [PATCH] feat(popouts): hover & settings cleanup --- core/.pre-commit-config.yaml | 2 +- quickshell/Common/PopoutManager.qml | 2 +- .../ControlCenter/ControlCenterPopout.qml | 1 + quickshell/Modules/DankBar/DankBarContent.qml | 43 ++++++++- .../Modules/ProcessList/ProcessListPopout.qml | 13 +++ quickshell/Modules/Settings/DankBarTab.qml | 88 +++++++++---------- quickshell/Modules/Settings/WallpaperTab.qml | 2 +- quickshell/Widgets/DankPopout.qml | 4 + quickshell/Widgets/DankPopoutConnected.qml | 68 ++++++++++---- quickshell/Widgets/DankPopoutStandalone.qml | 18 +++- .../translations/settings_search_index.json | 32 ++++++- 11 files changed, 200 insertions(+), 73 deletions(-) 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/quickshell/Common/PopoutManager.qml b/quickshell/Common/PopoutManager.qml index 1c821e2a..6586b9b5 100644 --- a/quickshell/Common/PopoutManager.qml +++ b/quickshell/Common/PopoutManager.qml @@ -191,7 +191,7 @@ Singleton { const p = getActivePopout(screen); if (!p || !_isPopoutPresented(p)) return false; - return p.hoverDismissEnabled === false; + return p.hoverDismissEnabled === false || p.hoverDismissSuspended === true; } function isCurrentPopout(popout, screenName) { 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 b9dde7e5..26ecacfa 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 { @@ -401,6 +409,19 @@ Item { property var _pendingHoverHit: null property string _pendingHoverTrigger: "" + function resetHoverForBarGeometryChange() { + _cancelPendingHover(); + _hoverCloseTimer.stop(); + _pendingPopoutOpenSpec = null; + + const activePopout = PopoutManager.getActivePopout(barWindow?.screen); + const hasTransientSurface = activeHoverTrigger !== "" || activePopout?.hoverDismissEnabled === true; + if (hasTransientSurface && !PopoutManager.isActivePopoutPinned(barWindow?.screen)) + closeHoverSurfaces(); + else + activeHoverTrigger = ""; + } + Timer { id: _hoverIntentTimer interval: topBarContent.hoverPopoutDelay @@ -525,6 +546,9 @@ Item { } } + if (typeof popout.prepareForTrigger === "function") + popout.prepareForTrigger(spec.triggerSource, mode); + if (spec.prepare) spec.prepare(popout); @@ -859,6 +883,10 @@ Item { const inst = _notepadWidgetForScreen()?.notepadInstance; return inst?.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; @@ -1116,12 +1144,19 @@ Item { if (!PopoutManager.cursorOverBar(_lastHoverGlobalX, _lastHoverGlobalY)) return; + const activePopout = PopoutManager.getActivePopout(barWindow?.screen); + const targetLoader = _loaderForWidgetId(hit.widgetId); + const targetPopout = _resolvePopoutFromLoader(targetLoader); + const managerOwnsTransition = !!(activePopout && targetPopout); + // 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. + // in place. PopoutManager also owns handoff between loaded popouts, so only + // pre-close special/unmanaged surfaces here. if (triggerKey !== activeHoverTrigger && activeHoverTrigger !== "" && !_hitTargetsActivePopout(hit)) { - // Mark popout as superseded to fade in-place before closing. - _beginSupersededCloseForActive(); - closeHoverSurfaces(); + if (!managerOwnsTransition) { + _beginSupersededCloseForActive(); + closeHoverSurfaces(); + } } if (!openHoverPopoutForHit(hit)) { 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 28e484fd..67cf84a4 100644 --- a/quickshell/Modules/Settings/DankBarTab.qml +++ b/quickshell/Modules/Settings/DankBarTab.qml @@ -1256,6 +1256,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") @@ -1800,50 +1844,6 @@ 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: "mouse" title: I18n.tr("Scroll Wheel") 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/Widgets/DankPopout.qml b/quickshell/Widgets/DankPopout.qml index a81904d2..7ffbc1bc 100644 --- a/quickshell/Widgets/DankPopout.qml +++ b/quickshell/Widgets/DankPopout.qml @@ -25,6 +25,7 @@ Item { 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 @@ -187,6 +188,8 @@ Item { } function closeFromHoverDismiss() { + if (hoverDismissSuspended) + return; hoverDismissEnabled = false; // Enable animations using standard Theme-bound popout motion to preserve bindings. if (impl.item) @@ -307,6 +310,7 @@ Item { it.effectiveBarPosition = Qt.binding(() => root.effectiveBarPosition); it.effectiveBarBottomGap = Qt.binding(() => root.effectiveBarBottomGap); it.hoverDismissEnabled = Qt.binding(() => root.hoverDismissEnabled); + it.hoverDismissSuspended = Qt.binding(() => root.hoverDismissSuspended); it.shouldBeVisible = root.shouldBeVisible; if (root._primeContent && typeof it.primeContent === "function") diff --git a/quickshell/Widgets/DankPopoutConnected.qml b/quickshell/Widgets/DankPopoutConnected.qml index 3c39c300..64d93764 100644 --- a/quickshell/Widgets/DankPopoutConnected.qml +++ b/quickshell/Widgets/DankPopoutConnected.qml @@ -409,13 +409,15 @@ Item { property bool animationsEnabled: true property bool hoverDismissEnabled: false + property bool hoverDismissSuspended: false function cancelHoverDismiss() { hoverDismissTracker.cancelPending(); + _hoverDismissGrace.stop(); } function closeFromHoverDismiss() { - if (isClosing || !shouldBeVisible) + if (hoverDismissSuspended || isClosing || !shouldBeVisible) return; if (popoutHandle?.closeFromHoverDismiss) popoutHandle.closeFromHoverDismiss(); @@ -479,7 +481,10 @@ Item { } function close() { - _endMorphTravel(); + if (_supersededClose && morphTravelEnabled) + _freezeMorphTravel(); + else + _endMorphTravel(); isClosing = true; shouldBeVisible = false; _primeContent = false; @@ -518,6 +523,7 @@ Item { onTriggered: { if (!shouldBeVisible) { contentWindow.visible = false; + root._endMorphTravel(); isClosing = false; PopoutManager.hidePopout(popoutHandle); popoutClosed(); @@ -682,8 +688,9 @@ Item { NumberAnimation { duration: root._morphTravelDuration easing.type: Easing.BezierSpline - // Emphasized curve for fluid morph travel. - easing.bezierCurve: Theme.expressiveCurves.emphasized + // M3 Expressive spatial motion starts with momentum and settles gently, + // which keeps rapid hover retargets from pausing between surfaces. + easing.bezierCurve: Theme.variantEnterCurve } } @@ -692,10 +699,9 @@ Item { readonly property real pubBodyW: morphSeedW + (alignedWidth - morphSeedW) * morphProgress readonly property real pubBodyH: morphSeedH + (renderedAlignedHeight - morphSeedH) * morphProgress - onPubBodyXChanged: _queueBodySync() - onPubBodyYChanged: _queueBodySync() - onPubBodyWChanged: _queueBodySync() - onPubBodyHChanged: _queueBodySync() + // 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; @@ -716,12 +722,13 @@ Item { 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)); + // 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(() => { @@ -730,6 +737,25 @@ Item { }); } + 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; @@ -856,16 +882,24 @@ Item { _hoverOverBody = over; if (over) _hoverDismissGrace.stop(); - else if (root.hoverDismissEnabled && root.shouldBeVisible) + else if (root.hoverDismissEnabled && !root.hoverDismissSuspended && root.shouldBeVisible) _hoverDismissGrace.restart(); } + onHoverDismissSuspendedChanged: { + if (hoverDismissSuspended) { + _hoverDismissGrace.stop(); + } else if (hoverDismissEnabled && shouldBeVisible && !_hoverOverBody) { + _hoverDismissGrace.restart(); + } + } + Timer { id: _hoverDismissGrace interval: 150 repeat: false onTriggered: { - if (!root.hoverDismissEnabled || !root.shouldBeVisible) + if (!root.hoverDismissEnabled || root.hoverDismissSuspended || !root.shouldBeVisible) return; if (root._hoverOverBody) return; @@ -907,7 +941,7 @@ Item { HoverDismissTracker { id: hoverDismissTracker anchors.fill: parent - enabled: root.hoverDismissEnabled && root.shouldBeVisible + enabled: root.hoverDismissEnabled && !root.hoverDismissSuspended && root.shouldBeVisible shouldDismiss: function () { return !PopoutManager.cursorOverBar(PopoutManager.hoverCursorGlobalX, PopoutManager.hoverCursorGlobalY); } diff --git a/quickshell/Widgets/DankPopoutStandalone.qml b/quickshell/Widgets/DankPopoutStandalone.qml index c57c7a14..e05c617b 100644 --- a/quickshell/Widgets/DankPopoutStandalone.qml +++ b/quickshell/Widgets/DankPopoutStandalone.qml @@ -37,13 +37,15 @@ Item { property bool isClosing: false property bool animationsEnabled: true property bool hoverDismissEnabled: false + property bool hoverDismissSuspended: false function cancelHoverDismiss() { hoverDismissTracker.cancelPending(); + _hoverDismissGrace.stop(); } function closeFromHoverDismiss() { - if (isClosing || !shouldBeVisible) + if (hoverDismissSuspended || isClosing || !shouldBeVisible) return; if (popoutHandle?.closeFromHoverDismiss) popoutHandle.closeFromHoverDismiss(); @@ -58,16 +60,24 @@ Item { _hoverOverBody = over; if (over) _hoverDismissGrace.stop(); - else if (root.hoverDismissEnabled && root.shouldBeVisible) + else if (root.hoverDismissEnabled && !root.hoverDismissSuspended && root.shouldBeVisible) _hoverDismissGrace.restart(); } + onHoverDismissSuspendedChanged: { + if (hoverDismissSuspended) { + _hoverDismissGrace.stop(); + } else if (hoverDismissEnabled && shouldBeVisible && !_hoverOverBody) { + _hoverDismissGrace.restart(); + } + } + Timer { id: _hoverDismissGrace interval: 150 repeat: false onTriggered: { - if (!root.hoverDismissEnabled || !root.shouldBeVisible) + if (!root.hoverDismissEnabled || root.hoverDismissSuspended || !root.shouldBeVisible) return; if (root._hoverOverBody) return; @@ -641,7 +651,7 @@ Item { HoverDismissTracker { id: hoverDismissTracker anchors.fill: parent - enabled: root.hoverDismissEnabled && root.shouldBeVisible + enabled: root.hoverDismissEnabled && !root.hoverDismissSuspended && root.shouldBeVisible shouldDismiss: function () { return !PopoutManager.cursorOverBar(PopoutManager.hoverCursorGlobalX, PopoutManager.hoverCursorGlobalY); } 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",