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.
Centermeans “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 bias —
Centeris 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
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
The projected pointer that dock-preview scoring uses.
Inertial throwHow wheel events are dispatched to field-radius tuning vs. content scroll.
Semantic inputThe state machine whose Dragging state enables dock previews.
Wheel-event origins.
Events and input (core)How this piece fits in panes.
Panes overview