Skip to Content
ftui-layoutPanesDrag-resize machine

Drag-Resize Machine

PaneDragResizeMachine is the finite state machine that converts pointer events into layout mutations. It is one of the load-bearing pieces of the pane subsystem: if this state machine is wrong, drags feel twitchy or stuck, and replay traces diverge across backends.

What problem it solves

Pointer events arrive in a firehose. A casual mouse click on a divider might produce 20 MouseMove events before MouseUp, most of which represent pointer noise — a tenth of a cell worth of jitter. If you apply every move as a resize you get:

  • Visible thrashing on every tiny twitch.
  • Timeline spam — hundreds of SetSplitRatio entries per click.
  • Nondeterministic replay — jitter is OS- and device-dependent.

The machine fixes all three. It debounces pointer motion with two thresholds (a drag threshold to leave idle, an update hysteresis to emit a new resize) and only reaches the Dragging state — where operations fire — after the user actually commits to the gesture.

The three states

ASCII form:

PointerDown(grip) ┌──────────────────────────────┐ ▼ │ ┌──────┐ cross threshold ┌─────────┐ │ Idle │────────────────────▶ │ Armed │ └──────┘ └─────────┘ ▲ │ │ ▼ cross drag_threshold │ ┌──────────┐ │ PointerUp / Cancel │ Dragging │ └────────────────────────── └──────────┘ ▼ Cancel / PointerUp (commit) Idle

Only Dragging emits PaneDragResizeEffect::DragUpdated and applies resize operations. Transitioning out of Dragging via PointerUp emits Committed; transitioning out via Cancel (Escape pressed, window unfocused, target gone) emits Canceled.

State payload

Each state carries the pointer identity and origin so that interleaved multi-pointer events can be dispatched correctly:

pub enum PaneDragResizeState { Idle, Armed { target: PaneResizeTarget, pointer_id: u32, origin: PanePointerPosition, current: PanePointerPosition, started_sequence: u64, }, Dragging { target: PaneResizeTarget, pointer_id: u32, origin: PanePointerPosition, current: PanePointerPosition, started_sequence: u64, drag_started_sequence: u64, }, }

started_sequence / drag_started_sequence link the state back to the semantic input event that triggered the transition, so the replay machinery can verify each step.

The machine itself

pub struct PaneDragResizeMachine { state: PaneDragResizeState, drag_threshold: u16, // cells; must be > 0 update_hysteresis: u16, // cells; must be > 0 transition_counter: u64, } impl PaneDragResizeMachine { pub fn new(drag_threshold: u16) -> Result<Self, …>; pub fn new_with_hysteresis(drag_threshold: u16, update_hysteresis: u16) -> Result<Self, …>; pub fn state(&self) -> PaneDragResizeState; pub fn is_active(&self) -> bool; // true in Armed or Dragging }

The constructors reject drag_threshold == 0 or update_hysteresis == 0 — a zero threshold would collapse the machine to “every pointer event is a drag” and defeat the whole purpose.

The two thresholds, illustrated

origin │ │←── drag_threshold (e.g. 3 cells) ──→│ │ │ │ │ │ no operations │ first DragUpdated here │ │ │ pointer position over time ─────────────────▶

Then, inside Dragging:

last emitted position │ │←── update_hysteresis (e.g. 1 cell) ──→│ │ │ │ │ │ operations suppressed │ new DragUpdated here │ │ │ pointer position over time ──────────────────▶

The drag threshold protects the transition out of Armed; the update hysteresis protects the rate at which operations fire from within Dragging. Default values live in PANE_DRAG_RESIZE_DEFAULT_THRESHOLD and …_DEFAULT_HYSTERESIS.

Emitted effects

Every accepted transition emits a PaneDragResizeEffect:

EffectWhen
ArmedFirst PointerDown on a valid grip.
DragStartedCumulative delta crosses drag_threshold.
DragUpdatedIn Dragging, motion crosses update_hysteresis.
CommittedPointerUp while Dragging.
CanceledCancel or Blur from any non-idle state.
KeyboardAppliedKeyboardResize input (independent of pointer states).
WheelAppliedWheelNudge input.
NoopEvent deliberately ignored (with a PaneDragResizeNoopReason).

Noop is a first-class outcome. Getting an event that “didn’t do anything” is different from getting an event that errored; the noop reasons (IdleWithoutActiveDrag, PointerMismatch, TargetMismatch, ThresholdNotReached, BelowHysteresis, …) are serialized so telemetry can count them.

Worked example — step through a drag

User presses button on a vertical divider

Semantic input: PointerDown { target: …, pointer_id: 7, position: (40, 12), button: Primary }.

Machine transitions Idle → Armed { pointer_id: 7, origin: (40,12), current: (40,12) }. Effect: Armed.

User moves 1 cell right

Semantic input: PointerMove { pointer_id: 7, position: (41, 12), delta_x: 1, delta_y: 0 }.

|delta_x| + |delta_y| = 1 < drag_threshold (default 3). Machine stays Armed. Effect: Noop { reason: ThresholdNotReached }.

User moves 3 more cells right

Cumulative delta is now 4 ≥ drag_threshold. Transition Armed → Dragging. Two effects in one tick: DragStarted then DragUpdated.

User continues moving

Each move that crosses the update_hysteresis (default 1 cell) emits a DragUpdated. Sub-threshold moves are Noop { BelowHysteresis }.

User releases

PointerUp. Transition Dragging → Idle. Effect: Committed.

Every DragUpdated / Committed is what the caller converts into a SetSplitRatio operation and applies to the tree through the timeline.

Interaction with the tree is one level up

Importantly, the machine doesn’t touch the PaneTree. It emits effects; a runtime-level caller translates DragUpdated { total_delta_x, … } into a PaneOperation::SetSplitRatio and submits it to the journaler. The separation means you can unit-test the machine against recorded input with no tree at all.

Pitfalls

Don’t lower the thresholds to zero to “feel more responsive.” You will get drag storms on hardware with noisy pointers, replay divergence between backends, and a timeline that’s unreadable. If the default feels laggy, raise the hysteresis, not lower it.

pointer_id mismatches produce Noop { PointerMismatch }. If you see these in telemetry, something upstream (the host adapter, typically) is not tagging events with a consistent pointer identity across a gesture. Fix at the source; don’t ignore the noops.

Blur ends any active drag. If the user Alt-Tabs away mid-drag, the adapter should send Blur. The machine transitions to Idle with effect Canceled { reason: Blur }. Layout is left wherever the last committed DragUpdated left it — not rolled back.

Where to go next