From 74f280eaed695f586b7cdd2186843bef435dbe02 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 26 Aug 2026 11:48:51 +0800 Subject: [PATCH] workspaces: keep the trail animating and level across row rebuilds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regressions from the previous commit, both from trusting the live geometry too far. Snapping instead of moving: switching to an empty workspace rebuilds the button row, and for a frame afterwards no button is allocated, so button_geom returned None for both the source and destination. from_geom then returned None and the caller fell back to an instant place(). The trail now remembers the last size measured from an allocated button and animates from that, so a rebuild costs continuity of size, not the animation itself. Sitting low: y was taken from the live geometry, which after a rebuild or mid-animation is not where the row actually is. Only x comes from the live geometry now — that is what makes an interrupted switch continue from where the pill is rather than jumping back — while y, w and h always come from a real button. --- src/bar/workspaces.rs | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/src/bar/workspaces.rs b/src/bar/workspaces.rs index ef2d833..8d98cf2 100644 --- a/src/bar/workspaces.rs +++ b/src/bar/workspaces.rs @@ -277,6 +277,13 @@ struct Geom { struct TrailInner { tick: Option, geom: Geom, + /// Last width/height measured from a button that was actually allocated. + /// A row rebuild (switching to an empty workspace makes Hyprland create + /// and destroy it) can leave every button unallocated for a frame, and + /// without a remembered size the trail had nothing safe to animate from + /// and fell back to an instant `place()` — which is what made a switch + /// snap instead of move. + natural: Option<(f64, f64)>, } /// Overlay + Fixed pill sitting *behind* the workspace buttons. The @@ -317,6 +324,7 @@ impl WorkspaceTrail { let inner = Rc::new(RefCell::new(TrailInner { tick: None, + natural: None, geom: Geom { x: 0.0, y: 0.0, @@ -418,6 +426,7 @@ impl WorkspaceTrail { } else { None }; + let cached = st.natural; drop(st); // Width must always come from a button that is CURRENTLY IN THE ROW. @@ -447,19 +456,24 @@ impl WorkspaceTrail { // Taking position from the live geometry and width from the source // button's natural size keeps the motion continuous while making width // a pure function of which button we started from. - // `natural` is None only if BOTH buttons are detached, in which case - // there is no sane width to animate from and the caller falls back to - // an instant `place()`. There is deliberately no path that returns the - // live width. - match (live, natural) { - (Some(live), Some(natural)) => Some(Geom { + if let Some(n) = natural { + self.inner.borrow_mut().natural = Some((n.w, n.h)); + } + // Only x comes from the live geometry. That is what makes an + // interrupted switch continue from where the pill currently is instead + // of jumping back. y/w/h always come from a real button: taking y from + // a mid-animation or post-rebuild geometry is what made the pill sit + // low, and taking w from it is what let the width compound. + let size = natural.map(|n| (n.w, n.h)).or(cached); + match (live, natural, size) { + (Some(live), _, Some((w, h))) => Some(Geom { x: live.x, - y: live.y, - w: natural.w, - h: natural.h, + y: natural.map(|n| n.y).unwrap_or(live.y), + w, + h, }), - (None, natural) => natural, - (Some(_), None) => None, + (None, Some(natural), _) => Some(natural), + _ => None, } } }