From 8db84c54f11edd36b378a672776da2f742010634 Mon Sep 17 00:00:00 2001 From: prekabreki Date: Tue, 13 Oct 2026 21:17:34 +0200 Subject: [PATCH] craft-ui: a tab dragged out of the window floats where it left On X11 (including XWayland) and Windows, the pointer leaving the window reaches egui as PointerGone, and egui-winit then drops the button release because it has no pointer position. finish_drag never saw the drag end: the panel stayed in its dock with the drop outline following the pointer until the next click inside the window. Leaving the window now ends the drag as a drop. interact_pos still holds the last position on that frame, and the panel floats there (clamped to the window) instead of docking into whatever target sits at the edge. Test: tab_dragged_out_of_the_window_floats_where_it_left. It fails without the fix (no action at all). Co-Authored-By: Claude Opus 5.5 (cherry picked from commit aecdf819ba753df0092eef73633706d3f6ce1f24) --- crates/ui/src/docking/surface.rs | 12 ++++++++---- crates/ui/src/docking/tests.rs | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index b0e5badd..764cfc76 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -829,8 +829,12 @@ impl Renderer<'_, P> { fn finish_drag(&mut self, ctx: &Context) { let Some(drag) = ctx.data(|d| d.get_temp::>(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::

(ctx, self.id); @@ -854,10 +858,10 @@ impl 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 index 54d37df6..11936502 100644 --- 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 {