Skip to Content
ftui-layoutPanesMagnetic docking

Magnetic Docking

Magnetic docking is the UX pattern where, while you drag a pane over another pane, the system previews the eventual insertion site and “attracts” the pointer toward it. It’s the same idea as snapping a window to the screen edge in macOS or Windows, generalized to a recursive pane tree.

The three primitives

/// Default radius for magnetic docking attraction in cell units. pub const PANE_MAGNETIC_FIELD_CELLS: f64 = 6.0; pub enum PaneDockZone { Left, Right, Top, Bottom, Center } pub struct PaneDockPreview { pub target: PaneId, pub zone: PaneDockZone, pub score: f64, // distance-weighted, higher = stronger pub ghost_rect: Rect, // where the new pane will land }

Three concepts, one per line:

  • Field radius — how close the pointer has to be before attraction kicks in, in cell units.
  • Dock zone — which side of the target pane is the preview for. Center means “replace the contents,” edges mean “split and dock.”
  • Ghost rect — the rectangle the new pane would occupy, visualized as a translucent overlay during the drag.

What the ghost looks like

┌───────────────────────┐ ┌───────────────────────┐ │ target pane │ │░░░░░░░░░░│ │ │ │ │░ghost:Left│ │ │ · pointer │ ───▶ │░░░░░░░░░░│ target │ │ │ │░░░░░░░░░░│ │ └───────────────────────┘ └──────────┴────────────┘ (pointer near left edge → zone: Left)

The ghost for a side-dock takes half the target’s width or height in the corresponding direction. For Center, the ghost is the target’s full rectangle — the existing contents are replaced.

Field radius — the attraction cone

A pointer at distance d from a zone anchor scores into a preview iff d ≤ magnetic_field_cells. The default PANE_MAGNETIC_FIELD_CELLS = 6.0 means: within 6 cells of any edge, you’ll see a ghost.

edge of target pane ◀─ 6 cells ─▶ no dock preview here ───────── edge ─────────────────────────▶ │ magnetic field zone │ (ghost preview shown)

Scoring

Candidates are ranked by score, a distance-weighted number where larger means stronger attraction. The score function mixes:

  • Distance to zone anchor — closer is higher.
  • Zone biasCenter is deliberately weighted slightly lower (≈ 0.85) so that edge zones win on tiebreaks; users rarely mean “replace” when they could mean “split.”
  • Inertial projection — a throw projects the pointer forward in time; the score uses the projected pointer, not the raw one. See inertial throw.

The top-ranked preview is what the UI renders as the ghost. Additional candidates are returned for tie-breaking or previewing in stacked-dock UIs:

impl PaneLayout { pub fn choose_dock_preview( &self, pointer: PanePointerPosition, magnetic_field_cells: f64, ) -> Option<PaneDockPreview>; pub fn dock_previews_ranked( &self, pointer: PanePointerPosition, magnetic_field_cells: f64, ) -> Vec<PaneDockPreview>; }

Tuning the field with the wheel

Scroll-wheel input while a drag is active can adjust the field radius instead of scrolling the content. A positive wheel tick widens the magnetic field (makes docking “sticky from further away”); a negative tick narrows it (requires closer pointer proximity).

This is implemented at the runtime layer by translating PaneSemanticInputEventKind::WheelNudge into a field-radius delta while the drag machine is Armed or Dragging. See semantic input and drag-resize machine for the adjacent machinery.

wheel up wheel up wheel up wheel down ━━━━▶ ━━━━▶ ━━━━▶ ━━━━▶ 6 cells 8 cells 10 cells 4 cells (wider magnetic field) (narrower field)

Users who want precision shrink the field; users who want snappy docking widen it.

Worked example — pick a preview during a drag

dock_preview.rs
use ftui_layout::pane::{ PANE_MAGNETIC_FIELD_CELLS, PaneLayout, PanePointerPosition, }; fn preview_for_pointer( layout: &PaneLayout, pointer: PanePointerPosition, custom_radius: Option<f64>, ) { let radius = custom_radius.unwrap_or(PANE_MAGNETIC_FIELD_CELLS); if let Some(preview) = layout.choose_dock_preview(pointer, radius) { println!( "ghost at {:?} on {:?}, score = {:.3}", preview.ghost_rect, preview.zone, preview.score, ); } else { println!("no dock target in range"); } }

Reflow planning — turn a preview into ops

When the user releases mid-drag, the PaneReflowMovePlan combines the inertial projection, the winning dock preview, the pressure-snap profile, and the SplitAxis into a list of PaneOperations:

pub struct PaneReflowMovePlan { pub source: PaneId, pub pointer: PanePointerPosition, pub projected_pointer: PanePointerPosition, pub preview: PaneDockPreview, pub snap_profile: PanePressureSnapProfile, pub operations: Vec<PaneOperation>, }

The operations field is exactly what goes into the timeline. Magnetic docking adds no new op variants; it just produces MoveSubtree + optional SetSplitRatio / NormalizeRatios entries.

Pitfalls

Don’t set the field radius to zero. A zero-cell field effectively disables docking — the pointer has to be exactly on the anchor pixel. That’s rarely what you want; a small-but-finite field (say, 2 cells) is the usable minimum.

Center is deliberately weaker than edges. If a preview for Center is beating your edge preview, check that your edge preview’s anchor is where you think it is. The bias is correct; the pointer location is probably closer to center than to the edge you expected.

Ghost rects don’t clamp to their parent. The ghost represents the insertion site; it can extend outside the dragged pane’s current position. Render it in the coordinate space of the PaneLayout, not the dragged pane’s clip region.

Where to go next