1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-06-28 22:12:10 -04:00

feat(popouts): complete initial hover implementation

This commit is contained in:
purian23
2026-06-26 23:33:40 -04:00
parent 06fa21118e
commit 7979fb2b0e
16 changed files with 642 additions and 80 deletions
+21 -4
View File
@@ -186,6 +186,14 @@ Singleton {
return currentPopoutsByScreen[screen.name] || null; 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) { function isCurrentPopout(popout, screenName) {
const name = screenName || popout?.screen?.name || ""; const name = screenName || popout?.screen?.name || "";
return !!name && currentPopoutsByScreen[name] === popout; return !!name && currentPopoutsByScreen[name] === popout;
@@ -194,6 +202,8 @@ Singleton {
function requestPopout(popout, tabIndex, triggerSource) { function requestPopout(popout, tabIndex, triggerSource) {
if (!popout || !popout.screen) if (!popout || !popout.screen)
return; return;
// Clicking a hover popout pins it open rather than toggling it closed
const wasTransient = popout.hoverDismissEnabled === true;
if (popout.hoverDismissEnabled !== undefined) if (popout.hoverDismissEnabled !== undefined)
popout.hoverDismissEnabled = false; popout.hoverDismissEnabled = false;
screenshotActive = false; screenshotActive = false;
@@ -240,13 +250,17 @@ Singleton {
} }
if (currentPopout === popout && popout.shouldBeVisible && !movedFromOtherScreen) { if (currentPopout === popout && popout.shouldBeVisible && !movedFromOtherScreen) {
if (triggerId !== undefined && currentPopoutTriggers[screenName] === triggerId) { const sameTrigger = triggerId === undefined || currentPopoutTriggers[screenName] === triggerId;
if (sameTrigger) {
if (!wasTransient) {
_closePopout(popout); _closePopout(popout);
return; return;
} }
if (popout.updateSurfacePosition)
if (triggerId === undefined) { popout.updateSurfacePosition();
_closePopout(popout); if (triggerId !== undefined)
currentPopoutTriggers[screenName] = triggerId;
return; return;
} }
@@ -315,6 +329,9 @@ Singleton {
currentPopoutsByScreen[screenName] = null; currentPopoutsByScreen[screenName] = null;
currentPopoutTriggers[screenName] = null; currentPopoutTriggers[screenName] = null;
} else { } else {
// Signal the active popout to fade in-place when morphed
if (typeof currentPopout.beginSupersededClose === "function")
currentPopout.beginSupersededClose();
_closePopout(currentPopout); _closePopout(currentPopout);
} }
} }
+44 -1
View File
@@ -291,6 +291,8 @@ Singleton {
onFrameLauncherEmergeSideChanged: saveSettings() onFrameLauncherEmergeSideChanged: saveSettings()
property bool frameLauncherArcExtender: false property bool frameLauncherArcExtender: false
onFrameLauncherArcExtenderChanged: saveSettings() onFrameLauncherArcExtenderChanged: saveSettings()
property bool frameLauncherEdgeHover: false
onFrameLauncherEdgeHoverChanged: saveSettings()
readonly property string frameModalEmergeSide: frameLauncherEmergeSide === "top" ? "bottom" : "top" readonly property string frameModalEmergeSide: frameLauncherEmergeSide === "top" ? "bottom" : "top"
property string frameMode: "connected" property string frameMode: "connected"
onFrameModeChanged: saveSettings() onFrameModeChanged: saveSettings()
@@ -1000,7 +1002,8 @@ Singleton {
"shadowColorMode": "default", "shadowColorMode": "default",
"shadowCustomColor": "#000000", "shadowCustomColor": "#000000",
"clickThrough": false, "clickThrough": false,
"hoverPopouts": false "hoverPopouts": false,
"hoverPopoutDelay": 150
} }
] ]
@@ -2437,6 +2440,46 @@ Singleton {
return barConfigs.filter(cfg => cfg.enabled); 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() { function getScreensSortedByPosition() {
const screens = []; const screens = [];
for (var i = 0; i < Quickshell.screens.length; i++) { for (var i = 0; i < Quickshell.screens.length; i++) {
@@ -642,6 +642,7 @@ var SPEC = {
frameCloseGaps: { def: true }, frameCloseGaps: { def: true },
frameLauncherEmergeSide: { def: "bottom" }, frameLauncherEmergeSide: { def: "bottom" },
frameLauncherArcExtender: { def: false }, frameLauncherArcExtender: { def: false },
frameLauncherEdgeHover: { def: false },
frameMode: { def: "connected" }, frameMode: { def: "connected" },
barInsetPaddingShared: { def: -1 }, barInsetPaddingShared: { def: -1 },
barInsetPaddingSyncAll: { def: false }, barInsetPaddingSyncAll: { def: false },
+6
View File
@@ -233,6 +233,12 @@ Item {
sourceComponent: Frame {} sourceComponent: Frame {}
} }
Loader {
active: SettingsData.frameEnabled && SettingsData.frameLauncherEdgeHover
asynchronous: false
sourceComponent: FrameLauncherHoverZone {}
}
DeferredAction { DeferredAction {
id: frameSurfaceReloadAction id: frameSurfaceReloadAction
onTriggered: root.frameSurfacesLoaded = true onTriggered: root.frameSurfacesLoaded = true
@@ -24,6 +24,7 @@ Item {
readonly property string resolvedConnectedBarSide: impl.item ? (impl.item.resolvedConnectedBarSide ?? "") : "" readonly property string resolvedConnectedBarSide: impl.item ? (impl.item.resolvedConnectedBarSide ?? "") : ""
readonly property bool launcherArcExtenderActive: impl.item ? (impl.item.launcherArcExtenderActive ?? false) : false readonly property bool launcherArcExtenderActive: impl.item ? (impl.item.launcherArcExtenderActive ?? false) : false
property bool triggerUsesOverlayLayer: false property bool triggerUsesOverlayLayer: false
property bool edgeHoverManaged: false
signal dialogClosed signal dialogClosed
@@ -394,6 +394,8 @@ Item {
closeCleanupTimer.stop(); closeCleanupTimer.stop();
isClosing = false; isClosing = false;
openedFromOverview = false; openedFromOverview = false;
_edgeArmed = false;
_edgeBodyHover = false;
animationsEnabled = false; animationsEnabled = false;
@@ -447,6 +449,9 @@ Item {
keyboardActive = false; keyboardActive = false;
spotlightOpen = false; spotlightOpen = false;
_edgeRetractGrace.stop();
_edgeArmed = false;
_edgeBodyHover = false;
ModalManager.closeModal(modalHandle); ModalManager.closeModal(modalHandle);
closeCleanupTimer.start(); 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 { Connections {
target: spotlightContent?.controller ?? null target: spotlightContent?.controller ?? null
function onModeChanged(mode, userInitiated) { function onModeChanged(mode, userInitiated) {
@@ -628,6 +658,13 @@ Item {
width: root.alignedWidth width: root.alignedWidth
height: root.contentSurfaceHeight height: root.contentSurfaceHeight
// Passive tracker for edge-hover dismissal that preserves input events.
HoverHandler {
id: edgeBodyHoverHandler
enabled: root._edgeRetractEnabled
onHoveredChanged: root._onEdgeBodyHoverChanged(hovered)
}
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
enabled: root.spotlightOpen enabled: root.spotlightOpen
+150 -18
View File
@@ -385,6 +385,36 @@ Item {
property real _lastHoverGlobalY: 0 property real _lastHoverGlobalY: 0
readonly property bool hoverPopoutsEnabled: barConfig?.hoverPopouts ?? false 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() { function getBarPosition() {
return barWindow.axis?.edge === "left" ? 2 : (barWindow.axis?.edge === "right" ? 3 : (barWindow.axis?.edge === "top" ? 0 : 1)); 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) if (loader.item)
return loader.item; return loader.item;
const pairs = [ 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]];
[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++) { for (let i = 0; i < pairs.length; i++) {
if (loader === pairs[i][0] && pairs[i][1]) if (loader === pairs[i][0] && pairs[i][1])
return pairs[i][1]; return pairs[i][1];
@@ -803,6 +822,13 @@ Item {
TrayMenuManager.closeAllMenus(); 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) { function openNotepadHover(widgetItem) {
const instance = widgetItem.prepareNotepadInstance?.(widgetItem.notepadInstance) ?? widgetItem.notepadInstance; const instance = widgetItem.prepareNotepadInstance?.(widgetItem.notepadInstance) ?? widgetItem.notepadInstance;
if (!instance || typeof instance.show !== "function") if (!instance || typeof instance.show !== "function")
@@ -981,10 +1007,14 @@ Item {
PopoutManager.updateHoverCursor(gx, gy); PopoutManager.updateHoverCursor(gx, gy);
_syncHoverTriggerState(); _syncHoverTriggerState();
// Ignore hover events when a popout is pinned open.
if (PopoutManager.isActivePopoutPinned(barWindow?.screen))
return;
const hit = findWidgetAtGlobalPoint(gx, gy); const hit = findWidgetAtGlobalPoint(gx, gy);
if (!hit) { if (!hit) {
if (!cursorOverHoverChain(gx, gy)) _cancelPendingHover();
closeHoverSurfaces(); scheduleHoverClose(gx, gy);
return; return;
} }
@@ -1002,16 +1032,97 @@ Item {
triggerKey = _dashTriggerSource(hit.section, 3); triggerKey = _dashTriggerSource(hit.section, 3);
if (!triggerKey) { if (!triggerKey) {
if (!cursorOverHoverChain(gx, gy)) _cancelPendingHover();
closeHoverSurfaces(); scheduleHoverClose(gx, gy);
return; 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; 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(); closeHoverSurfaces();
}
if (!openHoverPopoutForHit(hit)) { if (!openHoverPopoutForHit(hit)) {
if (activeHoverTrigger !== "") if (activeHoverTrigger !== "")
@@ -1022,6 +1133,27 @@ Item {
activeHoverTrigger = triggerKey; 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: ({ readonly property var widgetVisibility: ({
"cpuUsage": DgopService.dgopAvailable, "cpuUsage": DgopService.dgopAvailable,
"memUsage": DgopService.dgopAvailable, "memUsage": DgopService.dgopAvailable,
+9 -17
View File
@@ -1111,33 +1111,25 @@ PanelWindow {
rightWidgetsModel: barWindow.rightWidgetsModel rightWidgetsModel: barWindow.rightWidgetsModel
} }
MouseArea { // Passive HoverHandler to track cursor without intercepting clicks or scroll events.
id: hoverPopoutArea HoverHandler {
anchors.fill: parent id: hoverPopoutHandler
z: 1 enabled: (barConfig?.hoverPopouts ?? false) && !barWindow.clickThroughEnabled
hoverEnabled: barConfig?.hoverPopouts ?? false
enabled: hoverPopoutArea.hoverEnabled && !barWindow.clickThroughEnabled
acceptedButtons: Qt.NoButton
propagateComposedEvents: true
property real lastGlobalX: 0 property real lastGlobalX: 0
property real lastGlobalY: 0 property real lastGlobalY: 0
onPositionChanged: mouse => { onPointChanged: {
const gp = mapToItem(null, mouse.x, mouse.y); const gp = barUnitInset.mapToItem(null, point.position.x, point.position.y);
lastGlobalX = gp.x; lastGlobalX = gp.x;
lastGlobalY = gp.y; lastGlobalY = gp.y;
topBarContent.checkHoverPopout(gp.x, gp.y); topBarContent.checkHoverPopout(gp.x, gp.y);
} }
onWheel: wheel => scrollArea.processWheel(wheel) onHoveredChanged: {
if (hovered)
onContainsMouseChanged: {
if (containsMouse)
return; return;
if (topBarContent.cursorOverHoverChain(lastGlobalX, lastGlobalY)) topBarContent.scheduleHoverClose(lastGlobalX, lastGlobalY);
return;
topBarContent.closeHoverSurfaces();
} }
} }
} }
@@ -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();
}
}
}
}
}
@@ -1811,6 +1811,37 @@ Item {
onToggled: checked => SettingsData.updateBarConfig(selectedBarId, { onToggled: checked => SettingsData.updateBarConfig(selectedBarId, {
hoverPopouts: checked 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 { SettingsToggleCard {
+9
View File
@@ -357,6 +357,15 @@ Item {
checked: SettingsData.frameLauncherArcExtender checked: SettingsData.frameLauncherArcExtender
onToggled: checked => SettingsData.set("frameLauncherArcExtender", checked) 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 { SettingsCard {
+15 -2
View File
@@ -502,15 +502,26 @@ Singleton {
property string _dankLauncherV2PendingQuery: "" property string _dankLauncherV2PendingQuery: ""
property string _dankLauncherV2PendingMode: "" property string _dankLauncherV2PendingMode: ""
property bool _dankLauncherV2TriggerUsesOverlayLayer: false property bool _dankLauncherV2TriggerUsesOverlayLayer: false
property bool _dankLauncherV2EdgeHoverManaged: false
function _setDankLauncherV2TriggerUsesOverlayLayer(value) { function _setDankLauncherV2TriggerUsesOverlayLayer(value) {
_dankLauncherV2TriggerUsesOverlayLayer = value === true; _dankLauncherV2TriggerUsesOverlayLayer = value === true;
// Disable edge-hover by default on every open/toggle path unless explicitly enabled.
_setDankLauncherV2EdgeHoverManaged(false);
if (dankLauncherV2Modal) if (dankLauncherV2Modal)
dankLauncherV2Modal.triggerUsesOverlayLayer = _dankLauncherV2TriggerUsesOverlayLayer; 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); _setDankLauncherV2TriggerUsesOverlayLayer(triggerUsesOverlayLayer);
_setDankLauncherV2EdgeHoverManaged(edgeHoverManaged);
if (dankLauncherV2Modal) { if (dankLauncherV2Modal) {
dankLauncherV2Modal.show(); dankLauncherV2Modal.show();
} else if (dankLauncherV2ModalLoader) { } else if (dankLauncherV2ModalLoader) {
@@ -591,8 +602,10 @@ Singleton {
} }
function _onDankLauncherV2ModalLoaded() { function _onDankLauncherV2ModalLoaded() {
if (dankLauncherV2Modal) if (dankLauncherV2Modal) {
dankLauncherV2Modal.triggerUsesOverlayLayer = _dankLauncherV2TriggerUsesOverlayLayer; dankLauncherV2Modal.triggerUsesOverlayLayer = _dankLauncherV2TriggerUsesOverlayLayer;
dankLauncherV2Modal.edgeHoverManaged = _dankLauncherV2EdgeHoverManaged;
}
if (_dankLauncherV2WantsOpen) { if (_dankLauncherV2WantsOpen) {
_dankLauncherV2WantsOpen = false; _dankLauncherV2WantsOpen = false;
if (_dankLauncherV2PendingQuery) { if (_dankLauncherV2PendingQuery) {
+11 -9
View File
@@ -180,20 +180,22 @@ Item {
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() { function closeFromHoverDismiss() {
hoverDismissEnabled = false; 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.animationsEnabled = true;
impl.item.animationDuration = Math.round(Theme.expressiveDurations.expressiveDefaultSpatial); for (const prop of ["dashVisible", "notificationHistoryVisible"]) {
impl.item.animationExitCurve = Theme.expressiveCurves.expressiveDefaultSpatial; if (root[prop] !== undefined) {
} root[prop] = false;
if (dashVisible !== undefined) {
dashVisible = false;
return; return;
} }
if (notificationHistoryVisible !== undefined) {
notificationHistoryVisible = false;
return;
} }
if (impl.item) if (impl.item)
impl.item.close(); impl.item.close();
+158 -21
View File
@@ -235,10 +235,10 @@ Item {
const presented = contentWindow.visible || root.shouldBeVisible; const presented = contentWindow.visible || root.shouldBeVisible;
const phase = root.isClosing ? "closing" : (!presented ? "hidden" : (!contentWindow.visible && root.shouldBeVisible ? "opening" : "open")); const phase = root.isClosing ? "closing" : (!presented ? "hidden" : (!contentWindow.visible && root.shouldBeVisible ? "opening" : "open"));
const bodyRect = { const bodyRect = {
"x": root.alignedX, "x": root.pubBodyX,
"y": root.renderedAlignedY, "y": root.pubBodyY,
"width": root.alignedWidth, "width": root.pubBodyW,
"height": root.renderedAlignedHeight "height": root.pubBodyH
}; };
const animationOffset = { const animationOffset = {
"x": _connectedChromeAnimX(), "x": _connectedChromeAnimX(),
@@ -255,10 +255,10 @@ Item {
"animationOffset": animationOffset, "animationOffset": animationOffset,
"scale": 1, "scale": 1,
"opacity": Theme.connectedSurfaceColor.a, "opacity": Theme.connectedSurfaceColor.a,
"bodyX": root.alignedX, "bodyX": root.pubBodyX,
"bodyY": root.renderedAlignedY, "bodyY": root.pubBodyY,
"bodyW": root.alignedWidth, "bodyW": root.pubBodyW,
"bodyH": root.renderedAlignedHeight, "bodyH": root.pubBodyH,
"animX": animationOffset.x, "animX": animationOffset.x,
"animY": animationOffset.y, "animY": animationOffset.y,
"screen": root.screen ? root.screen.name : "", "screen": root.screen ? root.screen.name : "",
@@ -312,7 +312,7 @@ Item {
return; return;
if (!contentWindow.visible && !shouldBeVisible) if (!contentWindow.visible && !shouldBeVisible)
return; 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 property bool _animSyncQueued: false
@@ -430,6 +430,7 @@ Item {
isClosing = false; isClosing = false;
animationsEnabled = false; animationsEnabled = false;
_primeContent = true; _primeContent = true;
_supersededClose = false;
const screenChanged = _lastOpenedScreen !== null && _lastOpenedScreen !== screen; const screenChanged = _lastOpenedScreen !== null && _lastOpenedScreen !== screen;
if (screenChanged) { if (screenChanged) {
@@ -444,6 +445,13 @@ Item {
_captureChromeAnimTravel(); _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) { if (root.frameOwnsConnectedChrome) {
chromeLease.beginClaim(); chromeLease.beginClaim();
_publishConnectedChromeState(true, true); _publishConnectedChromeState(true, true);
@@ -471,6 +479,7 @@ Item {
} }
function close() { function close() {
_endMorphTravel();
isClosing = true; isClosing = true;
shouldBeVisible = false; shouldBeVisible = false;
_primeContent = false; _primeContent = false;
@@ -657,6 +666,88 @@ Item {
easing.bezierCurve: root.renderedGeometryGrowing ? root.animationEnterCurve : root.animationExitCurve 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: { readonly property real connectedAnchorX: {
if (!root.usesConnectedSurfaceChrome) if (!root.usesConnectedSurfaceChrome)
return triggerX; return triggerX;
@@ -758,6 +849,32 @@ Item {
readonly property real maskWidth: _dismissZone.width readonly property real maskWidth: _dismissZone.width
readonly property real maskHeight: _dismissZone.height 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 { DismissZone {
id: _dismissZone id: _dismissZone
barPosition: root.effectiveBarPosition barPosition: root.effectiveBarPosition
@@ -795,6 +912,7 @@ Item {
return !PopoutManager.cursorOverBar(PopoutManager.hoverCursorGlobalX, PopoutManager.hoverCursorGlobalY); return !PopoutManager.cursorOverBar(PopoutManager.hoverCursorGlobalX, PopoutManager.hoverCursorGlobalY);
} }
onDismissRequested: root.closeFromHoverDismiss() onDismissRequested: root.closeFromHoverDismiss()
onHoverMoved: (gx, gy) => PopoutManager.updateHoverCursor(gx, gy)
} }
WindowBlur { WindowBlur {
@@ -878,10 +996,11 @@ Item {
Item { Item {
id: contentContainer id: contentContainer
x: root.alignedX // Follow the morphing body bounds during transition.
y: root.renderedAlignedY x: root.morphTravelEnabled ? root.pubBodyX : root.alignedX
width: root.alignedWidth y: root.morphTravelEnabled ? root.pubBodyY : root.renderedAlignedY
height: root.renderedAlignedHeight 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 barTop: effectiveBarPosition === SettingsData.Position.Top
readonly property bool barBottom: effectiveBarPosition === SettingsData.Position.Bottom readonly property bool barBottom: effectiveBarPosition === SettingsData.Position.Bottom
@@ -950,6 +1069,19 @@ Item {
readonly property real computedScaleCollapsed: root.animationScaleCollapsed 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 { QtObject {
id: morph id: morph
property real openProgress: 0 property real openProgress: 0
@@ -977,7 +1109,8 @@ Item {
target: root target: root
function onShouldBeVisibleChanged() { function onShouldBeVisibleChanged() {
root._captureChromeAnimTravel(); 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 _renderActive: Theme.isDirectionalEffect || shouldBeVisible
property bool _animating: false 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 visible: _renderActive
scale: contentContainer.scaleValue scale: contentContainer.scaleValue
x: Theme.snap(contentContainer.animX + (rollOutAdjuster.baseWidth - width) * (1 - scale) * 0.5, root.dpr) 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) 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.smooth: false
layer.textureSize: root.dpr > 1 ? Qt.size(Math.ceil(width * root.dpr), Math.ceil(height * root.dpr)) : Qt.size(0, 0) layer.textureSize: root.dpr > 1 ? Qt.size(Math.ceil(width * root.dpr), Math.ceil(height * root.dpr)) : Qt.size(0, 0)
Behavior on opacity { Behavior on opacity {
enabled: !Theme.isDirectionalEffect enabled: contentWrapper._fadeWithOpacity
NumberAnimation { 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.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
onRunningChanged: { onRunningChanged: {
@@ -1131,9 +1268,9 @@ Item {
} }
Behavior on publishedOpacity { Behavior on publishedOpacity {
enabled: !Theme.isDirectionalEffect enabled: contentWrapper._fadeWithOpacity
NumberAnimation { 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.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
} }
@@ -51,6 +51,32 @@ Item {
close(); 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 var customKeyboardFocus: null
property bool backgroundInteractive: true property bool backgroundInteractive: true
property bool contentHandlesKeys: false property bool contentHandlesKeys: false
@@ -620,6 +646,7 @@ Item {
return !PopoutManager.cursorOverBar(PopoutManager.hoverCursorGlobalX, PopoutManager.hoverCursorGlobalY); return !PopoutManager.cursorOverBar(PopoutManager.hoverCursorGlobalX, PopoutManager.hoverCursorGlobalY);
} }
onDismissRequested: root.closeFromHoverDismiss() onDismissRequested: root.closeFromHoverDismiss()
onHoverMoved: (gx, gy) => PopoutManager.updateHoverCursor(gx, gy)
} }
WindowBlur { WindowBlur {
@@ -739,6 +766,19 @@ Item {
readonly property real computedScaleCollapsed: root.animationScaleCollapsed 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). // openProgress: 0 = closed (at offset, scaleCollapsed), 1 = open (at 0, scale 1).
QtObject { QtObject {
id: morph id: morph
+10 -1
View File
@@ -9,12 +9,20 @@ Item {
property var shouldDismiss: null property var shouldDismiss: null
signal dismissRequested signal dismissRequested
// Emitted on every hover move; passive to avoid blocking overlapping MouseAreas
signal hoverMoved(real gx, real gy)
anchors.fill: parent anchors.fill: parent
HoverHandler { HoverHandler {
id: hoverHandler id: hoverHandler
enabled: root.enabled 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: { onHoveredChanged: {
if (hoverHandler.hovered || !root.enabled) if (hoverHandler.hovered || !root.enabled)
return; return;
@@ -24,5 +32,6 @@ Item {
} }
} }
function cancelPending() {} function cancelPending() {
}
} }