From c69aa4fea29f6f941a839a5b37e82e8215af65dd Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Sat, 29 Aug 2026 03:44:40 +0200 Subject: [PATCH] fix(quickshell): stop the bar twitching when a collapse finishes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two causes, both outside the panel animation itself. bodyBottom and bodyEndPadding are animated reals, so their sum spends the tail of every transition on a fraction. A layout rounds that up, then drops a pixel the moment the animation lands on its exact value — a 1px hop after the motion has visibly finished. implicitHeight and targetHeight now round. The rest was the surface. Hyprland animates layer-surface resizes (animations enabled, `layers` left at its default), and the deferred shrink put that resize exactly where the panel motion ended. The bar is now sized once to the expanded rail via the new BarPanel.expandedHeight and never resizes; only exclusiveZone tracks the state, so the desktop still reflows once per toggle, at the start. That retires barHeight, the content-height handler and the 340ms shrink timer. A surface that stays tall would swallow clicks across the screen while the rail is collapsed, so input is masked to the panel row. Instrumenting BarPanel per frame ruled the panels themselves out first: slug and summary hold the same absolute y through an entire collapse. The bar and scrim also get their own layer namespaces. Nothing depends on them yet; they are the handle for a layerrule that would exempt the rail from compositor animations without catching the launchers, which share the default "quickshell" namespace and do want their fade. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HEtXvseVb5gwAtKNhFx2PU --- .../hyprchrome/widgets/ChromeBackdrop.qml | 1 + .../hyprchrome/widgets/HyprChromeBar.qml | 45 +++++++------------ .../hyprchrome/widgets/panels/BarPanel.qml | 26 +++++++---- 3 files changed, 35 insertions(+), 37 deletions(-) diff --git a/dotfiles/quickshell/hyprchrome/widgets/ChromeBackdrop.qml b/dotfiles/quickshell/hyprchrome/widgets/ChromeBackdrop.qml index 4de1703..da5857a 100644 --- a/dotfiles/quickshell/hyprchrome/widgets/ChromeBackdrop.qml +++ b/dotfiles/quickshell/hyprchrome/widgets/ChromeBackdrop.qml @@ -47,6 +47,7 @@ PanelWindow { visible: scrim.opacity > 0 + WlrLayershell.namespace: "hyprchrome-scrim" WlrLayershell.layer: WlrLayer.Top WlrLayershell.keyboardFocus: WlrKeyboardFocus.None exclusionMode: ExclusionMode.Ignore diff --git a/dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml b/dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml index 1177859..341a28f 100644 --- a/dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml +++ b/dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml @@ -62,44 +62,33 @@ Scope { // One layer above the scrim, so the stacking is guaranteed rather than // dependent on surface creation order. See ChromeBackdrop. + // Own namespace so a layerrule can exempt the rail from Hyprland's layer + // animation without also catching the launchers, which share the default + // "quickshell" namespace and do want their fade. + WlrLayershell.namespace: "hyprchrome-bar" WlrLayershell.layer: WlrLayer.Overlay property int margin: 12 - // Where the panels will SETTLE, not where they are mid-transition. Binding - // the surface to the animated height instead resizes the layer surface — - // and, with the automatic exclusive zone, relayouts every tiled window on - // this output — on every frame of the animation. + // The surface never resizes: it is always tall enough for the expanded + // rail, and only the exclusive zone tracks the current state. Resizing a + // layer surface makes Hyprland animate the change, which showed up as the + // panels twitching a pixel or two the moment the collapse finished. + // + // The zone still follows the target height, so tiled windows reflow once + // per toggle, at the start, and slide while the panels animate. + readonly property real expandedContent: Math.max(hostPanel.expandedHeight, vitalsPanel.expandedHeight, trayPanel.expandedHeight) + window.margin * 2 readonly property real contentHeight: Math.max(hostPanel.targetHeight, vitalsPanel.targetHeight, trayPanel.targetHeight) + window.margin * 2 - // The surface and the exclusive zone move on different clocks. The zone is - // the desktop-visible half: set it to the target immediately, so the tiled - // windows reflow ONCE, at the start, and slide while the bar animates. - // The surface itself grows before the panels do but shrinks only after they - // have finished, because a surface that shrank immediately would clip the - // panels still animating inside it. - property real barHeight: 0 + implicitHeight: Math.round(window.expandedContent) exclusionMode: ExclusionMode.Normal exclusiveZone: Math.round(window.contentHeight) - implicitHeight: window.barHeight - - onContentHeightChanged: { - if (window.contentHeight > window.barHeight) - window.barHeight = window.contentHeight; - else - shrink.restart(); - } - - Component.onCompleted: window.barHeight = window.contentHeight - - Timer { - id: shrink - - // Longer than the panel's own collapse (200ms body + 110ms fade-in). - interval: 340 - onTriggered: window.barHeight = window.contentHeight + // Only the panels take input. Without this the surface would keep eating + // clicks across its full height while the rail is collapsed. + mask: Region { + item: panelRow } anchors { top: true; left: true; right: true; } diff --git a/dotfiles/quickshell/hyprchrome/widgets/panels/BarPanel.qml b/dotfiles/quickshell/hyprchrome/widgets/panels/BarPanel.qml index 422462f..bcedb43 100644 --- a/dotfiles/quickshell/hyprchrome/widgets/panels/BarPanel.qml +++ b/dotfiles/quickshell/hyprchrome/widgets/panels/BarPanel.qml @@ -93,16 +93,24 @@ Item { property real bodyBottom: detail.y + detail.height property real bodyEndPadding: panel.padding - implicitHeight: Math.max(panel.minimumHeight, panel.bodyBottom + panel.bodyEndPadding) + // Rounded: bodyBottom and bodyEndPadding are animated reals, so the sum + // spends the tail of every transition on a fraction. A layout rounds that + // UP, then drops a pixel the moment the animation lands on its exact + // value — a 1px hop after the motion has visibly finished. + implicitHeight: Math.round(Math.max(panel.minimumHeight, panel.bodyBottom + panel.bodyEndPadding)) - // Where the panel will settle in its current state, skipping the values - // the transition passes through. A window sized to this reconfigures once - // per toggle rather than once per animation frame — which, for a - // layer-shell bar with an automatic exclusive zone, is the difference - // between one relayout of the desktop and a dozen. - readonly property real targetHeight: Math.max(panel.minimumHeight, panel.expanded - ? detail.y + detail.height + panel.padding - : panel.collapsedHeight) + // Where the panel settles in each state, skipping the values the transition + // passes through: a host sizes its surface and its exclusive zone from these + // rather than from the animated height, so the desktop is relaid out once per + // toggle instead of once per animation frame. + // Height of the expanded body regardless of the current state — what a + // host needs to size a surface that must not resize when panels collapse. + readonly property real expandedHeight: Math.round(Math.max(panel.minimumHeight, + detail.y + detail.height + panel.padding)) + + readonly property real targetHeight: panel.expanded + ? panel.expandedHeight + : Math.round(Math.max(panel.minimumHeight, panel.collapsedHeight)) // The two chamfer cuts (top-right at y=chamfer, bottom-left at