fix(dock): split divider drag resizes smoothly instead of jumping

The handle mixed the hitbox-local drag origin with window-space
drag-move positions, so the first move snapped the ratio to a clamp
boundary (a panel suddenly grew and further drags did nothing). Drags
are now incremental window-space deltas applied to the freshly-read
ratio; drag-move routing is filtered to the owning split container so
nested splits no longer double-apply with wrong geometry; pair extent
excludes the divider itself so the boundary tracks the pointer 1:1.
This commit is contained in:
2026-08-18 12:41:12 +08:00
parent 447353ebbd
commit 6f75d3c92a
2 changed files with 101 additions and 28 deletions
+68 -2
View File
@@ -111,7 +111,7 @@ pub struct DockArea {
tab_bars: HashMap<NodePath, (Entity<TabBar>, Subscription)>,
/// One split-handle entity per boundary of each `Split` node, keyed by
/// the node's current path and the boundary index. Holds transient drag
/// state (`SplitHandle::drag_origin`) across frames; pruned with the tab
/// state (`SplitHandle::last_position`) across frames; pruned with the tab
/// bars each render. A split with N children keeps N-1 handles so every
/// pair of panels can be resized independently.
split_handles: HashMap<(NodePath, usize), (Entity<SplitHandle>, Subscription)>,
@@ -753,7 +753,12 @@ impl DockArea {
Axis::Horizontal => event.bounds.size.width,
Axis::Vertical => event.bounds.size.height,
};
let pair_extent = full_extent * pair_total / total.max(1.0);
// The children share the container extent minus the fixed-size
// handles between them; the pair's ratio share applies to that
// remainder (keeps the divider tracking the pointer 1:1).
let handles = ratios.len().saturating_sub(1) as f32;
let usable = full_extent - SplitHandle::HITBOX * handles;
let pair_extent = usable * pair_total / total.max(1.0);
let position = match direction {
Axis::Horizontal => event.event.position.x,
Axis::Vertical => event.event.position.y,
@@ -843,6 +848,11 @@ impl DockArea {
children,
} => {
let direction = *direction;
// This container's own path, so the drag-move handler only
// routes drags that belong to this split (a drag-move fires on
// every split container while any handle is dragged; nested
// splits must not steal or double-apply the resize).
let container_path = path.clone();
let mut container = div()
.flex()
.size_full()
@@ -851,6 +861,9 @@ impl DockArea {
.on_drag_move::<SplitHandleDrag>(cx.listener(
move |this, event: &DragMoveEvent<SplitHandleDrag>, _window, cx| {
let drag = event.drag(cx);
if drag.path != container_path {
return;
}
let path = drag.path.clone();
let index = drag.index;
this.route_split_drag(&path, index, direction, event, cx);
@@ -1278,6 +1291,59 @@ mod tests {
})
}
/// The root split's ratios (the test layouts split at the root).
fn root_ratios(view: &Entity<DockHost>, cx: &mut VisualTestContext) -> Vec<f32> {
cx.update(|_window, app| {
view.read(app)
.dock
.read(app)
.layout()
.split_ratios(&NodePath::default())
.expect("root is a split")
})
}
/// Dragging a split's divider moves the boundary with the pointer: the
/// grab never jumps the ratio to a clamp boundary, and each move applies
/// only its own incremental delta to the current ratio (no compounding).
#[test]
fn dragging_a_split_handle_tracks_the_pointer() {
let mut test_app = TestAppContext::single();
let window = open_dock_window(&mut test_app, &[(1, "One")], &[(2, 1, DropZone::Right)]);
let any_window = *window.deref();
let view: Entity<DockHost> = window.root(&mut test_app).unwrap();
let mut cx = VisualTestContext::from_window(any_window, &test_app).into_mut();
assert_eq!(dock_layout(&view, cx), "1|2");
// The divider sits at the middle of the 800px-wide window (a 6px
// hitbox around x=400). Grab it, then drag to the right in two 50px
// moves; the first drag-move only establishes the baseline.
cx.simulate_mouse_down(point(px(400.), px(300.)), MouseButton::Left, Modifiers::default());
cx.simulate_mouse_move(point(px(404.), px(300.)), Some(MouseButton::Left), Modifiers::default());
cx.simulate_mouse_move(point(px(430.), px(300.)), Some(MouseButton::Left), Modifiers::default());
cx.simulate_mouse_move(point(px(480.), px(300.)), Some(MouseButton::Left), Modifiers::default());
// The left child's share grew by ~50px of the ~794px pair (800
// minus the divider), not to a clamp boundary.
let ratios = root_ratios(&view, &mut cx);
assert!(
(ratios[0] - (0.5 + 50.0 / 794.0)).abs() < 0.02,
"ratio follows the pointer delta: {ratios:?}"
);
// A second move applies its own delta to the updated ratio; the
// total drag distance is not re-applied on top.
cx.simulate_mouse_move(point(px(530.), px(300.)), Some(MouseButton::Left), Modifiers::default());
let ratios = root_ratios(&view, &mut cx);
assert!(
(ratios[0] - (0.5 + 100.0 / 794.0)).abs() < 0.02,
"deltas accumulate incrementally: {ratios:?}"
);
cx.simulate_mouse_up(point(px(530.), px(300.)), MouseButton::Left, Modifiers::default());
}
/// Dragging a tab onto another panel's center merges it into that panel's
/// tab group.
#[test]
+33 -26
View File
@@ -80,8 +80,9 @@ pub(crate) struct SplitHandle {
path: NodePath,
/// Boundary this handle moves: between children `index` and `index + 1`.
index: usize,
/// Pointer position where the current drag started, if dragging.
drag_origin: Option<Pixels>,
/// Window-space pointer position of the previous drag-move event; the
/// first move of a drag establishes the baseline.
last_position: Option<Pixels>,
}
impl SplitHandle {
@@ -104,23 +105,29 @@ impl SplitHandle {
direction,
path,
index,
drag_origin: None,
last_position: None,
}
}
/// Begins a drag, remembering the pointer origin.
pub(crate) fn begin_drag(&mut self, origin: Pixels) {
self.drag_origin = Some(origin);
/// Begins a drag, clearing any stale baseline; the first drag-move event
/// establishes the pointer baseline (the drag-start offset handed to the
/// ghost constructor is local to the handle's hitbox, a different
/// coordinate space than the drag-move positions, so it is unusable).
pub(crate) fn begin_drag(&mut self) {
self.last_position = None;
}
/// Applies an in-progress drag: converts the pointer delta to a ratio
/// delta relative to the pair extent and emits a
/// [`SplitHandleEvent::ResizeRequested`].
/// Applies an in-progress drag: converts the pointer delta since the
/// previous move into a ratio delta relative to the pair extent and
/// emits a [`SplitHandleEvent::ResizeRequested`].
///
/// `start_ratio` is the share of the `index` child within its pair at
/// drag start, re-read from the layout by the owning dock area on every
/// move so external edits during the drag are respected; `pair_extent` is
/// the combined on-screen extent of the two children, in pixels.
/// `start_ratio` is the share of the `index` child within its pair,
/// re-read from the layout by the owning dock area on every move so
/// external edits during the drag are respected; `pair_extent` is the
/// combined on-screen extent of the two children, in pixels. Both the
/// baseline and `position` are window-space samples of the same
/// drag-move stream, so incremental deltas stay consistent regardless of
/// the coordinate space any individual event is reported in.
pub(crate) fn drag_to(
&mut self,
position: Pixels,
@@ -128,18 +135,21 @@ impl SplitHandle {
start_ratio: f32,
cx: &mut Context<Self>,
) {
let Some(origin) = self.drag_origin else {
if pair_extent.0 <= 0.0 {
return;
}
let Some(last) = self.last_position.replace(position) else {
return;
};
if pair_extent.0 <= 0.0 {
let delta = position.0 - last.0;
if delta == 0.0 {
return;
}
// Keep both children above MIN_CHILD_EXTENT, but never clamp harder
// than a quarter of the pair so tiny parents stay resizable.
let min_ratio = (Self::MIN_CHILD_EXTENT.0 / pair_extent.0).min(0.25);
let max_ratio = 1.0 - min_ratio;
let ratio =
(start_ratio + (position.0 - origin.0) / pair_extent.0).clamp(min_ratio, max_ratio);
let ratio = (start_ratio + delta / pair_extent.0).clamp(min_ratio, max_ratio);
cx.emit(SplitHandleEvent::ResizeRequested {
path: self.path.clone(),
index: self.index,
@@ -150,7 +160,7 @@ impl SplitHandle {
/// Ends the current drag, if any.
pub(crate) fn end_drag(&mut self) {
self.drag_origin = None;
self.last_position = None;
}
/// Emits a [`SplitHandleEvent::ResetRequested`] for a double-click.
@@ -171,18 +181,15 @@ impl Render for SplitHandle {
let direction = self.direction;
let handle = cx.entity();
// Begins the drag on the handle (recording the pointer origin) and
// returns the ghost view shown under the pointer.
// Begins the drag on the handle; the ghost view is shown under the
// pointer. The origin offset is hitbox-local and unused — the handle
// establishes its baseline from the first drag-move event instead.
let ghost_ctor = move |_drag: &SplitHandleDrag,
origin: Point<Pixels>,
_origin: Point<Pixels>,
_window: &mut Window,
cx: &mut App| {
handle.update(cx, |handle, _cx| {
handle.begin_drag(if direction == Axis::Horizontal {
origin.x
} else {
origin.y
});
handle.begin_drag();
});
cx.new(|_cx| SplitDragGhost { direction })
};