Files
homelab/dotfiles/quickshell/hyprchrome/widgets/HyprChromeBar.qml
T
darmanandClaude Opus 5 c69aa4fea2 fix(quickshell): stop the bar twitching when a collapse finishes
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HEtXvseVb5gwAtKNhFx2PU
2026-08-29 03:44:40 +02:00

137 lines
4.2 KiB
QML

import QtQuick
import QtQuick.Shapes
import QtQuick.Layouts
import Quickshell
import Quickshell.Hyprland
import Quickshell.Wayland
import qs.hyprchrome.widgets
import qs.hyprchrome.widgets.host
import qs.hyprchrome.widgets.vitals
import qs.hyprchrome.widgets.tray
Scope {
id: root
// Monitor the rail lives on. Falls back to the FIRST connected screen when
// the name matches nothing, so the bar still appears on a single-monitor
// session or after a cable swap (DebugWindow falls back to the last one
// instead — it wants the secondary).
property string screenName: "DP-2"
// Quickshell.screens is a QML list, not a JS array — no .find() on it.
readonly property var targetScreen: {
const screens = Quickshell.screens;
if (screens.length === 0)
return null;
for (let i = 0; i < screens.length; i++) {
if (screens[i].name === root.screenName)
return screens[i];
}
return screens[0];
}
// Density is a property of the BAR: every panel follows it, so the whole rail
// expands and collapses as one. Panels keep their own animation; only the
// decision is centralised here.
property bool expanded: true
function toggle() {
root.expanded = !root.expanded;
}
// SUPER A — see hosts/terra/home/hyprland.nix.
GlobalShortcut {
name: "chrome"
description: "Expand or collapse the hyprchrome bar"
onPressed: root.toggle()
}
// Scrim first: it is a layer below the bar, so stacking does not depend on
// creation order, but keeping the declaration order the same as the visual
// order costs nothing.
ChromeBackdrop {
screen: root.targetScreen
active: root.expanded
}
PanelWindow {
id: window
screen: root.targetScreen
visible: root.targetScreen !== null
// 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
// 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
implicitHeight: Math.round(window.expandedContent)
exclusionMode: ExclusionMode.Normal
exclusiveZone: Math.round(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; }
color: "transparent"
RowLayout {
id: panelRow
x: window.margin
y: window.margin
width: window.width - window.margin * 2
spacing: 8
HostPanel {
id: hostPanel
expanded: root.expanded
toggleOnClick: false
Layout.preferredWidth: 350
Layout.fillHeight: true
}
VitalsPanel {
id: vitalsPanel
expanded: root.expanded
toggleOnClick: false
Layout.preferredWidth: 500
Layout.fillHeight: true
}
// Slack lives between the left group and the tray, so the tray sits
// flush right whatever the other panels measure.
Item { Layout.fillWidth: true }
TrayPanel {
id: trayPanel
expanded: root.expanded
toggleOnClick: false
Layout.fillHeight: true
}
}
}
}