workspaces: keep the trail animating and level across row rebuilds

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.
This commit is contained in:
Breadway 2026-08-26 11:48:51 +08:00
parent 1b523eab63
commit 74f280eaed

View file

@ -277,6 +277,13 @@ struct Geom {
struct TrailInner { struct TrailInner {
tick: Option<gtk4::TickCallbackId>, tick: Option<gtk4::TickCallbackId>,
geom: Geom, 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 /// Overlay + Fixed pill sitting *behind* the workspace buttons. The
@ -317,6 +324,7 @@ impl WorkspaceTrail {
let inner = Rc::new(RefCell::new(TrailInner { let inner = Rc::new(RefCell::new(TrailInner {
tick: None, tick: None,
natural: None,
geom: Geom { geom: Geom {
x: 0.0, x: 0.0,
y: 0.0, y: 0.0,
@ -418,6 +426,7 @@ impl WorkspaceTrail {
} else { } else {
None None
}; };
let cached = st.natural;
drop(st); drop(st);
// Width must always come from a button that is CURRENTLY IN THE ROW. // 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 // Taking position from the live geometry and width from the source
// button's natural size keeps the motion continuous while making width // button's natural size keeps the motion continuous while making width
// a pure function of which button we started from. // a pure function of which button we started from.
// `natural` is None only if BOTH buttons are detached, in which case if let Some(n) = natural {
// there is no sane width to animate from and the caller falls back to self.inner.borrow_mut().natural = Some((n.w, n.h));
// an instant `place()`. There is deliberately no path that returns the }
// live width. // Only x comes from the live geometry. That is what makes an
match (live, natural) { // interrupted switch continue from where the pill currently is instead
(Some(live), Some(natural)) => Some(Geom { // 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, x: live.x,
y: live.y, y: natural.map(|n| n.y).unwrap_or(live.y),
w: natural.w, w,
h: natural.h, h,
}), }),
(None, natural) => natural, (None, Some(natural), _) => Some(natural),
(Some(_), None) => None, _ => None,
} }
} }
} }