Animation
The animation layer moves windows smoothly between layout positions. It is an
in-tree copy of a standalone window-animation crate, embedded directly into the
project rather than pulled as a dependency. This embedding strategy keeps the
animation code tightly coupled to the project’s needs (specifically the
InvisibleBounds translation and the AppliedLayout bridge) while retaining a
clean backend abstraction for testing. See design decisions
for the embedding rationale.
Public API Surface
The animation layer exposes a small set of types through
src/animation/mod.rs:
WindowAnimator— the main entry point. Owns a background worker thread that drives the frame loop. Submit animation batches withanimate().WindowTarget— desired final geometry for one window (window ref + target position + target size).WindowRef— opaque HWND wrapper (isizeforSendsafety).IVec2— 2D integer vector for positions and sizes.AnimRect— the animation engine’s rectangle type,{x, y, w, h}.AnimatorConfig— duration, easing curves, interrupt policy, frame pacing.
The AnimRect type uses {w, h} for its size fields. This is intentionally
different from flow’s common::Rect which uses {width, height}. The two types
coexist in different modules and are converted at integration boundaries. The
daemon bridge handles this conversion transparently.
Data Flow
When a layout mutation produces an AppliedLayout, the daemon calls
animate_layout(), which converts the layout into animation targets and submits
them to the animator’s worker thread.
flowchart TB
SUB["ScrollingSpace<br/>layout mutation"]
AL["AppliedLayout<br/>(virtual + actual)"]
BRIDGE["daemon::animate_layout()<br/>visible-to-window-rect<br/>translation"]
TARGETS["Vec<WindowTarget>"]
ANIM["WindowAnimator::animate()<br/>build_tweens()"]
TWEEN["AnimationBatch<br/>per-window tweens"]
BACKEND["WindowBackend<br/>(Win32 or Mock)"]
SWP["SetWindowPos per window"]
SUB --> AL
AL --> BRIDGE
BRIDGE --> TARGETS
TARGETS --> ANIM
ANIM --> TWEEN
TWEEN --> BACKEND
BACKEND --> SWP
The Frame Loop
WindowAnimator spawns a dedicated worker thread at construction time. The
worker drives a continuous loop:
- When idle, it blocks on a
crossbeam_channelwaiting for commands. - When an
Animatecommand arrives, it builds per-window tweens (from rect, to rect) and enters the frame loop. - Each frame, it samples the normalised progress
tfrom wall-clock elapsed time, computes the interpolated rect for each tween, and callsbackend.apply_batch()to reposition the windows. - After applying, it calls
backend.dwm_flush(), which blocks until the Desktop Window Manager completes its current composition cycle (~16ms at 60Hz). This is the frame-pacing mechanism: rather than a fixedsleepinterval,DwmFlushsynchronises the animation with the display refresh rate. - The batch is cleared only when
treaches exactly 1.0 — the frame that was applied at the precise target rect. This prevents the “resizing column width comes out wrong” bug whereprogress()ticks past 1.0 during theSetWindowPosround-trip and the batch is retired without ever applying the final frame.
Progress is clamped to [0.0, 1.0] and never exceeds 1.0, even if wall-clock
time far exceeds the animation duration. This invariant makes t >= 1.0 a
precise and safe completion signal.
Easing Functions
Position and size channels are eased independently. The animation module
(src/animation/easing.rs) provides a
comprehensive catalog of 31 named easing curves plus arbitrary CSS cubic-bezier
curves (solved via Newton-Raphson iteration):
| Category | Variants |
|---|---|
| Linear | Linear |
| Sine | EaseInSine, EaseOutSine, EaseInOutSine |
| Quadratic | EaseInQuad, EaseOutQuad, EaseInOutQuad |
| Cubic | EaseInCubic, EaseOutCubic, EaseInOutCubic |
| Quartic | EaseInQuart, EaseOutQuart, EaseInOutQuart |
| Quintic | EaseInQuint, EaseOutQuint, EaseInOutQuint |
| Exponential | EaseInExpo, EaseOutExpo, EaseInOutExpo |
| Circular | EaseInCirc, EaseOutCirc, EaseInOutCirc |
| Back | EaseInBack, EaseOutBack, EaseInOutBack |
| Elastic | EaseInElastic, EaseOutElastic, EaseInOutElastic |
| Bounce | EaseInBounce, EaseOutBounce, EaseInOutBounce |
| Custom | CubicBezier(x1, y1, x2, y2) |
The default config uses EaseInOut (maps to EaseInOutCubic) for position and
Linear for size. Position curves can be more expressive because resize-heavy
transitions look worse on Windows — window content must reflow each frame.
Size animation supports a DisabledIfUnchanged mode: when a window’s width and
height already match the target, the size channels are skipped and the move is
treated as a pure translation. This avoids unnecessary content reflow.
Batch Scheduling
The animation layer uses AnimationBatch (src/animation/batch.rs)
to manage all per-window tweens for one animation run. Key design points:
- No-op filtering: windows whose current rect already matches the target are silently excluded from the batch. This avoids wasting frames on windows that don’t need to move.
- Independent easing per channel: position (x, y) and size (w, h) are eased with separate curves, allowing a fast position move combined with a slower size transition.
- Wall-clock timing: progress is computed from
Instant::now()at batch start, not from frame counting. This makes animation duration independent of frame rate.
Interrupt Policies
When a new animate() call arrives while an animation is already playing, the
configurable interrupt policy decides what happens:
| Policy | Behaviour |
|---|---|
RetargetFromCurrent (default) | Sample each window’s current interpolated position, cancel the active batch, and start a new batch from those positions toward the new targets. Preserves visual continuity. |
QueueAfterCurrent | Finish the current animation, then start the queued one. |
DropNew | Silently discard the incoming request. |
The default RetargetFromCurrent policy is what enables smooth rapid-fire
operations (e.g., holding the scroll key). Each successive layout mutation
retargets windows from wherever they currently appear on screen, not from their
original position.
The Two Backends
The WindowBackend trait (src/animation/backend/mod.rs)
decouples the animation engine from Win32:
classDiagram
class WindowBackend {
<<trait>>
+get_window_rect(WindowRef) Rect
+apply_batch(updates) Result
+dwm_flush() Result
}
class Win32Backend {
Individual SetWindowPos calls
+ DwmFlush for vsync pacing
}
class MockBackend {
In-memory HashMap of rects
Records call history for assertions
}
WindowBackend <|-- Win32Backend
WindowBackend <|-- MockBackend
Win32Backend (src/animation/backend/win32.rs)
uses individual SetWindowPos calls rather than the batch DeferWindowPos API.
This is a deliberate resilience choice: DeferWindowPos is atomic, so a single
elevated admin window (protected by UIPI) causes the entire batch to fail with
ERROR_ACCESS_DENIED. Individual calls mean one failure logs a warning but does
not block the remaining windows.
MockBackend (src/animation/backend/mock.rs)
stores rects in a HashMap and records call history. Always compiled (no feature
gate) so both unit tests and integration tests can use it freely.
The Bridge: daemon::animation
The daemon’s animation bridge (src/daemon/animation.rs)
is the integration point between the layout engine and the animation system. Its
responsibilities:
-
Registry sync: always calls
update_tiling_slots_from_layout()andupdate_tiled_rects()on the registry, even when no windows physically moved. Logical positions (col/row) may change without pixel-level moves. -
Visible-rect to window-rect translation: the layout engine computes visible rects (what the user sees), but
SetWindowPosuses window rects (which include invisible borders). The bridge looks up each window’sInvisibleBoundsfrom the registry and callsvisible_to_window()to translate. Without this, windows would appear with gaps larger than configured. -
Type conversion: maps flow’s
WindowId(isize)toWindowRef(isize), and the visible rect’s{width, height}toIVec2::new(width, height). -
Submit all windows: every entry in
actual_layoutbecomes a target, not just the ones that changed. The animator’sbuild_tweensdrops no-ops internally, but passing all windows ensures that mid-flight retargeting works correctly even when a window’s target rect didn’t change.
A standalone animate_layout_raw() function performs the same conversion but
takes &mut WindowAnimator directly. This is used during daemon construction
when FlowWM doesn’t exist yet but the animator needs to snap
windows to their initial positions (with Duration::ZERO for an instant snap).
Mid-Flight Retargeting
If a new layout arrives while a previous animation is still running, the
RetargetFromCurrent policy ensures smooth transitions. The worker samples
each window’s current interpolated position from the active batch (using the
batch’s frozen config for consistency) and starts a new batch from those
positions toward the new targets. This means rapid-fire operations like
scrolling or swapping produce continuous motion with no visible restarts.
Cross-References
- Layout pipeline — how
AppliedLayoutis produced by the mutate-then-project pipeline. - Event pipelines — when
animate_layoutis called in response to hook events and IPC commands. - Window registry — how
InvisibleBoundsis measured and stored per window.