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
@@ -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()] })@@ -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 {