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:
@@ -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]
|
||||
|
||||
@@ -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 })
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user