mirror of
https://github.com/emilk/egui.git
synced 2026-08-29 04:40:03 -04:00
Treat a press that leaves a widget as a drag (#8365)
A widget that senses both clicks and drags postpones the click-versus-drag decision until the pointer moves past `max_click_dist` or is held for `max_click_duration`. But a click has to be released *on* the widget — so once the pointer leaves, the gesture can only be a drag, and there is nothing left to wait for. This matters for widgets thinner than `max_click_dist` (6px), such as panel resize handles. The pointer leaves such a widget almost immediately, which hands the hover to whatever is underneath, while `dragged()` was not true yet. So a handle highlighting on `hovered() || dragged()` blinked out mid-gesture. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
187
tests/egui_tests/tests/test_click_or_drag.rs
Normal file
187
tests/egui_tests/tests/test_click_or_drag.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
//! Tests for how egui decides whether a press on a click-and-drag widget
|
||||
//! is a click or a drag.
|
||||
|
||||
use egui::{Id, InputOptions, Pos2, Rect, Sense, Style, Vec2};
|
||||
use egui_kittest::Harness;
|
||||
|
||||
/// How far the pointer may move before a press is decidedly a drag.
|
||||
fn max_click_dist() -> f32 {
|
||||
InputOptions::default().max_click_dist
|
||||
}
|
||||
|
||||
/// How far outside its rect a widget can still be hit.
|
||||
fn interact_radius() -> f32 {
|
||||
Style::default().interaction.interact_radius
|
||||
}
|
||||
|
||||
fn widget_id() -> Id {
|
||||
Id::new("click_and_drag")
|
||||
}
|
||||
|
||||
/// A harness with one click-and-drag widget of the given size at the top-left.
|
||||
///
|
||||
/// If `with_background`, a second click-and-drag widget covers the whole area
|
||||
/// _beneath_ it. That one matters: without something under the pointer to take
|
||||
/// over the hover, the first widget keeps it even after the pointer leaves.
|
||||
///
|
||||
/// Steps at 60Hz. The default `step_dt` of 0.25s would blow past
|
||||
/// `max_click_duration` within a couple of frames, turning every press into a
|
||||
/// drag before the distance rules get a chance to matter.
|
||||
fn harness_with_widget(size: Vec2, with_background: bool) -> Harness<'static, ()> {
|
||||
Harness::builder()
|
||||
.with_step_dt(1.0 / 60.0)
|
||||
.with_size(Vec2::new(300.0, 200.0))
|
||||
.build_ui(move |ui| {
|
||||
if with_background {
|
||||
// Allocated first, so it ends up _behind_ the widget under test.
|
||||
ui.interact(
|
||||
ui.max_rect(),
|
||||
Id::new("background"),
|
||||
Sense::click_and_drag(),
|
||||
);
|
||||
}
|
||||
let rect = Rect::from_min_size(ui.max_rect().min, size);
|
||||
ui.interact(rect, widget_id(), Sense::click_and_drag());
|
||||
})
|
||||
}
|
||||
|
||||
/// The widget's `(hovered, dragged)` as of the last completed pass.
|
||||
fn widget_state(harness: &Harness<'_, ()>) -> (bool, bool) {
|
||||
harness
|
||||
.ctx
|
||||
.read_response(widget_id())
|
||||
.map(|r| (r.hovered(), r.dragged()))
|
||||
.expect("the widget should have been registered")
|
||||
}
|
||||
|
||||
/// The widget's rect as of the last completed pass.
|
||||
fn widget_rect(harness: &Harness<'_, ()>) -> Rect {
|
||||
harness
|
||||
.ctx
|
||||
.read_response(widget_id())
|
||||
.expect("the widget should have been registered")
|
||||
.rect
|
||||
}
|
||||
|
||||
/// Press the primary button at `pos`, without releasing it.
|
||||
fn press_at(harness: &mut Harness<'_, ()>, pos: Pos2) {
|
||||
harness.hover_at(pos);
|
||||
harness.step();
|
||||
harness.drag_at(pos);
|
||||
harness.step();
|
||||
}
|
||||
|
||||
/// Once a release can no longer land on the widget, the press can no longer become
|
||||
/// a click, so it counts as a drag right away — without waiting for `max_click_dist`.
|
||||
///
|
||||
/// This matters for widgets thinner than `max_click_dist` (panel resize handles,
|
||||
/// say): waiting would leave them neither hovered nor dragged for a few frames,
|
||||
/// which shows up as a flickering highlight.
|
||||
#[test]
|
||||
fn press_that_leaves_a_thin_widget_becomes_a_drag_immediately() {
|
||||
let width = max_click_dist() / 2.0; // thinner than `max_click_dist`
|
||||
let mut harness = harness_with_widget(Vec2::new(width, 100.0), true);
|
||||
harness.step();
|
||||
|
||||
let grab = widget_rect(&harness).center();
|
||||
press_at(&mut harness, grab);
|
||||
|
||||
let (hovered, dragged) = widget_state(&harness);
|
||||
assert!(hovered && !dragged, "the press starts out undecided");
|
||||
|
||||
// Creep outward in 1px steps, never reaching `max_click_dist` —
|
||||
// if we did, `is_decidedly_dragging` would explain the drag on its own
|
||||
// and the test would prove nothing.
|
||||
let mut saw_drag = false;
|
||||
for step in 1..max_click_dist().ceil() as i32 {
|
||||
let offset = step as f32;
|
||||
harness.hover_at(Pos2::new(grab.x + offset, grab.y));
|
||||
harness.step();
|
||||
|
||||
let (hovered, dragged) = widget_state(&harness);
|
||||
saw_drag |= dragged;
|
||||
assert!(
|
||||
hovered || dragged,
|
||||
"at +{offset}px the widget was neither hovered nor dragged, \
|
||||
so anything highlighting on `hovered || dragged` would blink out"
|
||||
);
|
||||
}
|
||||
|
||||
assert!(
|
||||
saw_drag,
|
||||
"leaving the widget should have started a drag, even within max_click_dist"
|
||||
);
|
||||
}
|
||||
|
||||
/// While the pointer is still on the widget, a press stays undecided: hovered,
|
||||
/// but not yet dragged, so it can still become a click.
|
||||
#[test]
|
||||
fn press_inside_a_wide_widget_stays_undecided() {
|
||||
let mut harness = harness_with_widget(Vec2::new(100.0, 100.0), true);
|
||||
harness.step();
|
||||
|
||||
let grab = widget_rect(&harness).center();
|
||||
press_at(&mut harness, grab);
|
||||
|
||||
// A small twitch: inside the widget, and inside `max_click_dist`.
|
||||
harness.hover_at(Pos2::new(grab.x + max_click_dist() / 2.0, grab.y));
|
||||
harness.step();
|
||||
|
||||
let (hovered, dragged) = widget_state(&harness);
|
||||
assert!(hovered, "the pointer is still over the widget");
|
||||
assert!(
|
||||
!dragged,
|
||||
"a small twitch inside the widget should still be able to become a click"
|
||||
);
|
||||
}
|
||||
|
||||
/// A press just _outside_ the widget still hits it, thanks to `interact_radius`.
|
||||
/// The pointer hasn't moved at all, so this must not count as leaving the widget.
|
||||
#[test]
|
||||
fn press_just_outside_a_widget_stays_undecided() {
|
||||
// No background: we want the widget to win the hit-test from a distance.
|
||||
let mut harness = harness_with_widget(Vec2::new(100.0, 100.0), false);
|
||||
harness.step();
|
||||
|
||||
let rect = widget_rect(&harness);
|
||||
let offset = interact_radius() - 1.0;
|
||||
let grab = Pos2::new(rect.right() + offset, rect.center().y);
|
||||
press_at(&mut harness, grab);
|
||||
|
||||
let (hovered, dragged) = widget_state(&harness);
|
||||
assert!(
|
||||
hovered,
|
||||
"a press within interact_radius still hits the widget"
|
||||
);
|
||||
assert!(
|
||||
!dragged,
|
||||
"the pointer never moved, so this press must still be able to become a click"
|
||||
);
|
||||
}
|
||||
|
||||
/// A press and release inside the widget is still a click, not a drag.
|
||||
#[test]
|
||||
fn click_inside_a_widget_still_clicks() {
|
||||
let mut harness = harness_with_widget(Vec2::new(100.0, 100.0), true);
|
||||
harness.step();
|
||||
|
||||
let grab = widget_rect(&harness).center();
|
||||
press_at(&mut harness, grab);
|
||||
// Release without `drop_at`, which would also fire `PointerGone` and so
|
||||
// discard the click.
|
||||
harness.event(egui::Event::PointerButton {
|
||||
pos: grab,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: false,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
});
|
||||
harness.step();
|
||||
|
||||
assert!(
|
||||
harness
|
||||
.ctx
|
||||
.read_response(widget_id())
|
||||
.is_some_and(|r| r.clicked()),
|
||||
"press and release without moving should be a click"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user