diff --git a/quickshell/Common/PopoutManager.qml b/quickshell/Common/PopoutManager.qml index 6586b9b5..e8b546cd 100644 --- a/quickshell/Common/PopoutManager.qml +++ b/quickshell/Common/PopoutManager.qml @@ -25,12 +25,12 @@ Singleton { hoverCursorGlobalY = gy; } - function cursorOverBar(gx, gy, padding) { + function cursorOverBar(gx, gy, padding, excludedWindow) { const pad = padding !== undefined ? padding : 16; const bars = KeyboardFocus.barWindows || []; for (let i = 0; i < bars.length; i++) { const w = bars[i]; - if (!w?.visible) + if (!w?.visible || w === excludedWindow) continue; if (typeof w.containsGlobalPoint === "function") { if (w.containsGlobalPoint(gx, gy, pad)) @@ -199,104 +199,22 @@ Singleton { return !!name && currentPopoutsByScreen[name] === popout; } - function requestPopout(popout, tabIndex, triggerSource) { + function _requestPopout(popout, tabIndex, triggerSource, hoverRequest) { if (!popout || !popout.screen) return; - // Clicking a hover popout pins it open rather than toggling it closed + + // Clicking a transient popout pins it instead of toggling it closed. const wasTransient = popout.hoverDismissEnabled === true; - if (popout.hoverDismissEnabled !== undefined) + if (!hoverRequest && popout.hoverDismissEnabled !== undefined) popout.hoverDismissEnabled = false; + screenshotActive = false; const screenName = popout.screen.name; const currentPopout = currentPopoutsByScreen[screenName]; const triggerId = triggerSource !== undefined ? triggerSource : tabIndex; + const alreadyPresented = currentPopout === popout && (hoverRequest ? _isPopoutPresented(popout) : popout.shouldBeVisible); - const willOpen = !(currentPopout === popout && popout.shouldBeVisible && triggerId !== undefined && currentPopoutTriggers[screenName] === triggerId); - if (willOpen) { - popoutOpening(); - } - - let movedFromOtherScreen = false; - for (const otherScreenName in currentPopoutsByScreen) { - if (otherScreenName === screenName) - continue; - const otherPopout = currentPopoutsByScreen[otherScreenName]; - if (!otherPopout) - continue; - - if (_isStale(otherPopout)) { - currentPopoutsByScreen[otherScreenName] = null; - currentPopoutTriggers[otherScreenName] = null; - continue; - } - - if (otherPopout === popout) { - movedFromOtherScreen = true; - currentPopoutsByScreen[otherScreenName] = null; - currentPopoutTriggers[otherScreenName] = null; - continue; - } - - _closePopout(otherPopout); - } - - if (currentPopout && currentPopout !== popout) { - if (_isStale(currentPopout)) { - currentPopoutsByScreen[screenName] = null; - currentPopoutTriggers[screenName] = null; - } else { - _closePopout(currentPopout); - } - } - - if (currentPopout === popout && popout.shouldBeVisible && !movedFromOtherScreen) { - const sameTrigger = triggerId === undefined || currentPopoutTriggers[screenName] === triggerId; - - if (sameTrigger) { - if (!wasTransient) { - _closePopout(popout); - return; - } - if (popout.updateSurfacePosition) - popout.updateSurfacePosition(); - if (triggerId !== undefined) - currentPopoutTriggers[screenName] = triggerId; - return; - } - - if (tabIndex !== undefined && popout.currentTabIndex !== undefined) { - popout.currentTabIndex = tabIndex; - } - if (popout.updateSurfacePosition) - popout.updateSurfacePosition(); - currentPopoutTriggers[screenName] = triggerId; - return; - } - - currentPopoutTriggers[screenName] = triggerId; - currentPopoutsByScreen[screenName] = popout; - popoutChanged(); - - if (tabIndex !== undefined && popout.currentTabIndex !== undefined) { - popout.currentTabIndex = tabIndex; - } - - if (currentPopout !== popout) { - ModalManager.closeAllModalsExcept(null); - } - - _openPopout(popout); - } - - function requestHoverPopout(popout, tabIndex, triggerSource) { - if (!popout || !popout.screen) - return; - screenshotActive = false; - const screenName = popout.screen.name; - const currentPopout = currentPopoutsByScreen[screenName]; - const triggerId = triggerSource !== undefined ? triggerSource : tabIndex; - - const willOpen = !(currentPopout === popout && _isPopoutPresented(popout) && triggerId !== undefined && currentPopoutTriggers[screenName] === triggerId); + const willOpen = !(alreadyPresented && triggerId !== undefined && currentPopoutTriggers[screenName] === triggerId); if (willOpen) popoutOpening(); @@ -329,23 +247,36 @@ Singleton { currentPopoutsByScreen[screenName] = null; currentPopoutTriggers[screenName] = null; } else { - // Signal the active popout to fade in-place when morphed - if (typeof currentPopout.beginSupersededClose === "function") + if (hoverRequest && typeof currentPopout.beginSupersededClose === "function") currentPopout.beginSupersededClose(); _closePopout(currentPopout); } } - if (currentPopout === popout && _isPopoutPresented(popout) && !movedFromOtherScreen) { - if (triggerId !== undefined && currentPopoutTriggers[screenName] === triggerId) + if (alreadyPresented && !movedFromOtherScreen) { + const sameDefinedTrigger = triggerId !== undefined && currentPopoutTriggers[screenName] === triggerId; + if (hoverRequest && sameDefinedTrigger) return; - if (tabIndex !== undefined && popout.currentTabIndex !== undefined) + if (!hoverRequest && (triggerId === undefined || sameDefinedTrigger)) { + if (!wasTransient) { + _closePopout(popout); + return; + } + if (popout.updateSurfacePosition) + popout.updateSurfacePosition(); + if (triggerId !== undefined) + currentPopoutTriggers[screenName] = triggerId; + return; + } + + if (tabIndex !== undefined && popout.currentTabIndex !== undefined) { popout.currentTabIndex = tabIndex; + } if (popout.updateSurfacePosition) popout.updateSurfacePosition(); currentPopoutTriggers[screenName] = triggerId; - if (popout.hoverDismissEnabled !== undefined) + if (hoverRequest && popout.hoverDismissEnabled !== undefined) popout.hoverDismissEnabled = true; return; } @@ -354,15 +285,25 @@ Singleton { currentPopoutsByScreen[screenName] = popout; popoutChanged(); - if (tabIndex !== undefined && popout.currentTabIndex !== undefined) + if (tabIndex !== undefined && popout.currentTabIndex !== undefined) { popout.currentTabIndex = tabIndex; + } - if (currentPopout !== popout) + if (currentPopout !== popout) { ModalManager.closeAllModalsExcept(null); + } - if (popout.hoverDismissEnabled !== undefined) + if (hoverRequest && popout.hoverDismissEnabled !== undefined) popout.hoverDismissEnabled = true; _openPopout(popout); } + + function requestPopout(popout, tabIndex, triggerSource) { + _requestPopout(popout, tabIndex, triggerSource, false); + } + + function requestHoverPopout(popout, tabIndex, triggerSource) { + _requestPopout(popout, tabIndex, triggerSource, true); + } } diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 58f80692..97570e2f 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -569,7 +569,9 @@ var SPEC = { shadowOpacity: 60, shadowColorMode: "default", shadowCustomColor: "#000000", - clickThrough: false + clickThrough: false, + hoverPopouts: false, + hoverPopoutDelay: 150 }], onChange: "updateBarConfigs" }, diff --git a/quickshell/Modules/DankBar/DankBarContent.qml b/quickshell/Modules/DankBar/DankBarContent.qml index 26ecacfa..5da210ba 100644 --- a/quickshell/Modules/DankBar/DankBarContent.qml +++ b/quickshell/Modules/DankBar/DankBarContent.qml @@ -388,53 +388,51 @@ Item { return "left"; } - property string activeHoverTrigger: "" - property real _lastHoverGlobalX: 0 - 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 = ""; + DankBarHoverController { + id: hoverController + barContent: topBarContent + barWindow: topBarContent.barWindow + barConfig: topBarContent.barConfig + hLeftSection: topBarContent.hLeftSection + hCenterSection: topBarContent.hCenterSection + hRightSection: topBarContent.hRightSection + vLeftSection: topBarContent.vLeftSection + vCenterSection: topBarContent.vCenterSection + vRightSection: topBarContent.vRightSection + leftWidgetsModel: topBarContent.leftWidgetsModel + centerWidgetsModel: topBarContent.centerWidgetsModel + rightWidgetsModel: topBarContent.rightWidgetsModel } - property var _pendingHoverHit: null - property string _pendingHoverTrigger: "" + readonly property string activeHoverTrigger: hoverController.activeHoverTrigger + readonly property bool hoverPopoutsEnabled: hoverController.hoverPopoutsEnabled + + function queueHoverPopout(gx, gy) { + hoverController.queueHoverPoint(gx, gy); + } + + function checkHoverPopout(gx, gy) { + hoverController.checkHoverPopout(gx, gy); + } + + function findWidgetAtGlobalPoint(gx, gy) { + return hoverController.findWidgetAtGlobalPoint(gx, gy); + } + + function scheduleHoverClose(gx, gy) { + hoverController.scheduleHoverClose(gx, gy); + } + + function updateHoverBarHovered(hovered) { + hoverController.updateBarHovered(hovered); + } function resetHoverForBarGeometryChange() { - _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 = ""; + hoverController.resetForBarGeometryChange(); } - 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 _dashTriggerSource(section, tabIndex) { + return hoverController.dashTriggerSource(section, tabIndex); } function getBarPosition() { @@ -512,7 +510,7 @@ Item { topBarContent._pendingPopoutOpenSpec = null; topBarContent._finishWidgetPopoutOpen(pending, loader.item); if (pending.mode === "hover") - topBarContent.checkHoverPopout(topBarContent._lastHoverGlobalX, topBarContent._lastHoverGlobalY); + hoverController.recheckLatestPoint(); }; if (loader.item) { onLoaded(); @@ -557,638 +555,6 @@ Item { return true; } - function _getBarSections() { - if (barWindow.isVertical) { - return [ - { - section: vLeftSection, - name: "left" - }, - { - section: vCenterSection, - name: "center" - }, - { - section: vRightSection, - name: "right" - } - ]; - } - return [ - { - section: hLeftSection, - name: "left" - }, - { - section: hCenterSection, - name: "center" - }, - { - section: hRightSection, - name: "right" - } - ]; - } - - function _findWidgetHostInWrapper(wrapper) { - if (wrapper.widgetId !== undefined) - return wrapper; - const children = wrapper.children || []; - for (let i = 0; i < children.length; i++) { - if (children[i].widgetId !== undefined) - return children[i]; - } - return null; - } - - function _collectSectionWrappers(section) { - const layout = section.layoutLoader?.item; - if (layout) - return layout.children || []; - const children = section.children || []; - const wrappers = []; - for (let i = 0; i < children.length; i++) { - const child = children[i]; - if (!child || child === section.layoutLoader) - continue; - if (child.itemData !== undefined || child.widgetId !== undefined || _findWidgetHostInWrapper(child)) - wrappers.push(child); - } - return wrappers; - } - - function _widgetSupportsHoverPopout(widgetId, widgetItem) { - if (!widgetId || !widgetItem) - return false; - if (typeof widgetItem.triggerHoverPopout === "function") - return true; - if (widgetId === "systemTray" && typeof widgetItem.openHoverAtGlobalPoint === "function") - return true; - switch (widgetId) { - case "launcherButton": - case "clipboard": - case "clock": - case "music": - case "weather": - case "cpuUsage": - case "memUsage": - case "cpuTemp": - case "gpuTemp": - case "notificationButton": - case "battery": - case "layout": - case "vpn": - case "controlCenterButton": - case "systemUpdate": - case "notepadButton": - case "systemTray": - return true; - default: - return false; - } - } - - function _enumerateWidgetHosts() { - const hosts = []; - const sections = _getBarSections(); - for (let s = 0; s < sections.length; s++) { - const sectionEntry = sections[s]; - const section = sectionEntry.section; - if (!section) - continue; - const wrappers = _collectSectionWrappers(section); - for (let i = 0; i < wrappers.length; i++) { - const wrapper = wrappers[i]; - const host = _findWidgetHostInWrapper(wrapper); - if (!host?.widgetId) - continue; - hosts.push({ - host, - wrapper, - section: sectionEntry.name - }); - } - } - return hosts; - } - - function _collectHoverCandidates() { - const screenName = barWindow.screen?.name; - const candidates = []; - const seen = new Set(); - - function addCandidate(widgetId, widgetItem, sectionHint) { - if (!widgetId || !widgetItem || seen.has(widgetItem)) - return; - if (!_widgetSupportsHoverPopout(widgetId, widgetItem)) - return; - if (!getWidgetVisible(widgetId)) - return; - seen.add(widgetItem); - candidates.push({ - widgetId, - widgetItem, - section: widgetItem.section || sectionHint || "right", - wrapper: null - }); - } - - if (screenName) { - const registry = BarWidgetService.widgetRegistry; - if (registry && typeof registry === "object") { - for (const widgetId in registry) { - const screenMap = registry[widgetId]; - if (!screenMap || typeof screenMap !== "object") - continue; - const widgetItem = screenMap[screenName]; - if (widgetItem) - addCandidate(widgetId, widgetItem, widgetItem.section); - } - } - } - - const hosts = _enumerateWidgetHosts(); - for (let i = 0; i < hosts.length; i++) { - const entry = hosts[i]; - if (!entry.host?.item) - continue; - const existing = candidates.find(c => c.widgetItem === entry.host.item); - if (existing) { - existing.wrapper = entry.wrapper; - if (!existing.section) - existing.section = entry.section; - continue; - } - candidates.push({ - widgetId: entry.host.widgetId, - widgetItem: entry.host.item, - section: entry.host.item.section || entry.section, - wrapper: entry.wrapper - }); - } - - return candidates; - } - - function _globalItemBounds(item) { - const topLeft = item.mapToItem(null, 0, 0); - return { - x: topLeft.x, - y: topLeft.y, - width: item.width, - height: item.height - }; - } - - function _hitBoundsForWidget(widgetItem, wrapper) { - if (!widgetItem?.visible) - return null; - - if (widgetItem.visualContent !== undefined) { - const visual = widgetItem.visualContent; - if (visual.width > 0 && visual.height > 0) - return _globalItemBounds(visual); - } - - if (widgetItem.width > 0 && widgetItem.height > 0) - return _globalItemBounds(widgetItem); - - if (wrapper && wrapper.width > 0 && wrapper.height > 0) - return _globalItemBounds(wrapper); - - return null; - } - - function _pointInBounds(gx, gy, bounds) { - return gx >= bounds.x && gx < bounds.x + bounds.width && gy >= bounds.y && gy < bounds.y + bounds.height; - } - - function findWidgetAtGlobalPoint(gx, gy) { - const candidates = _collectHoverCandidates(); - let best = null; - let bestArea = Infinity; - for (let i = 0; i < candidates.length; i++) { - const entry = candidates[i]; - const bounds = _hitBoundsForWidget(entry.widgetItem, entry.wrapper); - if (!bounds || bounds.width <= 0 || bounds.height <= 0) - continue; - if (!_pointInBounds(gx, gy, bounds)) - continue; - const area = bounds.width * bounds.height; - if (area < bestArea) { - bestArea = area; - best = { - widgetId: entry.widgetId, - widgetItem: entry.widgetItem, - section: entry.section - }; - } - } - return best; - } - - function _dashTriggerSource(section, tabIndex) { - return (barConfig?.id ?? "default") + "-" + section + "-" + tabIndex; - } - - function _notepadWidgetForScreen() { - const screenName = barWindow?.screen?.name; - const fromRegistry = screenName ? BarWidgetService.getWidget("notepadButton", screenName) : null; - if (fromRegistry) - return fromRegistry; - const candidates = _collectHoverCandidates(); - for (let i = 0; i < candidates.length; i++) { - if (candidates[i].widgetId === "notepadButton") - return candidates[i].widgetItem; - } - return null; - } - - function notepadContainsGlobalPoint(gx, gy) { - const instance = _notepadWidgetForScreen()?.notepadInstance; - if (!instance?.isVisible || typeof instance.containsGlobalPoint !== "function") - return false; - return instance.containsGlobalPoint(gx, gy); - } - - function cursorOverHoverChain(gx, gy) { - if (PopoutManager.cursorOverBar(gx, gy)) - return true; - const popout = PopoutManager.getActivePopout(barWindow?.screen); - if (popout?.containsGlobalPoint?.(gx, gy)) - return true; - if (notepadContainsGlobalPoint(gx, gy)) - return true; - const screenName = barWindow.screen?.name; - if (screenName && TrayMenuManager.activeTrayMenus[screenName]) - return true; - return false; - } - - function _closeHoverNotepad() { - if (activeHoverTrigger !== "notepadButton") - return; - const instance = _notepadWidgetForScreen()?.notepadInstance; - if (!instance) - return; - if (instance.hoverDismissEnabled !== undefined) - instance.hoverDismissEnabled = false; - if (typeof instance.hideFromHoverDismiss === "function") - instance.hideFromHoverDismiss(); - else if (typeof instance.hide === "function") - instance.hide(); - } - - function closeHoverSurfaces() { - _closeHoverNotepad(); - activeHoverTrigger = ""; - PopoutManager.closePopoutForScreen(barWindow?.screen); - TrayMenuManager.closeAllMenus(); - } - - // 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") - return false; - if (instance.hoverDismissEnabled !== undefined) - instance.hoverDismissEnabled = true; - instance.show(); - return true; - } - - function _syncHoverTriggerState() { - if (activeHoverTrigger === "notepadButton") { - const inst = _notepadWidgetForScreen()?.notepadInstance; - if (!inst?.isVisible) - activeHoverTrigger = ""; - return; - } - if (activeHoverTrigger === "") - return; - if (!hasOpenHoverSurface()) - activeHoverTrigger = ""; - } - - function hasOpenHoverSurface() { - if (activeHoverTrigger === "") - return false; - if (activeHoverTrigger === "notepadButton") { - 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; - if (popout.dashVisible !== undefined) - return !!popout.dashVisible || !!popout.isClosing; - if (popout.notificationHistoryVisible !== undefined) - return !!popout.notificationHistoryVisible || !!popout.isClosing; - return !!(popout.shouldBeVisible || popout.isClosing); - } - - function openHoverPopoutForHit(hit) { - if (!hit?.widgetItem) - return false; - - const widgetId = hit.widgetId; - const widgetItem = hit.widgetItem; - const section = hit.section; - const mode = "hover"; - const base = { - widgetItem, - section, - mode - }; - - if (widgetId === "systemTray") { - if (typeof widgetItem.openHoverAtGlobalPoint !== "function") - return false; - return !!widgetItem.openHoverAtGlobalPoint(hit.globalX, hit.globalY); - } - - if (typeof widgetItem.triggerHoverPopout === "function") { - widgetItem.triggerHoverPopout(hit.widgetId); - return true; - } - - switch (widgetId) { - case "launcherButton": - return openWidgetPopout(Object.assign({}, base, { - loader: appDrawerLoader, - triggerSource: "appDrawer", - visualItem: widgetItem - })); - case "clipboard": - return openWidgetPopout(Object.assign({}, base, { - loader: clipboardHistoryPopoutLoader, - triggerSource: "clipboard", - prepare: popout => { - popout.activeTab = "recents"; - } - })); - case "clock": - return openWidgetPopout(Object.assign({}, base, { - loader: dankDashPopoutLoader, - tabIndex: 0, - triggerSource: _dashTriggerSource(section, 0), - useCenterSection: true, - setTriggerScreen: true - })); - case "music": - return openWidgetPopout(Object.assign({}, base, { - loader: dankDashPopoutLoader, - tabIndex: 1, - triggerSource: _dashTriggerSource(section, 1), - useCenterSection: true, - setTriggerScreen: true - })); - case "weather": - return openWidgetPopout(Object.assign({}, base, { - loader: dankDashPopoutLoader, - tabIndex: 3, - triggerSource: _dashTriggerSource(section, 3), - useCenterSection: true, - setTriggerScreen: true - })); - case "cpuUsage": - return openWidgetPopout(Object.assign({}, base, { - loader: processListPopoutLoader, - triggerSource: "cpu" - })); - case "memUsage": - return openWidgetPopout(Object.assign({}, base, { - loader: processListPopoutLoader, - triggerSource: "memory" - })); - case "cpuTemp": - return openWidgetPopout(Object.assign({}, base, { - loader: processListPopoutLoader, - triggerSource: "cpu_temp" - })); - case "gpuTemp": - return openWidgetPopout(Object.assign({}, base, { - loader: processListPopoutLoader, - triggerSource: "gpu_temp" - })); - case "notificationButton": - return openWidgetPopout(Object.assign({}, base, { - loader: notificationCenterLoader, - triggerSource: "notifications", - setTriggerScreen: true - })); - case "battery": - return openWidgetPopout(Object.assign({}, base, { - loader: batteryPopoutLoader, - triggerSource: "battery" - })); - case "layout": - return openWidgetPopout(Object.assign({}, base, { - loader: layoutPopoutLoader, - triggerSource: "layout" - })); - case "vpn": - return openWidgetPopout(Object.assign({}, base, { - loader: vpnPopoutLoader, - triggerSource: "vpn" - })); - case "controlCenterButton": - if (openWidgetPopout(Object.assign({}, base, { - loader: controlCenterLoader, - triggerSource: "controlCenter", - setTriggerScreen: true - }))) { - if (controlCenterLoader.item?.shouldBeVisible && NetworkService.wifiEnabled) - NetworkService.scanWifi(); - return true; - } - return false; - case "systemUpdate": - return openWidgetPopout(Object.assign({}, base, { - loader: systemUpdateLoader, - triggerSource: "systemUpdate", - visualItem: widgetItem - })); - case "notepadButton": - return openNotepadHover(widgetItem); - default: - return false; - } - } - - function checkHoverPopout(gx, gy) { - if (!hoverPopoutsEnabled) - return; - - _lastHoverGlobalX = gx; - _lastHoverGlobalY = gy; - PopoutManager.updateHoverCursor(gx, gy); - _syncHoverTriggerState(); - - // Ignore hover events when a popout is pinned open. - if (PopoutManager.isActivePopoutPinned(barWindow?.screen)) - return; - - const hit = findWidgetAtGlobalPoint(gx, gy); - if (!hit) { - _cancelPendingHover(); - scheduleHoverClose(gx, gy); - return; - } - - hit.globalX = gx; - hit.globalY = gy; - - let triggerKey = hit.widgetId; - if (hit.widgetId === "systemTray") - triggerKey = hit.widgetItem.hoverTriggerAtGlobalPoint?.(gx, gy) || ""; - else if (hit.widgetId === "clock") - triggerKey = _dashTriggerSource(hit.section, 0); - else if (hit.widgetId === "music") - triggerKey = _dashTriggerSource(hit.section, 1); - else if (hit.widgetId === "weather") - triggerKey = _dashTriggerSource(hit.section, 3); - - if (!triggerKey) { - _cancelPendingHover(); - scheduleHoverClose(gx, gy); - return; - } - - _hoverCloseTimer.stop(); - - if (triggerKey === activeHoverTrigger && hasOpenHoverSurface()) { - _cancelPendingHover(); - return; - } - - _pendingHoverHit = hit; - if (_pendingHoverTrigger !== triggerKey) { - _pendingHoverTrigger = triggerKey; - if (hoverPopoutDelay <= 0) - _commitPendingHover(); - else - _hoverIntentTimer.restart(); - } - } - - function _cancelPendingHover() { - _hoverIntentTimer.stop(); - _pendingHoverHit = null; - _pendingHoverTrigger = ""; - } - - // 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; - - 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. PopoutManager also owns handoff between loaded popouts, so only - // pre-close special/unmanaged surfaces here. - if (triggerKey !== activeHoverTrigger && activeHoverTrigger !== "" && !_hitTargetsActivePopout(hit)) { - if (!managerOwnsTransition) { - _beginSupersededCloseForActive(); - closeHoverSurfaces(); - } - } - - if (!openHoverPopoutForHit(hit)) { - if (activeHoverTrigger !== "") - closeHoverSurfaces(); - return; - } - - 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/DankBarHoverController.qml b/quickshell/Modules/DankBar/DankBarHoverController.qml new file mode 100644 index 00000000..27aaa5db --- /dev/null +++ b/quickshell/Modules/DankBar/DankBarHoverController.qml @@ -0,0 +1,938 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Services + +Item { + id: root + + required property var barContent + required property var barWindow + required property var barConfig + required property var hLeftSection + required property var hCenterSection + required property var hRightSection + required property var vLeftSection + required property var vCenterSection + required property var vRightSection + + property var leftWidgetsModel + property var centerWidgetsModel + property var rightWidgetsModel + + property string activeHoverTrigger: "" + readonly property bool hoverPopoutsEnabled: barConfig?.hoverPopouts ?? false + readonly property int hoverPopoutDelay: Math.max(0, barConfig?.hoverPopoutDelay ?? 150) + + property real _lastHoverGlobalX: 0 + property real _lastHoverGlobalY: 0 + property bool _hitTestPending: false + property bool _barHovered: false + property bool _barExitPending: false + property var _pendingHoverHit: null + property string _pendingHoverTrigger: "" + + property bool _candidateCacheValid: false + property var _candidateCache: [] + property var _candidateWatchers: [] + property bool _lastLookupWasMiss: false + + width: 0 + height: 0 + + onLeftWidgetsModelChanged: invalidateCandidateCache() + onCenterWidgetsModelChanged: invalidateCandidateCache() + onRightWidgetsModelChanged: invalidateCandidateCache() + + onHoverPopoutsEnabledChanged: { + if (hoverPopoutsEnabled) + return; + cancelQueuedHitTest(); + _cancelPendingHover(); + _hoverCloseTimer.stop(); + if (hasOpenHoverSurface() && !isActiveHoverSurfacePinned()) + closeHoverSurfaces(); + activeHoverTrigger = ""; + } + + Component.onDestruction: _disconnectCandidateWatchers() + + Connections { + target: root.barContent + + function onWidthChanged() { + root.invalidateCandidateCache(); + } + + function onHeightChanged() { + root.invalidateCandidateCache(); + } + } + + Connections { + target: root.barWindow + + function onScreenChanged() { + root.invalidateCandidateCache(); + } + } + + Connections { + target: BarWidgetService + + function onWidgetRegistered(_widgetId, screenName) { + if (screenName === root.barWindow?.screen?.name) + root.invalidateCandidateCache(); + } + + function onWidgetUnregistered(_widgetId, screenName) { + if (screenName === root.barWindow?.screen?.name) + root.invalidateCandidateCache(); + } + } + + FrameAnimation { + running: root._hitTestPending + onTriggered: { + root._hitTestPending = false; + root.checkHoverPopout(root._lastHoverGlobalX, root._lastHoverGlobalY); + } + } + + Timer { + id: _hoverIntentTimer + interval: root.hoverPopoutDelay + repeat: false + onTriggered: root._commitPendingHover() + } + + // Grace timer to prevent flicker when crossing gaps. + Timer { + id: _hoverCloseTimer + interval: 120 + repeat: false + onTriggered: root._commitHoverClose() + } + + function queueHoverPoint(gx, gy) { + _lastHoverGlobalX = gx; + _lastHoverGlobalY = gy; + _barHovered = true; + _barExitPending = false; + PopoutManager.updateHoverCursor(gx, gy); + if (hoverPopoutsEnabled) + _hitTestPending = true; + } + + function updateBarHovered(hovered) { + _barHovered = hovered; + if (hovered) { + _barExitPending = false; + _hoverCloseTimer.stop(); + return; + } + + cancelQueuedHitTest(); + _cancelPendingHover(); + if (!hoverPopoutsEnabled || isActiveHoverSurfacePinned()) + return; + _barExitPending = true; + _hoverCloseTimer.restart(); + } + + function cancelQueuedHitTest() { + _hitTestPending = false; + } + + function recheckLatestPoint() { + checkHoverPopout(_lastHoverGlobalX, _lastHoverGlobalY); + } + + function resetForBarGeometryChange() { + invalidateCandidateCache(); + cancelQueuedHitTest(); + _cancelPendingHover(); + _hoverCloseTimer.stop(); + barContent._pendingPopoutOpenSpec = null; + + const activePopout = PopoutManager.getActivePopout(barWindow?.screen); + const hasTransientSurface = activeHoverTrigger !== "" || activePopout?.hoverDismissEnabled === true; + if (hasTransientSurface && !isActiveHoverSurfacePinned()) + closeHoverSurfaces(); + else + activeHoverTrigger = ""; + } + + function invalidateCandidateCache() { + _candidateCacheValid = false; + _candidateCache = []; + _lastLookupWasMiss = false; + _disconnectCandidateWatchers(); + } + + function _disconnectCandidateWatchers() { + const watchers = _candidateWatchers; + _candidateWatchers = []; + for (let i = 0; i < watchers.length; i++) { + const watcher = watchers[i]; + try { + const signal = watcher.object?.[watcher.signalName]; + if (signal && typeof signal.disconnect === "function") + signal.disconnect(watcher.callback); + } catch (e) {} + } + } + + function _watchCandidateObject(object) { + if (!object) + return; + for (let i = 0; i < _candidateWatchers.length; i++) { + if (_candidateWatchers[i].object === object) + return; + } + + const signalNames = ["xChanged", "yChanged", "widthChanged", "heightChanged", "visibleChanged", "parentChanged", "childrenChanged", "itemChanged", "activeChanged", "destroyed"]; + for (let i = 0; i < signalNames.length; i++) { + const signalName = signalNames[i]; + try { + const signal = object[signalName]; + if (!signal || typeof signal.connect !== "function") + continue; + const callback = function () { + root.invalidateCandidateCache(); + }; + signal.connect(callback); + _candidateWatchers.push({ + object, + signalName, + callback + }); + } catch (e) {} + } + } + + function _getBarSections() { + if (barWindow.isVertical) { + return [ + { + section: vLeftSection, + name: "left" + }, + { + section: vCenterSection, + name: "center" + }, + { + section: vRightSection, + name: "right" + } + ]; + } + return [ + { + section: hLeftSection, + name: "left" + }, + { + section: hCenterSection, + name: "center" + }, + { + section: hRightSection, + name: "right" + } + ]; + } + + // The widget registry is keyed by (widgetId, screenName) + function _itemBelongsToThisBar(item) { + const owner = barContent; + if (!owner || !item) + return true; + let node = item; + let guard = 0; + while (node && guard < 100) { + if (node === owner) + return true; + node = node.parent; + guard++; + } + return false; + } + + function _findWidgetHostInWrapper(wrapper) { + if (wrapper.widgetId !== undefined) + return wrapper; + const children = wrapper.children || []; + for (let i = 0; i < children.length; i++) { + if (children[i].widgetId !== undefined) + return children[i]; + } + return null; + } + + function _collectSectionWrappers(section) { + _watchCandidateObject(section); + const layoutLoader = section.widgetLayoutLoader; + _watchCandidateObject(layoutLoader); + const layout = layoutLoader?.item; + if (layout) { + _watchCandidateObject(layout); + return layout.children || []; + } + const children = section.children || []; + const wrappers = []; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (!child || child === layoutLoader) + continue; + if (child.itemData !== undefined || child.widgetId !== undefined || _findWidgetHostInWrapper(child)) + wrappers.push(child); + } + return wrappers; + } + + function _widgetSupportsHoverPopout(widgetId, widgetItem) { + if (!widgetId || !widgetItem) + return false; + if (typeof widgetItem.triggerHoverPopout === "function") + return true; + if (widgetId === "systemTray" && typeof widgetItem.openHoverAtGlobalPoint === "function") + return true; + switch (widgetId) { + case "launcherButton": + case "clipboard": + case "clock": + case "music": + case "weather": + case "cpuUsage": + case "memUsage": + case "cpuTemp": + case "gpuTemp": + case "notificationButton": + case "battery": + case "layout": + case "vpn": + case "controlCenterButton": + case "systemUpdate": + case "notepadButton": + case "systemTray": + return true; + default: + return false; + } + } + + function _enumerateWidgetHosts() { + const hosts = []; + const sections = _getBarSections(); + for (let s = 0; s < sections.length; s++) { + const sectionEntry = sections[s]; + const section = sectionEntry.section; + if (!section) + continue; + const wrappers = _collectSectionWrappers(section); + for (let i = 0; i < wrappers.length; i++) { + const wrapper = wrappers[i]; + const host = _findWidgetHostInWrapper(wrapper); + if (!host?.widgetId) + continue; + _watchCandidateObject(wrapper); + _watchCandidateObject(host); + hosts.push({ + host, + wrapper, + section: sectionEntry.name + }); + } + } + return hosts; + } + + function _collectHoverCandidates() { + const screenName = barWindow.screen?.name; + const candidates = []; + const seen = new Set(); + + function addCandidate(widgetId, widgetItem, sectionHint) { + if (!widgetId || !widgetItem || seen.has(widgetItem)) + return; + if (!root._itemBelongsToThisBar(widgetItem)) + return; + if (!root._widgetSupportsHoverPopout(widgetId, widgetItem)) + return; + if (!root.barContent.getWidgetVisible(widgetId)) + return; + seen.add(widgetItem); + candidates.push({ + widgetId, + widgetItem, + section: widgetItem.section || sectionHint || "right", + wrapper: null, + host: null + }); + } + + if (screenName) { + const registry = BarWidgetService.widgetRegistry; + if (registry && typeof registry === "object") { + for (const widgetId in registry) { + const screenMap = registry[widgetId]; + if (!screenMap || typeof screenMap !== "object") + continue; + const widgetItem = screenMap[screenName]; + if (widgetItem) + addCandidate(widgetId, widgetItem, widgetItem.section); + } + } + } + + const hosts = _enumerateWidgetHosts(); + for (let i = 0; i < hosts.length; i++) { + const entry = hosts[i]; + if (!entry.host?.item) + continue; + const existing = candidates.find(candidate => candidate.widgetItem === entry.host.item); + if (existing) { + existing.wrapper = entry.wrapper; + existing.host = entry.host; + if (!existing.section) + existing.section = entry.section; + continue; + } + if (!_widgetSupportsHoverPopout(entry.host.widgetId, entry.host.item)) + continue; + candidates.push({ + widgetId: entry.host.widgetId, + widgetItem: entry.host.item, + section: entry.host.item.section || entry.section, + wrapper: entry.wrapper, + host: entry.host + }); + } + + return candidates; + } + + function _globalItemBounds(item) { + try { + const topLeft = item.mapToItem(null, 0, 0); + return { + x: topLeft.x, + y: topLeft.y, + width: item.width, + height: item.height + }; + } catch (e) { + return null; + } + } + + function _hitBoundsForWidget(widgetItem, wrapper) { + try { + if (!widgetItem?.visible) + return null; + + if (widgetItem.visualContent !== undefined) { + const visual = widgetItem.visualContent; + if (visual && visual.width > 0 && visual.height > 0) + return _globalItemBounds(visual); + } + + if (widgetItem.width > 0 && widgetItem.height > 0) + return _globalItemBounds(widgetItem); + + if (wrapper && wrapper.width > 0 && wrapper.height > 0) + return _globalItemBounds(wrapper); + } catch (e) {} + return null; + } + + function _pointInBounds(gx, gy, bounds) { + return gx >= bounds.x && gx < bounds.x + bounds.width && gy >= bounds.y && gy < bounds.y + bounds.height; + } + + function _sameBounds(a, b) { + return !!a && !!b && a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; + } + + function _buildCandidateCache() { + _disconnectCandidateWatchers(); + const candidates = _collectHoverCandidates(); + const cache = []; + for (let i = 0; i < candidates.length; i++) { + const entry = candidates[i]; + const bounds = _hitBoundsForWidget(entry.widgetItem, entry.wrapper); + _watchCandidateObject(entry.widgetItem); + _watchCandidateObject(entry.wrapper); + _watchCandidateObject(entry.host); + try { + _watchCandidateObject(entry.widgetItem?.visualContent); + } catch (e) {} + if (!bounds || bounds.width <= 0 || bounds.height <= 0) + continue; + cache.push({ + widgetId: entry.widgetId, + widgetItem: entry.widgetItem, + section: entry.section, + wrapper: entry.wrapper, + bounds + }); + } + _candidateCache = cache; + _candidateCacheValid = true; + _lastLookupWasMiss = false; + } + + function _scanCandidateCache(gx, gy) { + let best = null; + let bestArea = Infinity; + for (let i = 0; i < _candidateCache.length; i++) { + const entry = _candidateCache[i]; + const bounds = entry.bounds; + if (!_pointInBounds(gx, gy, bounds)) + continue; + const area = bounds.width * bounds.height; + if (area < bestArea) { + bestArea = area; + best = entry; + } + } + return best; + } + + function _validatedHit(entry, gx, gy) { + if (!entry) + return null; + const liveBounds = _hitBoundsForWidget(entry.widgetItem, entry.wrapper); + if (!liveBounds || !_pointInBounds(gx, gy, liveBounds)) + return null; + if (!_sameBounds(entry.bounds, liveBounds)) + return null; + return { + widgetId: entry.widgetId, + widgetItem: entry.widgetItem, + section: entry.section + }; + } + + function findWidgetAtGlobalPoint(gx, gy) { + if (!_candidateCacheValid) + _buildCandidateCache(); + + let entry = _scanCandidateCache(gx, gy); + let hit = _validatedHit(entry, gx, gy); + if (entry && !hit) { + invalidateCandidateCache(); + _buildCandidateCache(); + entry = _scanCandidateCache(gx, gy); + hit = _validatedHit(entry, gx, gy); + } else if (!entry && !_lastLookupWasMiss) { + // One live rebuild on entry into an empty gap covers layout changes whose + // source did not expose a QML geometry signal without rescanning every frame. + invalidateCandidateCache(); + _buildCandidateCache(); + entry = _scanCandidateCache(gx, gy); + hit = _validatedHit(entry, gx, gy); + } + + _lastLookupWasMiss = !hit; + return hit; + } + + function dashTriggerSource(section, tabIndex) { + return (barConfig?.id ?? "default") + "-" + section + "-" + tabIndex; + } + + function _notepadWidgetForScreen() { + // Prefer this bar's own enumerated candidates; the registry is screen-keyed and a + // sibling bar on the same screen can shadow it. + if (!_candidateCacheValid) + _buildCandidateCache(); + for (let i = 0; i < _candidateCache.length; i++) { + if (_candidateCache[i].widgetId === "notepadButton") + return _candidateCache[i].widgetItem; + } + const screenName = barWindow?.screen?.name; + const fromRegistry = screenName ? BarWidgetService.getWidget("notepadButton", screenName) : null; + if (fromRegistry && _itemBelongsToThisBar(fromRegistry)) + return fromRegistry; + return null; + } + + function notepadContainsGlobalPoint(gx, gy) { + const instance = _notepadWidgetForScreen()?.notepadInstance; + if (!instance?.isVisible || typeof instance.containsGlobalPoint !== "function") + return false; + return instance.containsGlobalPoint(gx, gy); + } + + function isActiveHoverSurfacePinned() { + if (activeHoverTrigger === "notepadButton") { + const instance = _notepadWidgetForScreen()?.notepadInstance; + if (instance?.hoverDismissSuspended === true) + return true; + } + return PopoutManager.isActivePopoutPinned(barWindow?.screen); + } + + function cursorOverHoverChain(gx, gy, excludedBarWindow) { + if (PopoutManager.cursorOverBar(gx, gy, undefined, excludedBarWindow)) + return true; + const popout = PopoutManager.getActivePopout(barWindow?.screen); + if (popout?.containsGlobalPoint?.(gx, gy)) + return true; + if (notepadContainsGlobalPoint(gx, gy)) + return true; + const screenName = barWindow.screen?.name; + if (screenName && TrayMenuManager.activeTrayMenus[screenName]) + return true; + return false; + } + + function _closeHoverNotepad() { + if (activeHoverTrigger !== "notepadButton") + return; + const instance = _notepadWidgetForScreen()?.notepadInstance; + if (!instance) + return; + if (instance.hoverDismissEnabled !== undefined) + instance.hoverDismissEnabled = false; + if (typeof instance.hideFromHoverDismiss === "function") + instance.hideFromHoverDismiss(); + else if (typeof instance.hide === "function") + instance.hide(); + } + + function closeHoverSurfaces() { + _closeHoverNotepad(); + activeHoverTrigger = ""; + PopoutManager.closePopoutForScreen(barWindow?.screen); + TrayMenuManager.closeAllMenus(); + } + + function _beginSupersededCloseForActive() { + const popout = PopoutManager.getActivePopout(barWindow?.screen); + if (popout && typeof popout.beginSupersededClose === "function") + popout.beginSupersededClose(); + } + + function openNotepadHover(widgetItem) { + const instance = widgetItem.prepareNotepadInstance?.(widgetItem.notepadInstance) ?? widgetItem.notepadInstance; + if (!instance || typeof instance.show !== "function") + return false; + if (instance.hoverDismissEnabled !== undefined) + instance.hoverDismissEnabled = true; + instance.show(); + return true; + } + + function _syncHoverTriggerState() { + if (activeHoverTrigger === "notepadButton") { + const instance = _notepadWidgetForScreen()?.notepadInstance; + if (!instance?.isVisible) + activeHoverTrigger = ""; + return; + } + if (activeHoverTrigger !== "" && !hasOpenHoverSurface()) + activeHoverTrigger = ""; + } + + function hasOpenHoverSurface() { + if (activeHoverTrigger === "") + return false; + if (activeHoverTrigger === "notepadButton") { + const instance = _notepadWidgetForScreen()?.notepadInstance; + return instance?.isVisible ?? false; + } + if (activeHoverTrigger.startsWith("tray-")) { + const screenName = barWindow.screen?.name; + return !!(screenName && TrayMenuManager.activeTrayMenus[screenName]); + } + const popout = PopoutManager.getActivePopout(barWindow?.screen); + if (!popout) + return false; + if (popout.dashVisible !== undefined) + return !!popout.dashVisible || !!popout.isClosing; + if (popout.notificationHistoryVisible !== undefined) + return !!popout.notificationHistoryVisible || !!popout.isClosing; + return !!(popout.shouldBeVisible || popout.isClosing); + } + + function _loaderForWidgetId(widgetId) { + switch (widgetId) { + case "launcherButton": + return PopoutService.appDrawerLoader; + case "clipboard": + return PopoutService.clipboardHistoryPopoutLoader; + case "clock": + case "music": + case "weather": + return PopoutService.dankDashPopoutLoader; + case "cpuUsage": + case "memUsage": + case "cpuTemp": + case "gpuTemp": + return PopoutService.processListPopoutLoader; + case "notificationButton": + return PopoutService.notificationCenterLoader; + case "battery": + return PopoutService.batteryPopoutLoader; + case "layout": + return PopoutService.layoutPopoutLoader; + case "vpn": + return PopoutService.vpnPopoutLoader; + case "controlCenterButton": + return PopoutService.controlCenterLoader; + case "systemUpdate": + return PopoutService.systemUpdateLoader; + default: + return null; + } + } + + function openHoverPopoutForHit(hit) { + if (!hit?.widgetItem) + return false; + + const widgetId = hit.widgetId; + const widgetItem = hit.widgetItem; + const section = hit.section; + const base = { + widgetItem, + section, + mode: "hover" + }; + + if (widgetId === "systemTray") { + if (typeof widgetItem.openHoverAtGlobalPoint !== "function") + return false; + return !!widgetItem.openHoverAtGlobalPoint(hit.globalX, hit.globalY); + } + + if (typeof widgetItem.triggerHoverPopout === "function") { + widgetItem.triggerHoverPopout(hit.widgetId); + return true; + } + + const loader = _loaderForWidgetId(widgetId); + switch (widgetId) { + case "launcherButton": + return barContent.openWidgetPopout(Object.assign({}, base, { + loader, + triggerSource: "appDrawer", + visualItem: widgetItem + })); + case "clipboard": + return barContent.openWidgetPopout(Object.assign({}, base, { + loader, + triggerSource: "clipboard", + prepare: popout => { + popout.activeTab = "recents"; + } + })); + case "clock": + case "music": + case "weather": + { + const tabIndex = widgetId === "clock" ? 0 : (widgetId === "music" ? 1 : 3); + return barContent.openWidgetPopout(Object.assign({}, base, { + loader, + tabIndex, + triggerSource: dashTriggerSource(section, tabIndex), + useCenterSection: true, + setTriggerScreen: true + })); + } + case "cpuUsage": + case "memUsage": + case "cpuTemp": + case "gpuTemp": + { + const triggerSources = { + cpuUsage: "cpu", + memUsage: "memory", + cpuTemp: "cpu_temp", + gpuTemp: "gpu_temp" + }; + return barContent.openWidgetPopout(Object.assign({}, base, { + loader, + triggerSource: triggerSources[widgetId] + })); + } + case "notificationButton": + return barContent.openWidgetPopout(Object.assign({}, base, { + loader, + triggerSource: "notifications", + setTriggerScreen: true + })); + case "battery": + case "layout": + case "vpn": + { + const triggerSources = { + battery: "battery", + layout: "layout", + vpn: "vpn" + }; + return barContent.openWidgetPopout(Object.assign({}, base, { + loader, + triggerSource: triggerSources[widgetId] + })); + } + case "controlCenterButton": + if (barContent.openWidgetPopout(Object.assign({}, base, { + loader, + triggerSource: "controlCenter", + setTriggerScreen: true + }))) { + if (loader.item?.shouldBeVisible && NetworkService.wifiEnabled) + NetworkService.scanWifi(); + return true; + } + return false; + case "systemUpdate": + return barContent.openWidgetPopout(Object.assign({}, base, { + loader, + triggerSource: "systemUpdate", + visualItem: widgetItem + })); + case "notepadButton": + return openNotepadHover(widgetItem); + default: + return false; + } + } + + function checkHoverPopout(gx, gy) { + if (!hoverPopoutsEnabled) + return; + + _lastHoverGlobalX = gx; + _lastHoverGlobalY = gy; + PopoutManager.updateHoverCursor(gx, gy); + _syncHoverTriggerState(); + + if (isActiveHoverSurfacePinned()) + return; + + const hit = findWidgetAtGlobalPoint(gx, gy); + if (!hit) { + _cancelPendingHover(); + scheduleHoverClose(gx, gy); + return; + } + + hit.globalX = gx; + hit.globalY = gy; + + let triggerKey = hit.widgetId; + if (hit.widgetId === "systemTray") + triggerKey = hit.widgetItem.hoverTriggerAtGlobalPoint?.(gx, gy) || ""; + else if (hit.widgetId === "clock") + triggerKey = dashTriggerSource(hit.section, 0); + else if (hit.widgetId === "music") + triggerKey = dashTriggerSource(hit.section, 1); + else if (hit.widgetId === "weather") + triggerKey = dashTriggerSource(hit.section, 3); + + if (!triggerKey) { + _cancelPendingHover(); + scheduleHoverClose(gx, gy); + return; + } + + _hoverCloseTimer.stop(); + + if (triggerKey === activeHoverTrigger && hasOpenHoverSurface()) { + _cancelPendingHover(); + return; + } + + _pendingHoverHit = hit; + if (_pendingHoverTrigger !== triggerKey) { + _pendingHoverTrigger = triggerKey; + if (hoverPopoutDelay <= 0) + _commitPendingHover(); + else + _hoverIntentTimer.restart(); + } + } + + function _cancelPendingHover() { + _hoverIntentTimer.stop(); + _pendingHoverHit = null; + _pendingHoverTrigger = ""; + } + + function _hitTargetsActivePopout(hit) { + const active = PopoutManager.getActivePopout(barWindow?.screen); + if (!active || !hit) + return false; + const loader = _loaderForWidgetId(hit.widgetId); + if (!loader) + return false; + return barContent._resolvePopoutFromLoader(loader) === active; + } + + function _commitPendingHover() { + const hit = _pendingHoverHit; + const triggerKey = _pendingHoverTrigger; + _pendingHoverHit = null; + _pendingHoverTrigger = ""; + if (!hit || !hoverPopoutsEnabled) + return; + if (isActiveHoverSurfacePinned()) + return; + if (!PopoutManager.cursorOverBar(_lastHoverGlobalX, _lastHoverGlobalY)) + return; + + const activePopout = PopoutManager.getActivePopout(barWindow?.screen); + const targetLoader = _loaderForWidgetId(hit.widgetId); + const targetPopout = barContent._resolvePopoutFromLoader(targetLoader); + const managerOwnsTransition = !!(activePopout && targetPopout); + + if (triggerKey !== activeHoverTrigger && activeHoverTrigger !== "" && !_hitTargetsActivePopout(hit)) { + if (!managerOwnsTransition) { + _beginSupersededCloseForActive(); + closeHoverSurfaces(); + } + } + + if (!openHoverPopoutForHit(hit)) { + if (activeHoverTrigger !== "") + closeHoverSurfaces(); + return; + } + + activeHoverTrigger = triggerKey; + } + + function scheduleHoverClose(gx, gy) { + cancelQueuedHitTest(); + _cancelPendingHover(); + _barExitPending = false; + if (!hoverPopoutsEnabled) + return; + if (isActiveHoverSurfacePinned()) + return; + if (cursorOverHoverChain(gx, gy)) + return; + _hoverCloseTimer.restart(); + } + + function _commitHoverClose() { + const gx = PopoutManager.hoverCursorGlobalX; + const gy = PopoutManager.hoverCursorGlobalY; + if (isActiveHoverSurfacePinned()) + return; + if (_barHovered) + return; + const excludedBar = _barExitPending ? barWindow : null; + if (cursorOverHoverChain(gx, gy, excludedBar)) + return; + _barExitPending = false; + closeHoverSurfaces(); + } +} diff --git a/quickshell/Modules/DankBar/DankBarWindow.qml b/quickshell/Modules/DankBar/DankBarWindow.qml index 656bb4e1..4d136666 100644 --- a/quickshell/Modules/DankBar/DankBarWindow.qml +++ b/quickshell/Modules/DankBar/DankBarWindow.qml @@ -1123,13 +1123,11 @@ PanelWindow { const gp = barUnitInset.mapToItem(null, point.position.x, point.position.y); lastGlobalX = gp.x; lastGlobalY = gp.y; - topBarContent.checkHoverPopout(gp.x, gp.y); + topBarContent.queueHoverPopout(gp.x, gp.y); } onHoveredChanged: { - if (hovered) - return; - topBarContent.scheduleHoverClose(lastGlobalX, lastGlobalY); + topBarContent.updateHoverBarHovered(hovered); } } } diff --git a/quickshell/Modules/DankBar/LeftSection.qml b/quickshell/Modules/DankBar/LeftSection.qml index 50091e77..9f01094f 100644 --- a/quickshell/Modules/DankBar/LeftSection.qml +++ b/quickshell/Modules/DankBar/LeftSection.qml @@ -19,6 +19,7 @@ Item { property bool forceVerticalLayout: false readonly property bool isVertical: overrideAxisLayout ? forceVerticalLayout : (axis?.isVertical ?? false) + property alias widgetLayoutLoader: layoutLoader implicitHeight: layoutLoader.item ? layoutLoader.item.implicitHeight : 0 implicitWidth: layoutLoader.item ? layoutLoader.item.implicitWidth : 0 diff --git a/quickshell/Modules/DankBar/RightSection.qml b/quickshell/Modules/DankBar/RightSection.qml index 2f19dff0..e9f9a73d 100644 --- a/quickshell/Modules/DankBar/RightSection.qml +++ b/quickshell/Modules/DankBar/RightSection.qml @@ -19,6 +19,7 @@ Item { property bool forceVerticalLayout: false readonly property bool isVertical: overrideAxisLayout ? forceVerticalLayout : (axis?.isVertical ?? false) + property alias widgetLayoutLoader: layoutLoader implicitHeight: layoutLoader.item ? layoutLoader.item.implicitHeight : 0 implicitWidth: layoutLoader.item ? layoutLoader.item.implicitWidth : 0 diff --git a/quickshell/Modules/Notepad/Notepad.qml b/quickshell/Modules/Notepad/Notepad.qml index 912b029e..c9d65656 100644 --- a/quickshell/Modules/Notepad/Notepad.qml +++ b/quickshell/Modules/Notepad/Notepad.qml @@ -23,6 +23,7 @@ Item { property bool showSettingsMenu: false property string pendingSaveContent: "" readonly property bool conflictBannerVisible: currentTab !== null && NotepadStorageService.conflictTabId === currentTab.id + readonly property bool anyModalOpen: fileDialogOpen || confirmationDialogOpen property var slideout: null property bool inPopout: false property bool surfaceVisible: slideout ? slideout.isVisible : true @@ -50,6 +51,14 @@ Item { slideout.suppressOverlayLayer = fileDialogOpen; } + Binding { + target: root.slideout + property: "hoverDismissSuspended" + value: root.anyModalOpen + when: root.slideout !== null + restoreMode: Binding.RestoreBindingOrValue + } + Connections { target: slideout enabled: slideout !== null diff --git a/quickshell/Modules/Settings/DankBarTab.qml b/quickshell/Modules/Settings/DankBarTab.qml index 67cf84a4..807c10b0 100644 --- a/quickshell/Modules/Settings/DankBarTab.qml +++ b/quickshell/Modules/Settings/DankBarTab.qml @@ -172,6 +172,7 @@ Item { scrollXBehavior: defaultBar.scrollXBehavior ?? "column", scrollYBehavior: defaultBar.scrollYBehavior ?? "workspace", hoverPopouts: defaultBar.hoverPopouts ?? false, + hoverPopoutDelay: defaultBar.hoverPopoutDelay ?? 150, shadowIntensity: defaultBar.shadowIntensity ?? 0, shadowOpacity: defaultBar.shadowOpacity ?? 60, shadowDirectionMode: defaultBar.shadowDirectionMode ?? "inherit", diff --git a/quickshell/Widgets/DankPopoutConnected.qml b/quickshell/Widgets/DankPopoutConnected.qml index 64d93764..54025239 100644 --- a/quickshell/Widgets/DankPopoutConnected.qml +++ b/quickshell/Widgets/DankPopoutConnected.qml @@ -42,6 +42,11 @@ Item { property real _chromeAnimTravelX: 1 property real _chromeAnimTravelY: 1 property bool _fullSyncQueued: false + property bool _publishedBodyValid: false + property real _publishedBodyX: 0 + property real _publishedBodyY: 0 + property real _publishedBodyW: 0 + property real _publishedBodyH: 0 property real storedBarThickness: Theme.barHeight - 4 property real storedBarSpacing: 4 @@ -131,7 +136,11 @@ Item { updateBodyState: function(_name, ownerId, bodyX, bodyY, bodyW, bodyH) { return ConnectedModeState.setPopoutBody(ownerId, bodyX, bodyY, bodyW, bodyH); } - onRecoveryRequested: root._queueFullSync() + onClaimIdChanged: root._resetPublishedBody() + onRecoveryRequested: { + root._resetPublishedBody(); + root._queueFullSync(); + } } property var _lastOpenedScreen: null @@ -234,11 +243,15 @@ Item { const visible = visibleOverride !== undefined ? !!visibleOverride : contentWindow.visible; const presented = contentWindow.visible || root.shouldBeVisible; const phase = root.isClosing ? "closing" : (!presented ? "hidden" : (!contentWindow.visible && root.shouldBeVisible ? "opening" : "open")); + const bodyX = Theme.snap(root.pubBodyX, root.dpr); + const bodyY = Theme.snap(root.pubBodyY, root.dpr); + const bodyW = Theme.snap(root.pubBodyW, root.dpr); + const bodyH = Theme.snap(root.pubBodyH, root.dpr); const bodyRect = { - "x": root.pubBodyX, - "y": root.pubBodyY, - "width": root.pubBodyW, - "height": root.pubBodyH + "x": bodyX, + "y": bodyY, + "width": bodyW, + "height": bodyH }; const animationOffset = { "x": _connectedChromeAnimX(), @@ -255,10 +268,10 @@ Item { "animationOffset": animationOffset, "scale": 1, "opacity": Theme.connectedSurfaceColor.a, - "bodyX": root.pubBodyX, - "bodyY": root.pubBodyY, - "bodyW": root.pubBodyW, - "bodyH": root.pubBodyH, + "bodyX": bodyX, + "bodyY": bodyY, + "bodyW": bodyW, + "bodyH": bodyH, "animX": animationOffset.x, "animY": animationOffset.y, "screen": root.screen ? root.screen.name : "", @@ -270,10 +283,15 @@ Item { function _publishConnectedChromeState(forceClaim, visibleOverride) { if (!root.frameOwnsConnectedChrome || !root.screen) return false; - return chromeLease.publish(_connectedChromeState(visibleOverride), !!forceClaim); + const state = _connectedChromeState(visibleOverride); + const published = chromeLease.publish(state, !!forceClaim); + if (published) + _rememberPublishedBody(state.bodyX, state.bodyY, state.bodyW, state.bodyH); + return published; } function _releaseConnectedChromeState() { + _resetPublishedBody(); chromeLease.release(); } @@ -312,7 +330,26 @@ Item { return; if (!contentWindow.visible && !shouldBeVisible) return; - chromeLease.updateBody(root.pubBodyX, root.pubBodyY, root.pubBodyW, root.pubBodyH); + const bodyX = Theme.snap(root.pubBodyX, root.dpr); + const bodyY = Theme.snap(root.pubBodyY, root.dpr); + const bodyW = Theme.snap(root.pubBodyW, root.dpr); + const bodyH = Theme.snap(root.pubBodyH, root.dpr); + if (_publishedBodyValid && _publishedBodyX === bodyX && _publishedBodyY === bodyY && _publishedBodyW === bodyW && _publishedBodyH === bodyH) + return; + if (chromeLease.updateBody(bodyX, bodyY, bodyW, bodyH)) + _rememberPublishedBody(bodyX, bodyY, bodyW, bodyH); + } + + function _rememberPublishedBody(bodyX, bodyY, bodyW, bodyH) { + _publishedBodyX = bodyX; + _publishedBodyY = bodyY; + _publishedBodyW = bodyW; + _publishedBodyH = bodyH; + _publishedBodyValid = true; + } + + function _resetPublishedBody() { + _publishedBodyValid = false; } property bool _animSyncQueued: false @@ -357,7 +394,10 @@ Item { onContentAnimYChanged: _queueAnimSync() onRenderedAlignedYChanged: _queueBodySync() onRenderedAlignedHeightChanged: _queueBodySync() - onScreenChanged: _queueFullSync() + onScreenChanged: { + _resetPublishedBody(); + _queueFullSync(); + } onEffectiveBarPositionChanged: _queueFullSync() Connections { @@ -412,8 +452,7 @@ Item { property bool hoverDismissSuspended: false function cancelHoverDismiss() { - hoverDismissTracker.cancelPending(); - _hoverDismissGrace.stop(); + hoverDismissController.cancelPending(); } function closeFromHoverDismiss() { @@ -428,6 +467,7 @@ Item { function open() { if (!screen) return; + _resetPublishedBody(); closeTimer.stop(); isClosing = false; animationsEnabled = false; @@ -485,6 +525,7 @@ Item { _freezeMorphTravel(); else _endMorphTravel(); + _resetPublishedBody(); isClosing = true; shouldBeVisible = false; _primeContent = false; @@ -875,40 +916,6 @@ 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.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.hoverDismissSuspended || !root.shouldBeVisible) - return; - if (root._hoverOverBody) - return; - if (PopoutManager.cursorOverBar(PopoutManager.hoverCursorGlobalX, PopoutManager.hoverCursorGlobalY)) - return; - root.closeFromHoverDismiss(); - } - } - DismissZone { id: _dismissZone barPosition: root.effectiveBarPosition @@ -927,26 +934,13 @@ Item { visible: false color: "transparent" - MouseArea { + PopoutHoverDismiss { + id: hoverDismissController anchors.fill: parent - z: -1 - acceptedButtons: Qt.NoButton - hoverEnabled: true - onPositionChanged: mouse => { - const gp = mapToItem(null, mouse.x, mouse.y); - PopoutManager.updateHoverCursor(gp.x, gp.y); - } - } - - HoverDismissTracker { - id: hoverDismissTracker - anchors.fill: parent - enabled: root.hoverDismissEnabled && !root.hoverDismissSuspended && root.shouldBeVisible - shouldDismiss: function () { - return !PopoutManager.cursorOverBar(PopoutManager.hoverCursorGlobalX, PopoutManager.hoverCursorGlobalY); - } + dismissEnabled: root.hoverDismissEnabled + dismissSuspended: root.hoverDismissSuspended + surfaceVisible: root.shouldBeVisible onDismissRequested: root.closeFromHoverDismiss() - onHoverMoved: (gx, gy) => PopoutManager.updateHoverCursor(gx, gy) } WindowBlur { @@ -1103,17 +1097,9 @@ 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); - } + PopoutHoverBodyTracker { + controller: hoverDismissController + trackingEnabled: root.hoverDismissEnabled && root.shouldBeVisible } QtObject { diff --git a/quickshell/Widgets/DankPopoutStandalone.qml b/quickshell/Widgets/DankPopoutStandalone.qml index e05c617b..4fd3f3fa 100644 --- a/quickshell/Widgets/DankPopoutStandalone.qml +++ b/quickshell/Widgets/DankPopoutStandalone.qml @@ -40,8 +40,7 @@ Item { property bool hoverDismissSuspended: false function cancelHoverDismiss() { - hoverDismissTracker.cancelPending(); - _hoverDismissGrace.stop(); + hoverDismissController.cancelPending(); } function closeFromHoverDismiss() { @@ -53,40 +52,6 @@ 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.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.hoverDismissSuspended || !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 @@ -637,26 +602,15 @@ Item { color: "transparent" readonly property bool closeVisualActive: root.shouldBeVisible || root.isClosing - MouseArea { + PopoutHoverDismiss { + id: hoverDismissController anchors.fill: parent - z: -1 - acceptedButtons: Qt.NoButton - hoverEnabled: true - onPositionChanged: mouse => { - const gp = mapToItem(null, mouse.x, mouse.y); - PopoutManager.updateHoverCursor(gp.x, gp.y); - } - } - - HoverDismissTracker { - id: hoverDismissTracker - anchors.fill: parent - enabled: root.hoverDismissEnabled && !root.hoverDismissSuspended && root.shouldBeVisible - shouldDismiss: function () { - return !PopoutManager.cursorOverBar(PopoutManager.hoverCursorGlobalX, PopoutManager.hoverCursorGlobalY); - } + dismissEnabled: root.hoverDismissEnabled + dismissSuspended: root.hoverDismissSuspended + surfaceVisible: root.shouldBeVisible + globalOffsetX: root._surfaceMarginLeft + globalOffsetY: root._fullHeight ? 0 : root._surfaceMarginTop onDismissRequested: root.closeFromHoverDismiss() - onHoverMoved: (gx, gy) => PopoutManager.updateHoverCursor(gx, gy) } WindowBlur { @@ -776,17 +730,9 @@ 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); - } + PopoutHoverBodyTracker { + controller: hoverDismissController + trackingEnabled: root.hoverDismissEnabled && root.shouldBeVisible } // openProgress: 0 = closed (at offset, scaleCollapsed), 1 = open (at 0, scale 1). diff --git a/quickshell/Widgets/DankSlideout.qml b/quickshell/Widgets/DankSlideout.qml index 243a035c..baf64b07 100644 --- a/quickshell/Widgets/DankSlideout.qml +++ b/quickshell/Widgets/DankSlideout.qml @@ -14,6 +14,7 @@ PanelWindow { property bool isVisible: false property bool hoverDismissEnabled: false + property bool hoverDismissSuspended: false property var targetScreen: null property var modelData: null property bool triggerUsesOverlayLayer: false @@ -26,6 +27,7 @@ PanelWindow { property real edgeGap: 0 property string slideEdge: "right" readonly property bool slideFromLeft: slideEdge === "left" + readonly property real surfaceOriginX: slideFromLeft ? 0 : Math.max(0, (modelData?.width ?? width) - width) property Component content: null property string title: "" property alias container: contentContainer @@ -48,6 +50,8 @@ PanelWindow { } function hideFromHoverDismiss() { + if (hoverDismissSuspended) + return; hoverDismissEnabled = false; slideAnimation.duration = Math.round(Theme.expressiveDurations.expressiveDefaultSpatial); hide(); @@ -62,7 +66,8 @@ PanelWindow { return false; const padding = 24; const topLeft = slideContainer.mapToItem(null, 0, 0); - return gx >= topLeft.x - padding && gx < topLeft.x + slideContainer.width + padding && gy >= topLeft.y - padding && gy < topLeft.y + slideContainer.height + padding; + const globalX = surfaceOriginX + topLeft.x; + return gx >= globalX - padding && gx < globalX + slideContainer.width + padding && gy >= topLeft.y - padding && gy < topLeft.y + slideContainer.height + padding; } function toggle() { @@ -86,25 +91,15 @@ PanelWindow { color: "transparent" - MouseArea { - anchors.fill: parent - z: -1 - acceptedButtons: Qt.NoButton - hoverEnabled: true - onPositionChanged: mouse => { - const gp = mapToItem(null, mouse.x, mouse.y); - PopoutManager.updateHoverCursor(gp.x, gp.y); - } - } - HoverDismissTracker { id: hoverDismissTracker - anchors.fill: parent - enabled: root.hoverDismissEnabled && root.isVisible + parent: root.contentItem + enabled: root.hoverDismissEnabled && !root.hoverDismissSuspended && root.isVisible shouldDismiss: function () { return !PopoutManager.cursorOverBar(PopoutManager.hoverCursorGlobalX, PopoutManager.hoverCursorGlobalY); } onDismissRequested: root.hideFromHoverDismiss() + onHoverMoved: (sceneX, sceneY) => PopoutManager.updateHoverCursor(root.surfaceOriginX + sceneX, sceneY) } readonly property bool slideoutBlurActive: root.visible && BlurService.enabled && Theme.connectedSurfaceBlurEnabled diff --git a/quickshell/Widgets/HoverDismissTracker.qml b/quickshell/Widgets/HoverDismissTracker.qml index bb51d7ca..f4922c90 100644 --- a/quickshell/Widgets/HoverDismissTracker.qml +++ b/quickshell/Widgets/HoverDismissTracker.qml @@ -2,34 +2,27 @@ pragma ComponentBehavior: Bound import QtQuick -Item { +HoverHandler { id: root - property bool enabled: false 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; - if (typeof root.shouldDismiss === "function" && !root.shouldDismiss()) - return; - root.dismissRequested(); - } + onPointChanged: { + if (!enabled || !hovered) + return; + const gp = parent.mapToItem(null, point.position.x, point.position.y); + hoverMoved(gp.x, gp.y); + } + onHoveredChanged: { + if (hovered || !enabled) + return; + if (typeof shouldDismiss === "function" && !shouldDismiss()) + return; + dismissRequested(); } function cancelPending() { diff --git a/quickshell/Widgets/PopoutHoverBodyTracker.qml b/quickshell/Widgets/PopoutHoverBodyTracker.qml new file mode 100644 index 00000000..2e73f769 --- /dev/null +++ b/quickshell/Widgets/PopoutHoverBodyTracker.qml @@ -0,0 +1,25 @@ +pragma ComponentBehavior: Bound + +import QtQuick + +HoverHandler { + id: root + + required property var controller + property bool trackingEnabled: false + + enabled: trackingEnabled + + onTrackingEnabledChanged: { + if (!trackingEnabled) + controller.updateBodyHover(false); + } + + onHoveredChanged: controller.updateBodyHover(hovered) + onPointChanged: { + if (!hovered) + return; + const gp = parent.mapToItem(null, point.position.x, point.position.y); + controller.updateCursor(gp.x, gp.y); + } +} diff --git a/quickshell/Widgets/PopoutHoverDismiss.qml b/quickshell/Widgets/PopoutHoverDismiss.qml new file mode 100644 index 00000000..4bddbc6a --- /dev/null +++ b/quickshell/Widgets/PopoutHoverDismiss.qml @@ -0,0 +1,75 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common + +Item { + id: root + + required property bool dismissEnabled + required property bool dismissSuspended + required property bool surfaceVisible + + property int graceInterval: 150 + property bool bodyHovered: false + property real globalOffsetX: 0 + property real globalOffsetY: 0 + + signal dismissRequested + + function cancelPending() { + graceTimer.stop(); + hoverTracker.cancelPending(); + } + + function updateBodyHover(over) { + bodyHovered = over; + if (over) { + graceTimer.stop(); + } else if (dismissEnabled && !dismissSuspended && surfaceVisible) { + graceTimer.restart(); + } + } + + function updateCursor(sceneX, sceneY) { + PopoutManager.updateHoverCursor(sceneX + globalOffsetX, sceneY + globalOffsetY); + } + + onDismissEnabledChanged: { + if (!dismissEnabled) + cancelPending(); + } + onDismissSuspendedChanged: { + if (dismissSuspended) + graceTimer.stop(); + else if (dismissEnabled && surfaceVisible && !bodyHovered) + graceTimer.restart(); + } + onSurfaceVisibleChanged: { + if (!surfaceVisible) + cancelPending(); + } + + Timer { + id: graceTimer + interval: root.graceInterval + repeat: false + onTriggered: { + if (!root.dismissEnabled || root.dismissSuspended || !root.surfaceVisible || root.bodyHovered) + return; + if (PopoutManager.cursorOverBar(PopoutManager.hoverCursorGlobalX, PopoutManager.hoverCursorGlobalY)) + return; + root.dismissRequested(); + } + } + + HoverDismissTracker { + id: hoverTracker + enabled: root.dismissEnabled && !root.dismissSuspended && root.surfaceVisible + shouldDismiss: function () { + return !PopoutManager.cursorOverBar(PopoutManager.hoverCursorGlobalX, PopoutManager.hoverCursorGlobalY); + } + onDismissRequested: root.dismissRequested() + onHoverMoved: (gx, gy) => root.updateCursor(gx, gy) + } +}