fix(ui): end floating group drags on pointer exit

Discard a renderer-owned group gesture when its pointer disappears so a
missing outside release cannot resume dragging, while retaining touch drops.

Closes #95

Co-Authored-By: OpenAI Codex <noreply@openai.com>
This commit is contained in:
euisuhandOpenAI Codex committed 2026-10-14 16:22:25 +09:00
1 parent 457a078809
commit 9a6f159fc5
2 files changed
+109 -2

No files matched your search

+7 -2
View File
@@ -812,11 +812,16 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
}
let (escape, focused, down, released, pointer) =
ctx.input(|i| (i.key_pressed(Key::Escape), i.focused, i.pointer.primary_down(), i.pointer.primary_released(), i.pointer.interact_pos()));
if escape || !focused || !down {
ctx.data_mut(|d| d.remove::<GroupDrag<P>>(self.id.with("group-drag")));
// A button release outside the window may never reach egui.
let has_pointer = ctx.input(|i| i.pointer.has_pointer());
// Touch-up also removes the pointer, but still completes a drop.
let touch_ended = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Touch { phase: egui::TouchPhase::End, .. })));
if escape || !focused || !down || !has_pointer {
cancel_drag::<P>(ctx, self.id);
if escape || !focused {
self.output.actions.push(Action::MoveFloating { panel: drag.panel, rect: drag.original });
} else if released
&& (has_pointer || touch_ended)
&& let Some(pointer) = pointer.filter(|p| p.is_finite())
&& !self.blockers.iter().any(|r| r.contains(pointer))
{
+102
View File
@@ -643,6 +643,108 @@ fn floating_group_grip_moves_every_member_while_tab_drag_detaches_one() {
assert_eq!(layout.floating[0].panels, vec![6]);
}
#[test]
fn floating_group_drag_ends_on_release_or_pointer_exit() {
for (released, leaves) in [(false, true), (true, true), (true, false)] {
let ctx = Context::default();
let mut layout = sample();
for _ in 0..2 {
step(&ctx, &mut layout, vec![]);
}
let start = pos2(86.0, 82.0);
step(&ctx, &mut layout, vec![Event::PointerMoved(start), pointer_button(start, true)]);
let moved = start + vec2(50.0, 40.0);
let actions = step(&ctx, &mut layout, vec![Event::PointerMoved(moved)]);
assert!(actions.iter().any(|action| matches!(action, Action::MoveFloating { .. })));
let last = layout.clone();
let mut events = vec![];
if released {
events.push(pointer_button(moved, false));
}
if leaves {
events.push(Event::PointerGone);
}
let (out, _) = frame(&ctx, &layout, events, true);
assert_eq!(out.group_drop, if leaves { None } else { Some((5, moved)) });
for action in out.actions {
layout.apply(action).unwrap();
}
assert_eq!(layout, last);
for pointer in [moved + vec2(20.0, 20.0), moved + vec2(30.0, 30.0)] {
let (out, _) = frame(&ctx, &layout, vec![Event::PointerMoved(pointer)], true);
assert!(out.actions.is_empty(), "{out:?}");
assert!(out.group_drag.is_none() && out.group_drop.is_none());
}
assert!(out.group_drag.is_none());
assert_eq!(ctx.dragged_id(), None);
}
}
#[test]
fn floating_group_touch_release_reports_drop_after_pointer_gone() {
for web_order in [false, true] {
let ctx = Context::default();
let mut layout = sample();
for _ in 0..2 {
step(&ctx, &mut layout, vec![]);
}
let touch = |phase, pos| Event::Touch { device_id: egui::TouchDeviceId(0), id: egui::TouchId(1), phase, pos, force: None };
let start = pos2(86.0, 82.0);
let press = pointer_button(start, true);
let events = if web_order {
vec![press, touch(egui::TouchPhase::Start, start)]
} else {
vec![touch(egui::TouchPhase::Start, start), Event::PointerMoved(start), press]
};
step(&ctx, &mut layout, events);
let moved = start + vec2(50.0, 40.0);
let events = if web_order {
vec![Event::PointerMoved(moved), touch(egui::TouchPhase::Move, moved)]
} else {
vec![touch(egui::TouchPhase::Move, moved), Event::PointerMoved(moved)]
};
assert!(step(&ctx, &mut layout, events).iter().any(|action| matches!(action, Action::MoveFloating { .. })));
// egui-winit emits Touch before release/gone; eframe web emits it afterwards.
let events = if web_order {
vec![pointer_button(moved, false), Event::PointerGone, touch(egui::TouchPhase::End, moved)]
} else {
vec![touch(egui::TouchPhase::End, moved), pointer_button(moved, false), Event::PointerGone]
};
let (out, _) = frame(&ctx, &layout, events, true);
assert!(!ctx.input(|i| i.pointer.has_pointer()));
assert_eq!(out.group_drop, Some((5, moved)));
assert!(out.group_drag.is_none());
assert_eq!(ctx.dragged_id(), None);
assert!(step(&ctx, &mut layout, vec![Event::PointerMoved(moved + vec2(30.0, 30.0))]).is_empty());
}
}
#[test]
fn floating_group_exit_with_escape_or_focus_loss_restores_original_geometry() {
for escape in [false, true] {
let ctx = Context::default();
let mut layout = sample();
let original = layout.clone();
for _ in 0..2 {
step(&ctx, &mut layout, vec![]);
}
let start = pos2(86.0, 82.0);
step(&ctx, &mut layout, vec![Event::PointerMoved(start), pointer_button(start, true)]);
step(&ctx, &mut layout, vec![Event::PointerMoved(start + vec2(50.0, 40.0))]);
assert_ne!(layout.floating[0].rect, original.floating[0].rect);
let mut events = vec![Event::PointerGone];
if escape {
events.push(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
}
let (out, _) = frame(&ctx, &layout, events, escape);
assert!(out.group_drag.is_none() && out.group_drop.is_none());
for action in out.actions {
layout.apply(action).unwrap();
}
assert_eq!(layout.floating[0].rect, original.floating[0].rect);
}
}
struct CustomContent {
labels: Vec<String>,
headers: usize,