Skip to Content
ftui-layoutPanesInertial throw

Inertial Throw and Pressure Snap

When a user finishes a drag, the pointer has velocity. A good UI carries that momentum forward for a few frames (“throw”) and, when it settles, snaps to a canonical layout — but only if the gesture was confident enough to warrant the snap. This page covers the two types that model exactly that.

The gesture signal — PaneMotionVector

Both downstream types consume a motion summary:

pub struct PaneMotionVector { pub delta_x: i32, pub delta_y: i32, pub speed: f64, // cells / second pub direction_changes: u16, // sign flips in the gesture window }

direction_changes is a cheap noise indicator. A smooth throw has 0–1 direction changes; a hesitation has 5+. The two models below are careful to weight noisy gestures as less confident.

PaneInertialThrow — momentum after release

pub struct PaneInertialThrow { pub velocity_x: f64, pub velocity_y: f64, pub damping: f64, // exponential damping per second pub horizon_ms: u16, // projection horizon } impl PaneInertialThrow { pub fn from_motion(motion: PaneMotionVector) -> Self; pub fn projected_pointer(self, start: PanePointerPosition) -> PanePointerPosition; }

from_motion derives a throw profile from the gesture:

  • Speed is clamped to [0, 220] cells/second, then raised to the power 0.72 — sublinear so very fast flicks don’t dominate.
  • Direction coherence is 1 − 0.55 * (direction_changes / 10) clamped into [0.35, 1.0]. Noisy gestures lose up to 65 % of their projected velocity.
  • Projected velocity is (10.0 + speed * 0.55) * coherence.

The physics, compactly

Given an initial velocity v0v_0 and damping λ\lambda, the position at time tt is an exponentially-decaying integral:

x(t)=x0+v0λ(1eλt)x(t) = x_0 + \frac{v_0}{\lambda} (1 - e^{-\lambda t})

As tt \to \infty, the pointer asymptotes at x0+v0/λx_0 + v_0 / \lambda. horizon_ms picks a cutoff where “close enough” is reached. projected_pointer(start) returns the final position the UI should preview.

pointer position │ ╭──────────── settle here │ ╭──────╯ │ ╭──────╯ │ ╭────╯ │ ╭────╯ │╭──╯ ───┼─────────────────────────────────▶ time release horizon_ms

The throw profile is what the docking preview uses to ask “where would this pane land if I released right now?” — see magnetic docking.

PanePressureSnapProfile — confidence-weighted snap

After the throw settles, the system decides whether to snap to a canonical layout (50/50 split, a preset ratio, a docked edge). The strength of that snap depends on how confident the gesture was:

pub struct PanePressureSnapProfile { pub strength_bps: u16, // 0..=10_000 pub hysteresis_bps: u16, // 0..=2_000 } impl PanePressureSnapProfile { pub fn from_motion(motion: PaneMotionVector) -> Self; pub fn apply_to_tuning(self, base: PaneSnapTuning) -> PaneSnapTuning; }

The profile is computed from four signals:

  1. Axis dominancemax(|dx|, |dy|) / (|dx| + |dy|), clamped to [0.5, 1.0]. A perfect horizontal drag scores 1.0; a diagonal one scores 0.5.
  2. Speed factor(speed / 70.0)^0.78, clamped to [0, 1]. Slow drags imply precision intent; fast drags imply canonical intent.
  3. Noise penaltydirection_changes / 7, clamped to [0, 1].
  4. Confidence = speed_factor * (0.65 + axis_dominance * 0.35) * (1 − noise_penalty * 0.72).

strength_bps runs from about 1,500 (no snap) to 10,000 (always snap). hysteresis_bps runs from about 60 to 560 — confident gestures earn a wider “sticky” window around the snap point so the layout feels solid.

The snap decision

apply_to_tuning(base) returns a PaneSnapTuning { step_bps, hysteresis_bps } that the layout planner uses downstream:

pub fn decide(self, ratio_bps: u16, previous_snap: Option<u16>) -> PaneSnapDecision;

The decision enum is:

pub struct PaneSnapDecision { pub reason: PaneSnapReason, // SnappedToStep | Sticky | NoSnap | … pub target_position: Option<u16>, }

Worked example — fast confident flick vs. slow probe

User slowly probes with low confidence

PaneMotionVector { delta_x: 3, delta_y: 1, speed: 6.0, direction_changes: 4 }.

  • speed_factor ≈ (6/70)^0.78 ≈ 0.14
  • direction_changes = 4noise_penalty ≈ 0.57
  • axis_dominance ≈ 0.75
  • confidence ≈ 0.14 * (0.65 + 0.75 * 0.35) * (1 − 0.57 * 0.72) ≈ 0.07

Result: strength_bps ≈ 2,100 — effectively no snap. The ratio is left where the user put it.

User flicks fast and decisively

PaneMotionVector { delta_x: 80, delta_y: 3, speed: 150.0, direction_changes: 0 }.

  • speed_factor ≈ (150/70)^0.78 ≈ 1.78 → clamp to 1.0
  • direction_changes = 0noise_penalty = 0
  • axis_dominance ≈ 0.96
  • confidence ≈ 1.0 * (0.65 + 0.96 * 0.35) * 1.0 ≈ 0.99

Result: strength_bps ≈ 10,000 — snap aggressively to the nearest canonical ratio. hysteresis_bps ≈ 560, so small moves near that ratio stay snapped.

The effect users perceive: precise dragging “sticks” where you leave it, and throws clunk satisfyingly into 50/50 or 1/3 positions.

Pitfalls

Don’t compute inertial throw from a single sample. You need at least the last two positions and a timestamp. If you pass a zero or noise-only motion vector, from_motion will give you a throw with barely-nonzero velocity — valid, but meaningless.

Snap strength is bps (basis points), not a percentage. 10,000 bps = 100 %. Pass values in the 0..=10_000 range, not 0..=100.

Momentum is opt-in. The drag machine itself emits a final Committed effect at the release position. Calling code decides whether to also run a throw projection and fire follow-up operations. If you don’t want momentum, don’t compute it.

Where to go next