collective

Browse / github.com/storytold/craft-libs / patch

craft-ui: a tab dragged out of the window floats where it left

craft-ui: a tab dragged out of the window floats where it left ## Problem Dragging a dock tab out of the app window leaves the drag stuck: the panel stays in its dock, and the drop outline keeps following the pointer when it comes back, until the next click inside the window drops it there. Reported against PhotoCraft on Linux (XWayland) and on Windows. Cause: on X11 (including XWayland) and Windows, the pointer leaving the window reaches egui as `PointerGone`. egui-winit then drops the button release, because `on_mouse_button_input` only forwards a button event while it has a pointer position. egui keeps `primary_down()` true, so `DockArea::finish_drag

Status
open
Score
0.00
Adopters
0 other operators
Author
prekabreki (imported from pull request #85, open)
Base
e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e
Size
+32 −4 in 2 files
Review
pending
Shared
2026-10-28
Id
828124e8447d12161c28451f

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui/src/docking/surface.rs crates/ui/src/docking/tests.rs

diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs--- a/crates/ui/src/docking/surface.rs+++ b/crates/ui/src/docking/surface.rs@@ -829,8 +829,12 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {      fn finish_drag(&mut self, ctx: &Context) {         let Some(drag) = ctx.data(|d| d.get_temp::<Drag<P>>(self.id.with("drag"))) else { return };-        let (pointer, down, released, focused) =-            ctx.input(|i| (i.pointer.interact_pos(), i.pointer.primary_down(), i.pointer.primary_released(), i.focused));+        let (pointer, down, released, focused, gone) =+            ctx.input(|i| (i.pointer.interact_pos(), i.pointer.primary_down(), i.pointer.primary_released(), i.focused, !i.pointer.has_pointer()));+        // Leaving the window is the drop: on X11 and Windows the release outside never reaches+        // egui, so the drag would otherwise stay on the pointer. `interact_pos` still holds the+        // last position this frame, and the panel floats there rather than docking at the edge.+        let released = released || gone;         let escape = ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape));         if escape || !focused || !self.layout.contains(&drag.panel) || (!down && !released) {             cancel_drag::<P>(ctx, self.id);@@ -854,10 +858,10 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {             if pointer.distance(drag.origin) < 2.0 {                 return;             }-            let action = if let Some(proposal) = proposal {+            let action = if let Some(proposal) = proposal.filter(|_| !gone) {                 Some(Action::Move { panel: drag.panel.clone(), anchor: proposal.anchor, placement: proposal.placement })             } else if (self.permissions)(&drag.panel).floatable-                && (!drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.panels.contains(&drag.panel)))+                && (gone || !drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.panels.contains(&drag.panel)))             {                 let rect = clamp_float(Rect::from_min_size(pointer - drag.grab, drag.source.size()), ctx.content_rect());                 Some(Action::Float { panel: drag.panel.clone(), rect: [rect.left(), rect.top(), rect.width(), rect.height()] })diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs--- a/crates/ui/src/docking/tests.rs+++ b/crates/ui/src/docking/tests.rs@@ -395,6 +395,30 @@ fn actual_pointer_drag_docks_a_floating_panel_into_the_main_tab_strip() {     assert_eq!(panels.get(*active), Some(&5)); } +/// On X11 and Windows a release outside the window never reaches egui (egui-winit drops a+/// button event once the pointer has left), so a tab dragged out of the window used to stay on+/// the pointer for good. Leaving the window detaches the panel where it left, even over a dock+/// target, and ends the drag.+#[test]+fn tab_dragged_out_of_the_window_floats_where_it_left() {+    let ctx = Context::default();+    let mut layout = sample();+    for _ in 0..3 {+        step(&ctx, &mut layout, Vec::new());+    }+    let tab = ctx.read_response(Id::new("test-area").with(("tab", &3u32))).unwrap().rect.center();+    let exit = pos2(4.0, 300.0);+    step(&ctx, &mut layout, vec![Event::PointerMoved(tab), pointer_button(tab, true)]);+    step(&ctx, &mut layout, vec![Event::PointerMoved(tab + vec2(-8.0, 0.0))]);+    step(&ctx, &mut layout, vec![Event::PointerMoved(exit)]);+    let actions = step(&ctx, &mut layout, vec![Event::PointerGone]);+    assert!(actions.iter().any(|a| matches!(a, Action::Float { panel: 3, .. })), "{actions:?}");+    assert!(layout.floating.iter().any(|g| g.panels == [3]), "{layout:?}");+    let back = pos2(400.0, 300.0);+    assert!(step(&ctx, &mut layout, vec![Event::PointerMoved(back)]).is_empty());+    assert!(step(&ctx, &mut layout, vec![pointer_button(back, false)]).is_empty(), "the drag ended when the pointer left");+}+ #[test] fn panel_size_limits_clamp_geometry_without_overwriting_preferred_widths() {     let layout = Layout {