From 57d08f6b3b033eb0c022f3b601d32b2ee68ad7ad Mon Sep 17 00:00:00 2001 From: purian23 Date: Wed, 1 Jul 2026 21:39:47 -0400 Subject: [PATCH] refactor(Xray): Update Xray & standalone to frame transitions - Fix dbar autohide with Xray options that could have blocked bar area content - Fixes #2729 --- quickshell/Common/AnimVariants.qml | 2 +- quickshell/Common/FrameTransitionState.qml | 56 +++++++++++++++ quickshell/Common/SettingsData.qml | 42 ++++++++++- quickshell/Common/Theme.qml | 2 +- quickshell/DMSShell.qml | 40 ++++++++--- quickshell/Modals/Common/DankModal.qml | 2 +- .../Modals/Common/DankModalConnected.qml | 2 +- .../Modals/Common/DankModalStandalone.qml | 2 +- .../DankLauncherV2/DankLauncherV2Modal.qml | 4 +- .../DankLauncherV2ModalConnected.qml | 2 +- .../DankLauncherV2ModalSpotlight.qml | 2 +- .../DankLauncherV2ModalStandalone.qml | 2 +- quickshell/Modules/DankBar/BarCanvas.qml | 2 +- quickshell/Modules/DankBar/DankBarWindow.qml | 37 ++++------ quickshell/Modules/Dock/Dock.qml | 6 +- quickshell/Modules/Frame/Frame.qml | 3 +- quickshell/Modules/Frame/FrameWindow.qml | 2 +- .../Notifications/Center/NotificationCard.qml | 2 +- .../Modules/Settings/CompositorLayoutTab.qml | 8 ++- quickshell/Services/CompositorService.qml | 5 +- quickshell/Services/HyprlandService.qml | 63 +++++++++++++--- quickshell/Services/NiriService.qml | 72 +++++++++++++------ .../translations/settings_search_index.json | 42 +++++++++-- 23 files changed, 313 insertions(+), 87 deletions(-) create mode 100644 quickshell/Common/FrameTransitionState.qml diff --git a/quickshell/Common/AnimVariants.qml b/quickshell/Common/AnimVariants.qml index 676af6d1d..b00672259 100644 --- a/quickshell/Common/AnimVariants.qml +++ b/quickshell/Common/AnimVariants.qml @@ -55,7 +55,7 @@ Singleton { readonly property bool isDirectionalEffect: isConnectedEffect || _effect === 1 readonly property bool isDepthEffect: _effect === 2 - readonly property bool isConnectedEffect: (typeof SettingsData !== "undefined") && SettingsData.connectedFrameModeActive + readonly property bool isConnectedEffect: (typeof FrameTransitionState !== "undefined") && FrameTransitionState.effectiveConnectedFrameModeActive readonly property real effectScaleCollapsed: _effectScaleCollapsed[_effect] !== undefined ? _effectScaleCollapsed[_effect] : 0.96 readonly property real effectAnimOffset: _effectAnimOffsets[_effect] !== undefined ? _effectAnimOffsets[_effect] : 16 diff --git a/quickshell/Common/FrameTransitionState.qml b/quickshell/Common/FrameTransitionState.qml new file mode 100644 index 000000000..78b14ef41 --- /dev/null +++ b/quickshell/Common/FrameTransitionState.qml @@ -0,0 +1,56 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell + +Singleton { + id: root + + property int revision: 0 + property int appliedRevision: 0 + readonly property bool ready: appliedRevision >= revision + + // Latched: surfaces render the last compositor-acknowledged state until the atomic flip on ack + property bool effectiveFrameEnabled: false + property string effectiveFrameMode: "connected" + readonly property bool effectiveConnectedFrameModeActive: effectiveFrameEnabled && effectiveFrameMode === "connected" + + signal transitionRequested(int revision) + + function begin() { + revision++; + transitionRequested(revision); + return revision; + } + + function acknowledge(requestRevision) { + if (requestRevision > appliedRevision) + appliedRevision = requestRevision; + } + + function syncEffective() { + effectiveFrameEnabled = SettingsData.frameEnabled; + effectiveFrameMode = SettingsData.frameMode; + } + + onReadyChanged: { + if (ready) + syncEffective(); + } + + // Tracks settings-load changes; live toggles begin() first (ready false) so the latch holds + Connections { + target: SettingsData + function onFrameEnabledChanged() { + if (root.ready) + root.syncEffective(); + } + function onFrameModeChanged() { + if (root.ready) + root.syncEffective(); + } + } + + Component.onCompleted: syncEffective() +} diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 15042c947..1bd16e320 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -266,7 +266,11 @@ Singleton { } property bool frameEnabled: false - onFrameEnabledChanged: saveSettings() + onFrameEnabledChanged: { + saveSettings(); + if (!_loading) + updateFrameCompositorLayout(); + } property real frameThickness: 16 onFrameThicknessChanged: saveSettings() property int barInsetPaddingShared: -1 @@ -299,7 +303,11 @@ Singleton { onFrameLauncherEdgeHoverChanged: saveSettings() readonly property string frameModalEmergeSide: frameLauncherEmergeSide === "top" ? "bottom" : "top" property string frameMode: "connected" - onFrameModeChanged: saveSettings() + onFrameModeChanged: { + saveSettings(); + if (!_loading && frameEnabled) + updateFrameCompositorLayout(); + } property var connectedFrameBarStyleBackups: ({}) onConnectedFrameBarStyleBackupsChanged: saveSettings() readonly property bool connectedFrameModeActive: frameEnabled && frameMode === "connected" @@ -1012,6 +1020,13 @@ Singleton { } ] + function _standaloneBarXrayAvailable(configs) { + const activeBars = (configs || []).filter(c => c && c.enabled && (c.visible ?? true)); + return activeBars.every(c => !c.autoHide); + } + + readonly property bool standaloneBarXrayAvailable: _standaloneBarXrayAvailable(barConfigs) + property bool desktopClockEnabled: false property string desktopClockStyle: "analog" property real desktopClockTransparency: 0.8 @@ -1456,6 +1471,17 @@ Singleton { MangoService.generateLayoutConfig(); } + function updateFrameCompositorLayout() { + // Generate before begin() so compositor readiness is already pending at transitionRequested + if (typeof CompositorService !== "undefined") { + if (CompositorService.isNiri && typeof NiriService !== "undefined") + NiriService.generateNiriLayoutConfig(true); + if (CompositorService.isHyprland && typeof HyprlandService !== "undefined") + HyprlandService.generateLayoutConfig(true); + } + FrameTransitionState.begin(); + } + function resolveIconTheme() { if (iconThemePerMode && typeof SessionData !== "undefined" && SessionData.isLightMode) return iconThemeLight; @@ -2403,13 +2429,17 @@ Singleton { if (index === -1) return; const positionChanged = updates.position !== undefined && configs[index].position !== updates.position; + const barXrayTargetWasAvailable = _standaloneBarXrayAvailable(configs); if (updates.autoHide === false || updates.visible === false) setBarIpcReveal(barId, false); Object.assign(configs[index], updates); - barConfigs = _sanitizeBarConfigsForConnectedFrame(configs).configs; + const sanitizedConfigs = _sanitizeBarConfigsForConnectedFrame(configs).configs; + barConfigs = sanitizedConfigs; updateBarConfigs(); + if (!frameEnabled && _standaloneBarXrayAvailable(sanitizedConfigs) !== barXrayTargetWasAvailable) + updateCompositorLayout(); if (positionChanged) { NotificationService.dismissAllPopups(); } @@ -3542,6 +3572,9 @@ Singleton { onLoaded: { if (isGreeterMode) return; + const wasLoaded = _hasLoaded; + const prevFrameEnabled = frameEnabled; + const prevFrameMode = frameMode; _loading = true; _hasUnsavedChanges = false; try { @@ -3576,6 +3609,9 @@ Singleton { } finally { _loading = false; } + // External edits reload under _loading, which skips the per-property transition triggers + if (wasLoaded && !_parseError && (frameEnabled !== prevFrameEnabled || (frameEnabled && frameMode !== prevFrameMode))) + updateFrameCompositorLayout(); } onLoadFailed: error => { if (!isGreeterMode) { diff --git a/quickshell/Common/Theme.qml b/quickshell/Common/Theme.qml index c8ecc584e..6c5162dd3 100644 --- a/quickshell/Common/Theme.qml +++ b/quickshell/Common/Theme.qml @@ -1076,7 +1076,7 @@ Singleton { readonly property real connectedCornerRadius: { if (typeof SettingsData === "undefined") return 12; - return SettingsData.connectedFrameModeActive ? SettingsData.frameRounding : cornerRadius; + return FrameTransitionState.effectiveConnectedFrameModeActive ? SettingsData.frameRounding : cornerRadius; } readonly property color connectedSurfaceColor: { if (typeof SettingsData === "undefined") diff --git a/quickshell/DMSShell.qml b/quickshell/DMSShell.qml index 3ae2c6ef4..0aaf8433f 100644 --- a/quickshell/DMSShell.qml +++ b/quickshell/DMSShell.qml @@ -173,6 +173,7 @@ Item { } property bool barSurfacesLoaded: true + property int pendingFrameTransitionRevision: 0 function recreateBarSurfaces() { log.info("Recreating bar surfaces, screens:", Quickshell.screens.length, Quickshell.screens.map(s => s.name).join(",")); @@ -181,9 +182,23 @@ Item { barSurfaceReloadAction.schedule(); } + // Holds the bar rebuild until the compositor applies the layout, so the swap lands in one pass + function runPendingFrameTransition() { + if (pendingFrameTransitionRevision <= 0 || !CompositorService.frameCompositorLayoutReady) + return; + recreateBarSurfaces(); + } + DeferredAction { id: barSurfaceReloadAction - onTriggered: root.barSurfacesLoaded = true + onTriggered: { + // Ack first so the latch flips and new bars build directly in the post-transition state + if (root.pendingFrameTransitionRevision > 0 && CompositorService.frameCompositorLayoutReady) { + FrameTransitionState.acknowledge(root.pendingFrameTransitionRevision); + root.pendingFrameTransitionRevision = 0; + } + root.barSurfacesLoaded = true; + } } property string _barLayoutStateJson: { @@ -212,14 +227,23 @@ Item { } } + Connections { + target: FrameTransitionState + function onTransitionRequested(revision) { + root.pendingFrameTransitionRevision = Math.max(root.pendingFrameTransitionRevision, revision); + root.runPendingFrameTransition(); + } + } + + Connections { + target: CompositorService + function onFrameCompositorLayoutReadyChanged() { + root.runPendingFrameTransition(); + } + } + Connections { target: SettingsData - function onFrameEnabledChanged() { - root.recreateBarSurfaces(); - } - function onConnectedFrameModeActiveChanged() { - root.recreateBarSurfaces(); - } function onForceDankBarLayoutRefresh() { root.recreateBarSurfaces(); } @@ -234,7 +258,7 @@ Item { } Loader { - active: SettingsData.frameEnabled && SettingsData.frameLauncherEdgeHover + active: FrameTransitionState.effectiveFrameEnabled && SettingsData.frameLauncherEdgeHover asynchronous: false sourceComponent: FrameLauncherHoverZone {} } diff --git a/quickshell/Modals/Common/DankModal.qml b/quickshell/Modals/Common/DankModal.qml index 0dec4eae8..f813ed670 100644 --- a/quickshell/Modals/Common/DankModal.qml +++ b/quickshell/Modals/Common/DankModal.qml @@ -93,7 +93,7 @@ Item { impl.item.toggle(); } - readonly property var _desiredBackend: SettingsData.connectedFrameModeActive ? connectedComp : standaloneComp + readonly property var _desiredBackend: FrameTransitionState.effectiveConnectedFrameModeActive ? connectedComp : standaloneComp property var _resolvedBackend: null Component.onCompleted: _resolvedBackend = _desiredBackend diff --git a/quickshell/Modals/Common/DankModalConnected.qml b/quickshell/Modals/Common/DankModalConnected.qml index d6bbb0800..dff99d163 100644 --- a/quickshell/Modals/Common/DankModalConnected.qml +++ b/quickshell/Modals/Common/DankModalConnected.qml @@ -33,7 +33,7 @@ Item { property string preferredConnectedBarSide: SettingsData.frameModalEmergeSide - readonly property bool frameConnectedMode: SettingsData.frameEnabled && Theme.isConnectedEffect && !!effectiveScreen && SettingsData.isScreenInPreferences(effectiveScreen, SettingsData.frameScreenPreferences) + readonly property bool frameConnectedMode: FrameTransitionState.effectiveFrameEnabled && Theme.isConnectedEffect && !!effectiveScreen && SettingsData.isScreenInPreferences(effectiveScreen, SettingsData.frameScreenPreferences) readonly property string resolvedConnectedBarSide: frameConnectedMode ? preferredConnectedBarSide : "" diff --git a/quickshell/Modals/Common/DankModalStandalone.qml b/quickshell/Modals/Common/DankModalStandalone.qml index 154fd9858..7dab56105 100644 --- a/quickshell/Modals/Common/DankModalStandalone.qml +++ b/quickshell/Modals/Common/DankModalStandalone.qml @@ -53,7 +53,7 @@ Item { readonly property alias contentWindow: contentWindow readonly property alias clickCatcher: clickCatcher readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab - readonly property bool useBackground: showBackground && !SettingsData.frameEnabled && SettingsData.modalDarkenBackground + readonly property bool useBackground: showBackground && !FrameTransitionState.effectiveFrameEnabled && SettingsData.modalDarkenBackground readonly property bool useSingleWindow: CompositorService.isHyprland || useBackground signal opened diff --git a/quickshell/Modals/DankLauncherV2/DankLauncherV2Modal.qml b/quickshell/Modals/DankLauncherV2/DankLauncherV2Modal.qml index f64760050..3561690d3 100644 --- a/quickshell/Modals/DankLauncherV2/DankLauncherV2Modal.qml +++ b/quickshell/Modals/DankLauncherV2/DankLauncherV2Modal.qml @@ -63,8 +63,8 @@ Item { impl.item.toggleWithMode(mode); } - readonly property bool useSpotlightBackend: !SettingsData.connectedFrameModeActive && SettingsData.launcherStyle === "spotlight" - readonly property var _desiredBackend: useSpotlightBackend ? spotlightComp : (SettingsData.connectedFrameModeActive ? connectedComp : standaloneComp) + readonly property bool useSpotlightBackend: !FrameTransitionState.effectiveConnectedFrameModeActive && SettingsData.launcherStyle === "spotlight" + readonly property var _desiredBackend: useSpotlightBackend ? spotlightComp : (FrameTransitionState.effectiveConnectedFrameModeActive ? connectedComp : standaloneComp) property var _resolvedBackend: null Component.onCompleted: _resolvedBackend = _desiredBackend diff --git a/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalConnected.qml b/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalConnected.qml index a7a2813d0..f50b7d28b 100644 --- a/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalConnected.qml +++ b/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalConnected.qml @@ -77,7 +77,7 @@ Item { readonly property string preferredConnectedBarSide: SettingsData.frameLauncherEmergeSide - readonly property bool frameConnectedMode: SettingsData.frameEnabled && Theme.isConnectedEffect && !!effectiveScreen && SettingsData.isScreenInPreferences(effectiveScreen, SettingsData.frameScreenPreferences) + readonly property bool frameConnectedMode: FrameTransitionState.effectiveFrameEnabled && Theme.isConnectedEffect && !!effectiveScreen && SettingsData.isScreenInPreferences(effectiveScreen, SettingsData.frameScreenPreferences) readonly property string resolvedConnectedBarSide: frameConnectedMode ? preferredConnectedBarSide : "" diff --git a/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalSpotlight.qml b/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalSpotlight.qml index 7211d521e..288e65409 100644 --- a/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalSpotlight.qml +++ b/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalSpotlight.qml @@ -30,7 +30,7 @@ Item { readonly property real screenWidth: effectiveScreen?.width ?? 1920 readonly property real screenHeight: effectiveScreen?.height ?? 1080 readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1 - readonly property bool useBackgroundDarken: !SettingsData.frameEnabled && SettingsData.modalDarkenBackground + readonly property bool useBackgroundDarken: !FrameTransitionState.effectiveFrameEnabled && SettingsData.modalDarkenBackground readonly property bool usesOverlayLayer: useBackgroundDarken || SettingsData.launcherUseOverlayLayer || triggerUsesOverlayLayer readonly property var effectiveLauncherLayer: LayerShell.fromEnv("DMS_MODAL_LAYER", root.usesOverlayLayer ? WlrLayer.Overlay : WlrLayer.Top, { "allow": ["top", "overlay"], diff --git a/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalStandalone.qml b/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalStandalone.qml index fbb76cbc4..dc54ab420 100644 --- a/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalStandalone.qml +++ b/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalStandalone.qml @@ -79,7 +79,7 @@ Item { readonly property real windowHeight: alignedHeight + contentY + shadowPad readonly property color backgroundColor: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) - readonly property bool useBackgroundDarken: !SettingsData.frameEnabled && SettingsData.modalDarkenBackground + readonly property bool useBackgroundDarken: !FrameTransitionState.effectiveFrameEnabled && SettingsData.modalDarkenBackground readonly property bool useSingleWindow: CompositorService.isHyprland || useBackgroundDarken readonly property bool usesOverlayLayer: useBackgroundDarken || SettingsData.launcherUseOverlayLayer || triggerUsesOverlayLayer readonly property var effectiveLauncherLayer: LayerShell.fromEnv("DMS_MODAL_LAYER", root.usesOverlayLayer ? WlrLayer.Overlay : WlrLayer.Top, { diff --git a/quickshell/Modules/DankBar/BarCanvas.qml b/quickshell/Modules/DankBar/BarCanvas.qml index f82db6c09..cee5a8d4c 100644 --- a/quickshell/Modules/DankBar/BarCanvas.qml +++ b/quickshell/Modules/DankBar/BarCanvas.qml @@ -10,7 +10,7 @@ Item { required property var axis required property var barConfig - readonly property bool frameShapesBar: SettingsData.frameEnabled && barWindow.usesFrameBarChrome + readonly property bool frameShapesBar: FrameTransitionState.effectiveFrameEnabled && barWindow.usesFrameBarChrome visible: !frameShapesBar diff --git a/quickshell/Modules/DankBar/DankBarWindow.qml b/quickshell/Modules/DankBar/DankBarWindow.qml index 957d1aa65..773f7a528 100644 --- a/quickshell/Modules/DankBar/DankBarWindow.qml +++ b/quickshell/Modules/DankBar/DankBarWindow.qml @@ -73,7 +73,6 @@ PanelWindow { let section = "center"; if (clockButtonRef && clockButtonRef.visualContent && dankDashPopoutLoader.item.setTriggerPosition) { - // Calculate barPosition from axis.edge const barPosition = axis?.edge === "left" ? 2 : (axis?.edge === "right" ? 3 : (axis?.edge === "top" ? 0 : 1)); section = clockButtonRef.section || "center"; @@ -148,7 +147,8 @@ PanelWindow { _blurRebuildTimer.restart(); } function onUsesFrameBarChromeChanged() { - _blurRebuildTimer.restart(); + // Rebuild immediately so the bar region never overlaps FrameWindow's during chrome handoff + barBlur.rebuild(); } function onBarRevealedChanged() { _blurRebuildTimer.restart(); @@ -179,17 +179,11 @@ PanelWindow { teardown(); if (!BlurService.enabled || !BlurService.available) return; - // When the bar is hidden (auto-hide, or config not visible) keep the blur - // region empty rather than sliding it off-surface. Some compositors (Hyprland) - // gate blur on a non-empty region and then blur the whole surface box when the - // clip degenerates to empty, leaving the bar strip blurred while the bar is - // hidden (issue #2656). A null region disables the effect cleanly. + // Hidden bar keeps a null region — an empty clip makes Hyprland blur the whole surface box if (!barWindow.barRevealed) return; - // In frame mode, FrameWindow owns the blur region for the entire screen edge - // (including the bar area). The bar must not set its own competing blur region - // so that frameBlurEnabled acts as the single control for all blur in frame mode. - if (SettingsData.frameEnabled && barWindow.usesFrameBarChrome) + // FrameWindow owns the blur region for the whole edge while the bar wears frame chrome + if (FrameTransitionState.effectiveFrameEnabled && barWindow.usesFrameBarChrome) return; const widgets = barWindow._blurWidgetItems.filter(w => w && w.visible && w.width > 0 && w.height > 0); @@ -245,8 +239,8 @@ PanelWindow { } Connections { - target: SettingsData - function onFrameEnabledChanged() { + target: FrameTransitionState + function onEffectiveFrameEnabledChanged() { barBlur.rebuild(); } } @@ -299,7 +293,7 @@ PanelWindow { readonly property color _surfaceContainer: Theme.surfaceContainer readonly property string _barId: barConfig?.id ?? "default" property real _backgroundAlpha: barConfig?.transparency ?? 1.0 - readonly property color _bgColor: (SettingsData.frameEnabled && usesFrameBarChrome) ? Theme.withAlpha(SettingsData.effectiveFrameColor, SettingsData.frameOpacity) : Theme.withAlpha(_surfaceContainer, _backgroundAlpha) + readonly property color _bgColor: (FrameTransitionState.effectiveFrameEnabled && usesFrameBarChrome) ? Theme.withAlpha(SettingsData.effectiveFrameColor, SettingsData.frameOpacity) : Theme.withAlpha(_surfaceContainer, _backgroundAlpha) function _updateBackgroundAlpha() { const live = SettingsData.barConfigs.find(c => c.id === _barId); @@ -331,9 +325,8 @@ PanelWindow { return Theme.snap(Theme.elevationRenderPadding(Theme.elevationLevel2, "top", 4, 8, 16), _dpr); } - // Flatten/spacing collapse for maximized windows is only for frame-integrated layout. - // When the bar draws its own pill, keep rounded corners and spacing like the dock. - readonly property bool flattenForMaximizedWindow: !SettingsData.frameEnabled || usesFrameBarChrome + // Flatten/spacing collapse for maximized windows only applies to frame-integrated layout + readonly property bool flattenForMaximizedWindow: !FrameTransitionState.effectiveFrameEnabled || usesFrameBarChrome property bool hasMaximizedToplevel: false property bool shouldHideForWindows: false @@ -455,7 +448,7 @@ PanelWindow { shouldHideForWindows = filtered.length > 0; } - property real effectiveSpacing: (SettingsData.frameEnabled && usesFrameBarChrome) ? 0 : ((flattenForMaximizedWindow && hasMaximizedToplevel) ? 0 : (barConfig?.spacing ?? 4)) + property real effectiveSpacing: (FrameTransitionState.effectiveFrameEnabled && usesFrameBarChrome) ? 0 : ((flattenForMaximizedWindow && hasMaximizedToplevel) ? 0 : (barConfig?.spacing ?? 4)) Behavior on effectiveSpacing { enabled: barWindow.visible @@ -466,8 +459,8 @@ PanelWindow { } readonly property int notificationCount: NotificationService.notifications.length - readonly property real effectiveBarThickness: (SettingsData.frameEnabled && usesFrameBarChrome) ? SettingsData.frameBarSize : Theme.snap(Math.max(barWindow.widgetThickness + (barConfig?.innerPadding ?? 4) + 4, Theme.barHeight - 4 - (8 - (barConfig?.innerPadding ?? 4))), _dpr) - readonly property bool effectiveOpenOnOverview: SettingsData.frameEnabled ? SettingsData.frameShowOnOverview : (barConfig?.openOnOverview ?? false) + readonly property real effectiveBarThickness: (FrameTransitionState.effectiveFrameEnabled && usesFrameBarChrome) ? SettingsData.frameBarSize : Theme.snap(Math.max(barWindow.widgetThickness + (barConfig?.innerPadding ?? 4) + 4, Theme.barHeight - 4 - (8 - (barConfig?.innerPadding ?? 4))), _dpr) + readonly property bool effectiveOpenOnOverview: FrameTransitionState.effectiveFrameEnabled ? SettingsData.frameShowOnOverview : (barConfig?.openOnOverview ?? false) readonly property real widgetThickness: Theme.snap(Math.max(20, 26 + (barConfig?.innerPadding ?? 4) * 0.6), _dpr) readonly property bool hasAdjacentTopBar: { @@ -665,7 +658,7 @@ PanelWindow { anchors.left: !isVertical ? true : (barPos === SettingsData.Position.Left) anchors.right: !isVertical ? true : (barPos === SettingsData.Position.Right) - readonly property bool reserveExclusiveWhenAutoHidden: SettingsData.frameEnabled && usesFrameBarChrome && !!barWindow.screen && SettingsData.isScreenInPreferences(barWindow.screen, SettingsData.frameScreenPreferences) + readonly property bool reserveExclusiveWhenAutoHidden: FrameTransitionState.effectiveFrameEnabled && usesFrameBarChrome && !!barWindow.screen && SettingsData.isScreenInPreferences(barWindow.screen, SettingsData.frameScreenPreferences) exclusiveZone: (!(barConfig?.visible ?? true) || (topBarCore.autoHide && !barWindow.reserveExclusiveWhenAutoHidden)) ? -1 : (barWindow.effectiveBarThickness + effectiveSpacing + (usesFrameBarChrome ? 0 : (barConfig?.bottomGap ?? 0))) @@ -1111,7 +1104,7 @@ PanelWindow { rightWidgetsModel: barWindow.rightWidgetsModel } - // Passive HoverHandler to track cursor without intercepting clicks or scroll events. + // Passive: tracks cursor without intercepting clicks or scroll HoverHandler { id: hoverPopoutHandler enabled: (barConfig?.hoverPopouts ?? false) && !barWindow.clickThroughEnabled diff --git a/quickshell/Modules/Dock/Dock.qml b/quickshell/Modules/Dock/Dock.qml index ab5a04dc9..a0e35be3a 100644 --- a/quickshell/Modules/Dock/Dock.qml +++ b/quickshell/Modules/Dock/Dock.qml @@ -717,7 +717,7 @@ Variants { Rectangle { anchors.fill: parent - visible: !usesConnectedFrameChrome && (!SettingsData.connectedFrameModeActive || dock.reveal) + visible: !usesConnectedFrameChrome && (!FrameTransitionState.effectiveConnectedFrameModeActive || dock.reveal) color: dock.surfaceColor topLeftRadius: dock.surfaceTopLeftRadius topRightRadius: dock.surfaceTopRightRadius @@ -727,7 +727,7 @@ Variants { Rectangle { anchors.fill: parent - visible: !usesConnectedFrameChrome && (!SettingsData.connectedFrameModeActive || dock.reveal) + visible: !usesConnectedFrameChrome && (!FrameTransitionState.effectiveConnectedFrameModeActive || dock.reveal) color: "transparent" topLeftRadius: dock.surfaceTopLeftRadius topRightRadius: dock.surfaceTopRightRadius @@ -747,7 +747,7 @@ Variants { Item { id: dockConnectedChrome - visible: Theme.isConnectedEffect && dock.reveal && !SettingsData.connectedFrameModeActive + visible: Theme.isConnectedEffect && dock.reveal && !FrameTransitionState.effectiveConnectedFrameModeActive readonly property real extraLeft: dock.isVertical ? 0 : Theme.connectedCornerRadius readonly property real extraTop: dock.isVertical ? Theme.connectedCornerRadius : 0 readonly property real bodyRadius: dock.surfaceRadius diff --git a/quickshell/Modules/Frame/Frame.qml b/quickshell/Modules/Frame/Frame.qml index c973ddc79..c42078576 100644 --- a/quickshell/Modules/Frame/Frame.qml +++ b/quickshell/Modules/Frame/Frame.qml @@ -14,7 +14,8 @@ Variants { required property var modelData - active: SettingsData.frameEnabled && SettingsData.isScreenInPreferences(instanceLoader.modelData, SettingsData.frameScreenPreferences) + // Live-or-latched: instances build early on enable, survive until ack on disable + active: (SettingsData.frameEnabled || FrameTransitionState.effectiveFrameEnabled) && SettingsData.isScreenInPreferences(instanceLoader.modelData, SettingsData.frameScreenPreferences) asynchronous: false sourceComponent: FrameInstance { diff --git a/quickshell/Modules/Frame/FrameWindow.qml b/quickshell/Modules/Frame/FrameWindow.qml index 880a266c9..d8a5fa7df 100644 --- a/quickshell/Modules/Frame/FrameWindow.qml +++ b/quickshell/Modules/Frame/FrameWindow.qml @@ -40,7 +40,7 @@ PanelWindow { } readonly property real _dpr: CompositorService.getScreenScale(win.targetScreen) - readonly property bool _frameActive: SettingsData.frameEnabled && SettingsData.isScreenInPreferences(win.targetScreen, SettingsData.frameScreenPreferences) + readonly property bool _frameActive: FrameTransitionState.effectiveFrameEnabled && SettingsData.isScreenInPreferences(win.targetScreen, SettingsData.frameScreenPreferences) readonly property int _windowRegionWidth: win._regionInt(win.width) readonly property int _windowRegionHeight: win._regionInt(win.height) readonly property string _screenName: win.targetScreen ? win.targetScreen.name : "" diff --git a/quickshell/Modules/Notifications/Center/NotificationCard.qml b/quickshell/Modules/Notifications/Center/NotificationCard.qml index 6c8d40803..50584cf91 100644 --- a/quickshell/Modules/Notifications/Center/NotificationCard.qml +++ b/quickshell/Modules/Notifications/Center/NotificationCard.qml @@ -40,7 +40,7 @@ Rectangle { readonly property real actionButtonHeight: compactMode ? 20 : 24 readonly property real collapsedContentHeight: Math.max(iconSize, Theme.fontSizeSmall * 1.2 + Theme.fontSizeMedium * 1.2 + Theme.fontSizeSmall * 1.2 * (compactMode ? 1 : 2)) readonly property real baseCardHeight: cardPadding * 2 + collapsedContentHeight + actionButtonHeight + contentSpacing - readonly property bool connectedFrameMode: SettingsData.connectedFrameModeActive + readonly property bool connectedFrameMode: FrameTransitionState.effectiveConnectedFrameModeActive width: parent ? parent.width : 400 height: expanded ? (expandedContent.height + cardPadding * 2) : (baseCardHeight + collapsedContent.extraHeight) diff --git a/quickshell/Modules/Settings/CompositorLayoutTab.qml b/quickshell/Modules/Settings/CompositorLayoutTab.qml index 0dda6372d..7c6b490ad 100644 --- a/quickshell/Modules/Settings/CompositorLayoutTab.qml +++ b/quickshell/Modules/Settings/CompositorLayoutTab.qml @@ -323,10 +323,12 @@ Item { SettingsToggleRow { // Hidden in Frame Connected mode, where it has no target visible: !(SettingsData.frameEnabled && SettingsData.frameMode === "connected") + enabled: SettingsData.frameEnabled || SettingsData.standaloneBarXrayAvailable + opacity: enabled ? 1 : 0.5 tags: ["niri", "xray", "bar", "frame", "performance"] settingKey: "niriLayoutBarXrayEnabled" text: SettingsData.frameEnabled ? I18n.tr("Frame Xray") : I18n.tr("Dank Bar Xray") - description: SettingsData.niriLayoutBarXrayEnabled ? (SettingsData.frameEnabled ? I18n.tr("Xray on makes the Frame's border visible through to the wallpaper; more efficient") : I18n.tr("Xray on makes the Dank Bar visible through to the wallpaper; more efficient")) : (SettingsData.frameEnabled ? I18n.tr("Xray off shows the real background beneath the Frame's border") : I18n.tr("Xray off shows the real background beneath the Dank Bar")) + description: !SettingsData.frameEnabled && !SettingsData.standaloneBarXrayAvailable ? I18n.tr("Unavailable while using Auto Hide as Xray would reveal the wallpaper through windows") : (SettingsData.niriLayoutBarXrayEnabled ? (SettingsData.frameEnabled ? I18n.tr("Xray on makes the Frame's border visible through to the wallpaper; more efficient") : I18n.tr("Xray on makes the Dank Bar visible through to the wallpaper; more efficient")) : (SettingsData.frameEnabled ? I18n.tr("Xray off shows the real background beneath the Frame's border") : I18n.tr("Xray off shows the real background beneath the Dank Bar"))) checked: SettingsData.niriLayoutBarXrayEnabled onToggled: checked => SettingsData.set("niriLayoutBarXrayEnabled", checked) } @@ -449,10 +451,12 @@ Item { SettingsToggleRow { // Hidden in Frame Connected mode, where it has no target visible: !(SettingsData.frameEnabled && SettingsData.frameMode === "connected") + enabled: SettingsData.frameEnabled || SettingsData.standaloneBarXrayAvailable + opacity: enabled ? 1 : 0.5 tags: ["hyprland", "xray", "bar", "frame", "performance"] settingKey: "hyprlandLayoutBarXrayEnabled" text: SettingsData.frameEnabled ? I18n.tr("Frame Xray") : I18n.tr("Dank Bar Xray") - description: SettingsData.hyprlandLayoutBarXrayEnabled ? (SettingsData.frameEnabled ? I18n.tr("Xray on makes the Frame's border visible through to the wallpaper; more efficient") : I18n.tr("Xray on makes the Dank Bar visible through to the wallpaper; more efficient")) : (SettingsData.frameEnabled ? I18n.tr("Xray off shows the real background beneath the Frame's border") : I18n.tr("Xray off shows the real background beneath the Dank Bar")) + description: !SettingsData.frameEnabled && !SettingsData.standaloneBarXrayAvailable ? I18n.tr("Unavailable while an enabled Dank Bar uses Auto Hide because Xray would reveal the wallpaper through windows") : (SettingsData.hyprlandLayoutBarXrayEnabled ? (SettingsData.frameEnabled ? I18n.tr("Xray on makes the Frame's border visible through to the wallpaper; more efficient") : I18n.tr("Xray on makes the Dank Bar visible through to the wallpaper; more efficient")) : (SettingsData.frameEnabled ? I18n.tr("Xray off shows the real background beneath the Frame's border") : I18n.tr("Xray off shows the real background beneath the Dank Bar"))) checked: SettingsData.hyprlandLayoutBarXrayEnabled onToggled: checked => SettingsData.set("hyprlandLayoutBarXrayEnabled", checked) } diff --git a/quickshell/Services/CompositorService.qml b/quickshell/Services/CompositorService.qml index c673a50b5..7940b2d83 100644 --- a/quickshell/Services/CompositorService.qml +++ b/quickshell/Services/CompositorService.qml @@ -22,6 +22,7 @@ Singleton { property bool isLabwc: false property string compositor: "unknown" property bool compositorDetected: false + readonly property bool frameCompositorLayoutReady: (!isNiri || NiriService.frameLayoutReady) && (!isHyprland || HyprlandService.frameLayoutReady) readonly property bool useHyprlandFocusGrab: isHyprland && Quickshell.env("DMS_HYPRLAND_EXCLUSIVE_FOCUS") !== "1" readonly property string hyprlandSignature: Quickshell.env("HYPRLAND_INSTANCE_SIGNATURE") @@ -612,7 +613,7 @@ Singleton { } function frameConfiguredForScreen(screenOrName) { - if (!SettingsData.frameEnabled) + if (!FrameTransitionState.effectiveFrameEnabled) return false; const screen = _screenForName(screenOrName); if (!screen || !SettingsData.isScreenInPreferences(screen, SettingsData.frameScreenPreferences)) @@ -627,7 +628,7 @@ Singleton { } function usesConnectedFrameChromeForScreen(screenOrName) { - return SettingsData.connectedFrameModeActive && frameWindowVisibleForScreen(screenOrName); + return FrameTransitionState.effectiveConnectedFrameModeActive && frameWindowVisibleForScreen(screenOrName); } function framePeerSurfacesUseOverlayForScreen(screenOrName) { diff --git a/quickshell/Services/HyprlandService.qml b/quickshell/Services/HyprlandService.qml index e0fb5cfa1..866c456db 100644 --- a/quickshell/Services/HyprlandService.qml +++ b/quickshell/Services/HyprlandService.qml @@ -25,6 +25,22 @@ Singleton { property bool luaConfigStatusReady: false property bool luaConfigStatusLoading: false property string luaConfigFormat: "" + property bool layoutGenerationPending: false + property bool layoutGenerationRunning: false + property int _layoutRequestRevision: 0 + property int _layoutAppliedRevision: 0 + property int _frameTransitionRevision: 0 + readonly property bool frameLayoutReady: _layoutAppliedRevision >= _frameTransitionRevision + + DeferredAction { + id: layoutGenerationAction + onTriggered: root.doGenerateLayoutConfig() + } + + onLuaConfigStatusLoadingChanged: { + if (!luaConfigStatusLoading && layoutGenerationPending) + layoutGenerationAction.schedule(); + } onLuaConfigActiveChanged: { if (luaConfigActive) @@ -237,18 +253,39 @@ Singleton { }); } - function reloadConfig() { + function reloadConfig(callback) { Proc.runCommand("hyprctl-reload", ["hyprctl", "reload"], (output, exitCode) => { if (exitCode !== 0) log.warn("hyprctl reload failed:", output); + if (callback) + callback(exitCode === 0); }); } - function generateLayoutConfig() { + function generateLayoutConfig(frameTransition) { if (!CompositorService.isHyprland) return; - if (!canWriteLuaConfig("layout")) + _layoutRequestRevision++; + if (frameTransition === true) + _frameTransitionRevision = _layoutRequestRevision; + layoutGenerationPending = true; + layoutGenerationAction.schedule(); + } + + function doGenerateLayoutConfig() { + if (layoutGenerationRunning) return; + layoutGenerationPending = false; + const requestRevision = _layoutRequestRevision; + if (!canWriteLuaConfig("layout")) { + if (luaConfigStatusLoading || !luaConfigStatusReady) { + layoutGenerationPending = true; + return; + } + _layoutAppliedRevision = Math.max(_layoutAppliedRevision, requestRevision); + return; + } + layoutGenerationRunning = true; const defaultRadius = typeof SettingsData !== "undefined" ? SettingsData.cornerRadius : 12; const defaultGaps = typeof SettingsData !== "undefined" ? Math.max(4, (SettingsData.barConfigs[0]?.spacing ?? 4)) : 4; @@ -262,10 +299,9 @@ Singleton { const barFrameXrayEnabled = typeof SettingsData === "undefined" || SettingsData.hyprlandLayoutBarXrayEnabled; const frameEnabled = typeof SettingsData !== "undefined" && SettingsData.frameEnabled; const frameConnectedMode = frameEnabled && SettingsData.frameMode === "connected"; - - // Hyprland's `xray = false` is still in early development, so unlike niri we never emit it (unset already - // samples real content) - we only force xray=true - const barFrameTargetNamespace = !frameEnabled ? "dms:bar" : (frameConnectedMode ? null : "dms:frame"); + // Hyprland `xray = false` is still early-development; unset already samples real content, so only force xray=true + // Standalone Bar Xray is safe only while every active bar reserves its strip + const barFrameTargetNamespace = !frameEnabled ? (SettingsData.standaloneBarXrayAvailable ? "dms:bar" : null) : (frameConnectedMode ? null : "dms:frame"); let content = `-- Auto-generated by DMS — do not edit manually @@ -302,10 +338,21 @@ hl.layer_rule({ Proc.runCommand("hypr-write-layout", ["sh", "-c", `mkdir -p "${hyprDmsDir}" && cat > "${layoutPath}" << 'EOF'\n${content}EOF`], (output, exitCode) => { if (exitCode !== 0) { log.warn("Failed to write layout config:", output); + // Best-effort ack so a failed write can't wedge frame transitions + _layoutAppliedRevision = Math.max(_layoutAppliedRevision, requestRevision); + layoutGenerationRunning = false; + if (layoutGenerationPending) + layoutGenerationAction.schedule(); return; } log.info("Generated layout config at", layoutPath); - reloadConfig(); + reloadConfig(success => { + // Advance even on failure — proceed degraded rather than wedge the transition + _layoutAppliedRevision = Math.max(_layoutAppliedRevision, requestRevision); + layoutGenerationRunning = false; + if (layoutGenerationPending) + layoutGenerationAction.schedule(); + }); }); } diff --git a/quickshell/Services/NiriService.qml b/quickshell/Services/NiriService.qml index 7d1f41957..737501132 100644 --- a/quickshell/Services/NiriService.qml +++ b/quickshell/Services/NiriService.qml @@ -42,6 +42,12 @@ Singleton { property bool suppressNextConfigToast: false property bool matugenSuppression: false property bool configGenerationPending: false + property int _layoutRequestRevision: 0 + property int _layoutAppliedRevision: 0 + property int _frameTransitionRevision: 0 + property int _awaitingLayoutReloadRevision: 0 + property string _lastGeneratedAlttabContent: "" + readonly property bool frameLayoutReady: _layoutAppliedRevision >= _frameTransitionRevision readonly property string screenshotsDir: Paths.strip(StandardPaths.writableLocation(StandardPaths.PicturesLocation)) + "/Screenshots" property string pendingScreenshotPath: "" @@ -72,9 +78,8 @@ Singleton { onTriggered: root.matugenSuppression = false } - Timer { - id: configGenerationDebounce - interval: 100 + DeferredAction { + id: configGenerationAction onTriggered: root.doGenerateNiriLayoutConfig() } @@ -117,13 +122,20 @@ Singleton { id: writeConfigProcess property string configContent: "" property string configPath: "" + property int requestRevision: 0 onExited: exitCode => { if (exitCode === 0) { log.info("Generated layout config at", configPath); - return; + } else { + if (root._awaitingLayoutReloadRevision === requestRevision) + root._awaitingLayoutReloadRevision = 0; + // Best-effort ack so a failed write can't wedge frame transitions + root._layoutAppliedRevision = Math.max(root._layoutAppliedRevision, requestRevision); + log.warn("Failed to write layout config, exit code:", exitCode); } - log.warn("Failed to write layout config, exit code:", exitCode); + if (root.configGenerationPending && root._awaitingLayoutReloadRevision <= root._layoutAppliedRevision) + configGenerationAction.schedule(); } } @@ -559,12 +571,25 @@ Singleton { function handleConfigLoaded(data) { if (data.failed) { validateProcess.running = true; + // Best-effort ack: a rejected reload must not wedge frame transitions + if (_awaitingLayoutReloadRevision > _layoutAppliedRevision) { + _layoutAppliedRevision = _awaitingLayoutReloadRevision; + _awaitingLayoutReloadRevision = 0; + } + if (configGenerationPending) + configGenerationAction.schedule(); return; } configValidationOutput = ""; ToastService.dismissCategory("niri-config"); fetchOutputs(); + if (_awaitingLayoutReloadRevision > _layoutAppliedRevision) { + _layoutAppliedRevision = _awaitingLayoutReloadRevision; + _awaitingLayoutReloadRevision = 0; + } + if (configGenerationPending) + configGenerationAction.schedule(); configReloaded(); if (hasInitialConnection && !suppressConfigToast && !suppressNextConfigToast && !matugenSuppression) { @@ -1069,15 +1094,21 @@ Singleton { return _matchAndEnrichToplevels(toplevels, windows.filter(nw => outputWorkspaceIds.has(nw.workspace_id))); } - function generateNiriLayoutConfig() { - if (!CompositorService.isNiri || configGenerationPending) + function generateNiriLayoutConfig(frameTransition) { + if (!CompositorService.isNiri) return; + _layoutRequestRevision++; + if (frameTransition === true) + _frameTransitionRevision = _layoutRequestRevision; suppressNextToast(); configGenerationPending = true; - configGenerationDebounce.restart(); + configGenerationAction.schedule(); } function doGenerateNiriLayoutConfig() { + if (writeConfigProcess.running || _awaitingLayoutReloadRevision > _layoutAppliedRevision) + return; + configGenerationPending = false; log.debug("Generating layout config..."); const defaultRadius = typeof SettingsData !== "undefined" ? SettingsData.cornerRadius : 12; @@ -1091,19 +1122,14 @@ Singleton { const barFrameXrayEnabled = typeof SettingsData === "undefined" || SettingsData.niriLayoutBarXrayEnabled; const frameEnabled = typeof SettingsData !== "undefined" && SettingsData.frameEnabled; const frameConnectedMode = frameEnabled && SettingsData.frameMode === "connected"; - - // The one wallpaper-flush surface to force xray=true on: bar (Frame off) or Frame's Separate-mode border - const barFrameTargetNamespace = !frameEnabled ? "dms:bar" : (frameConnectedMode ? null : "dms:frame"); + // Standalone Bar Xray is safe only while every active bar reserves its strip + const barFrameTargetNamespace = !frameEnabled ? (SettingsData.standaloneBarXrayAvailable ? "dms:bar" : null) : (frameConnectedMode ? null : "dms:frame"); let xrayRules = ""; if (!xrayEnabled) { - let excludes = ""; - if (frameEnabled) - excludes += ` - exclude namespace="^dms:bar$"`; xrayRules += ` layer-rule { - match namespace=".*"` + excludes + ` + match namespace=".*" background-effect { xray false } @@ -1157,13 +1183,18 @@ Singleton { writeConfigProcess.configContent = configContent; writeConfigProcess.configPath = configPath; + writeConfigProcess.requestRevision = _layoutRequestRevision; writeConfigProcess.command = ["sh", "-c", `mkdir -p "${niriDmsDir}" && cat > "${configPath}" << 'EOF'\n${configContent}\nEOF`]; + _awaitingLayoutReloadRevision = Math.max(_awaitingLayoutReloadRevision, _layoutRequestRevision); writeConfigProcess.running = true; - writeAlttabProcess.alttabContent = alttabContent; - writeAlttabProcess.alttabPath = alttabPath; - writeAlttabProcess.command = ["sh", "-c", `mkdir -p "${niriDmsDir}" && cat > "${alttabPath}" << 'EOF'\n${alttabContent}\nEOF`]; - writeAlttabProcess.running = true; + if (_lastGeneratedAlttabContent !== alttabContent) { + _lastGeneratedAlttabContent = alttabContent; + writeAlttabProcess.alttabContent = alttabContent; + writeAlttabProcess.alttabPath = alttabPath; + writeAlttabProcess.command = ["sh", "-c", `mkdir -p "${niriDmsDir}" && cat > "${alttabPath}" << 'EOF'\n${alttabContent}\nEOF`]; + writeAlttabProcess.running = true; + } for (const name of ["outputs", "binds", "cursor", "windowrules", "colors", "alttab", "layout"]) { const path = niriDmsDir + "/" + name + ".kdl"; @@ -1173,7 +1204,6 @@ Singleton { }); } - configGenerationPending = false; } function generateNiriBlurrule() { diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index c20877882..b9991b8b7 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -8649,18 +8649,39 @@ "category": "Personalization", "keywords": [ "appearance", + "auto", + "background", "bar", + "because", + "bg", "custom", "customize", + "dank", + "desktop", + "enabled", "frame", + "hide", "hyprland", - "makes", + "image", + "panel", "performance", "personal", "personalization", + "picture", + "reveal", + "statusbar", + "taskbar", + "through", + "topbar", + "unavailable", + "uses", + "wallpaper", + "while", + "windows", + "would", "xray" ], - "description": "Xray on makes the Frame" + "description": "Unavailable while an enabled Dank Bar uses Auto Hide because Xray would reveal the wallpaper through windows" }, { "section": "niriLayoutBarXrayEnabled", @@ -8669,18 +8690,31 @@ "category": "Personalization", "keywords": [ "appearance", + "auto", + "background", "bar", + "bg", "custom", "customize", + "desktop", "frame", - "makes", + "hide", + "image", "niri", "performance", "personal", "personalization", + "picture", + "reveal", + "through", + "unavailable", + "wallpaper", + "while", + "windows", + "would", "xray" ], - "description": "Xray on makes the Frame" + "description": "Unavailable while using Auto Hide as Xray would reveal the wallpaper through windows" }, { "section": "hyprlandLayout",