From 6f75d3c92a93cdda10d743d52fd0f10378ce574a Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Tue, 18 Aug 2026 12:41:12 +0800 Subject: [PATCH] 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. --- crates/gpui/src/dock/dock_area.rs | 70 +++++++++++++++++++++++++++- crates/gpui/src/dock/split_handle.rs | 59 ++++++++++++----------- 2 files changed, 101 insertions(+), 28 deletions(-) diff --git a/crates/gpui/src/dock/dock_area.rs b/crates/gpui/src/dock/dock_area.rs index ed5637e334..72e418cbf2 100644 --- a/crates/gpui/src/dock/dock_area.rs +++ b/crates/gpui/src/dock/dock_area.rs @@ -111,7 +111,7 @@ pub struct DockArea { tab_bars: HashMap, 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, 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::(cx.listener( move |this, event: &DragMoveEvent, _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, cx: &mut VisualTestContext) -> Vec { + 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 = 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] diff --git a/crates/gpui/src/dock/split_handle.rs b/crates/gpui/src/dock/split_handle.rs index 038f01dd27..5eac0c3d4d 100644 --- a/crates/gpui/src/dock/split_handle.rs +++ b/crates/gpui/src/dock/split_handle.rs @@ -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, + /// Window-space pointer position of the previous drag-move event; the + /// first move of a drag establishes the baseline. + last_position: Option, } 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, ) { - 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, + _origin: Point, _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 }) };