mirror of
https://github.com/emilk/egui.git
synced 2026-09-02 23:00:04 -04:00
Make non-interactive tooltips not interactable (#8362)
Fixes two bugs around tooltips: - During sizing pass tooltips would render at a different size, at which point they might overlap the interacted widget causing the hover state to change and the tooltip to never be shown - Fix: During sizing pass make the area `interactable: false` so events pass through - When hovering close to the boarder of a widget (in the area where `interact_radius` takes effect), there could be a feedback loop where the tooltip is shown one frame and hidden the next. - Fix: Always make tooltips `interactable: false` when they don't have interactive contents This PR changes the behavior of `Area::interactable` to it's original behavior: Now interactions will pass through the area background _and_ it's containing widgets. It worked this way initially but got changed in #4026 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -454,14 +454,12 @@ impl Area {
|
|||||||
state.size = None;
|
state.size = None;
|
||||||
}
|
}
|
||||||
state.pivot = pivot;
|
state.pivot = pivot;
|
||||||
state.interactable = interactable;
|
|
||||||
if let Some(new_pos) = new_pos {
|
if let Some(new_pos) = new_pos {
|
||||||
state.pivot_pos = Some(new_pos);
|
state.pivot_pos = Some(new_pos);
|
||||||
}
|
}
|
||||||
state.pivot_pos.get_or_insert_with(|| {
|
state.pivot_pos.get_or_insert_with(|| {
|
||||||
default_pos.unwrap_or_else(|| automatic_area_position(ctx, constrain_rect, layer_id))
|
default_pos.unwrap_or_else(|| automatic_area_position(ctx, constrain_rect, layer_id))
|
||||||
});
|
});
|
||||||
state.interactable = interactable;
|
|
||||||
|
|
||||||
let size = *state.size.get_or_insert_with(|| {
|
let size = *state.size.get_or_insert_with(|| {
|
||||||
sizing_pass = true;
|
sizing_pass = true;
|
||||||
@@ -484,6 +482,10 @@ impl Area {
|
|||||||
size
|
size
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// We should never be interactable during a sizing pass, since then we are shown at a different
|
||||||
|
// size which might interfere with hover state of the hovered widget causing popup feedback loops.
|
||||||
|
state.interactable = interactable && !sizing_pass;
|
||||||
|
|
||||||
// TODO(emilk): if last frame was sizing pass, it should be considered invisible for smoother fade-in
|
// TODO(emilk): if last frame was sizing pass, it should be considered invisible for smoother fade-in
|
||||||
let visible_last_frame = ctx.memory(|mem| mem.areas().visible_last_frame(&layer_id));
|
let visible_last_frame = ctx.memory(|mem| mem.areas().visible_last_frame(&layer_id));
|
||||||
|
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ pub struct Popup<'a> {
|
|||||||
/// Default width passed to the Area
|
/// Default width passed to the Area
|
||||||
width: Option<f32>,
|
width: Option<f32>,
|
||||||
sense: Sense,
|
sense: Sense,
|
||||||
|
interactable: bool,
|
||||||
layout: Layout,
|
layout: Layout,
|
||||||
frame: Option<Frame>,
|
frame: Option<Frame>,
|
||||||
style: StyleModifier,
|
style: StyleModifier,
|
||||||
@@ -202,6 +203,7 @@ impl<'a> Popup<'a> {
|
|||||||
gap: 0.0,
|
gap: 0.0,
|
||||||
width: None,
|
width: None,
|
||||||
sense: Sense::click(),
|
sense: Sense::click(),
|
||||||
|
interactable: true,
|
||||||
layout: Layout::default(),
|
layout: Layout::default(),
|
||||||
frame: None,
|
frame: None,
|
||||||
style: StyleModifier::default(),
|
style: StyleModifier::default(),
|
||||||
@@ -369,6 +371,15 @@ impl<'a> Popup<'a> {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// If `false`, the pointer goes straight through the popup and it's widgets to whatever is behind it.
|
||||||
|
///
|
||||||
|
/// Default: `true`.
|
||||||
|
#[inline]
|
||||||
|
pub fn interactable(mut self, interactable: bool) -> Self {
|
||||||
|
self.interactable = interactable;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Set the sense of the popup.
|
/// Set the sense of the popup.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn sense(mut self, sense: Sense) -> Self {
|
pub fn sense(mut self, sense: Sense) -> Self {
|
||||||
@@ -546,6 +557,7 @@ impl<'a> Popup<'a> {
|
|||||||
gap,
|
gap,
|
||||||
width,
|
width,
|
||||||
sense,
|
sense,
|
||||||
|
interactable,
|
||||||
layout,
|
layout,
|
||||||
frame,
|
frame,
|
||||||
style,
|
style,
|
||||||
@@ -570,6 +582,7 @@ impl<'a> Popup<'a> {
|
|||||||
.pivot(pivot)
|
.pivot(pivot)
|
||||||
.fixed_pos(anchor)
|
.fixed_pos(anchor)
|
||||||
.sense(sense)
|
.sense(sense)
|
||||||
|
.interactable(interactable)
|
||||||
.layout(layout)
|
.layout(layout)
|
||||||
.sizing_pass(!was_open_last_frame)
|
.sizing_pass(!was_open_last_frame)
|
||||||
.info(info.unwrap_or_else(|| {
|
.info(info.unwrap_or_else(|| {
|
||||||
|
|||||||
@@ -129,7 +129,15 @@ impl Tooltip<'_> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let tooltip_area_id = Self::tooltip_id(parent_widget, state.tooltip_count);
|
let tooltip_area_id = Self::tooltip_id(parent_widget, state.tooltip_count);
|
||||||
popup = popup.anchor(state.bounding_rect).id(tooltip_area_id);
|
|
||||||
|
// Tooltips without interactive contents should not be interactable (hover should pass
|
||||||
|
// through to the widget below).
|
||||||
|
let interactable = Self::had_interactive_widgets(popup.ctx(), tooltip_area_id);
|
||||||
|
|
||||||
|
popup = popup
|
||||||
|
.anchor(state.bounding_rect)
|
||||||
|
.id(tooltip_area_id)
|
||||||
|
.interactable(interactable);
|
||||||
|
|
||||||
let response = popup.show(|ui| {
|
let response = popup.show(|ui| {
|
||||||
// By default, the text in tooltips aren't selectable.
|
// By default, the text in tooltips aren't selectable.
|
||||||
@@ -192,6 +200,20 @@ impl Tooltip<'_> {
|
|||||||
widget_id.with(tooltip_count)
|
widget_id.with(tooltip_count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Did this tooltip contain anything the user can interact with, last pass?
|
||||||
|
///
|
||||||
|
/// Most tooltips are just text. Those should not react to the pointer at all,
|
||||||
|
/// or they would steal the hover from the widget they belong to.
|
||||||
|
fn had_interactive_widgets(ctx: &Context, tooltip_id: Id) -> bool {
|
||||||
|
let tooltip_layer_id = LayerId::new(Order::Tooltip, tooltip_id);
|
||||||
|
ctx.viewport(|vp| {
|
||||||
|
vp.prev_pass
|
||||||
|
.widgets
|
||||||
|
.get_layer(tooltip_layer_id)
|
||||||
|
.any(|w| w.enabled && w.sense.interactive())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Should we show a tooltip for this response?
|
/// Should we show a tooltip for this response?
|
||||||
///
|
///
|
||||||
/// Argument `allow_interactive_tooltip` controls whether mouse can interact with tooltip that
|
/// Argument `allow_interactive_tooltip` controls whether mouse can interact with tooltip that
|
||||||
@@ -247,15 +269,9 @@ impl Tooltip<'_> {
|
|||||||
// Check if we should automatically stay open:
|
// Check if we should automatically stay open:
|
||||||
|
|
||||||
let tooltip_id = Self::next_tooltip_id(&response.ctx, response.id);
|
let tooltip_id = Self::next_tooltip_id(&response.ctx, response.id);
|
||||||
let tooltip_layer_id = LayerId::new(Order::Tooltip, tooltip_id);
|
|
||||||
|
|
||||||
let tooltip_has_interactive_widget = allow_interactive_tooltip
|
let tooltip_has_interactive_widget = allow_interactive_tooltip
|
||||||
&& response.ctx.viewport(|vp| {
|
&& Self::had_interactive_widgets(&response.ctx, tooltip_id);
|
||||||
vp.prev_pass
|
|
||||||
.widgets
|
|
||||||
.get_layer(tooltip_layer_id)
|
|
||||||
.any(|w| w.enabled && w.sense.interactive())
|
|
||||||
});
|
|
||||||
|
|
||||||
if tooltip_has_interactive_widget {
|
if tooltip_has_interactive_widget {
|
||||||
// We keep the tooltip open if hovered,
|
// We keep the tooltip open if hovered,
|
||||||
|
|||||||
@@ -466,7 +466,13 @@ impl ContextImpl {
|
|||||||
viewport.this_pass.begin_pass();
|
viewport.this_pass.begin_pass();
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut layers: Vec<LayerId> = viewport.prev_pass.widgets.layer_ids().collect();
|
// Areas that are not interactable are click-through: skip them in the hit-test.
|
||||||
|
let mut layers: Vec<LayerId> = viewport
|
||||||
|
.prev_pass
|
||||||
|
.widgets
|
||||||
|
.layer_ids()
|
||||||
|
.filter(|layer_id| self.memory.areas().is_interactable(*layer_id))
|
||||||
|
.collect();
|
||||||
layers.sort_by(|&a, &b| self.memory.areas().compare_order(a, b));
|
layers.sort_by(|&a, &b| self.memory.areas().compare_order(a, b));
|
||||||
|
|
||||||
viewport.hits = if let Some(pos) = viewport.input.pointer.interact_pos() {
|
viewport.hits = if let Some(pos) = viewport.input.pointer.interact_pos() {
|
||||||
|
|||||||
@@ -1194,6 +1194,11 @@ impl Areas {
|
|||||||
self.areas.get_mut(&id)
|
self.areas.get_mut(&id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Can the user interact with this layer or it's widgets, or do clicks go straight through it?
|
||||||
|
pub(crate) fn is_interactable(&self, layer_id: LayerId) -> bool {
|
||||||
|
self.get(layer_id.id).is_none_or(|area| area.interactable)
|
||||||
|
}
|
||||||
|
|
||||||
/// All layers back-to-front, top is last.
|
/// All layers back-to-front, top is last.
|
||||||
pub(crate) fn order(&self) -> &[LayerId] {
|
pub(crate) fn order(&self) -> &[LayerId] {
|
||||||
&self.order
|
&self.order
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use egui::ScrollArea;
|
|
||||||
use egui::accesskit::Role;
|
use egui::accesskit::Role;
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
use egui::epaint::Shape;
|
use egui::epaint::Shape;
|
||||||
use egui::style::ScrollAnimation;
|
use egui::style::ScrollAnimation;
|
||||||
use egui::text::{LayoutJob, TextWrapping};
|
use egui::text::{LayoutJob, TextWrapping};
|
||||||
use egui::{
|
use egui::{
|
||||||
Align, Button, Color32, FontFamily, FontId, Image, Label, Layout, RichText, Sense, TextBuffer,
|
Align, Button, Color32, FontFamily, FontId, Image, Label, Layout, Rect, RichText, Sense,
|
||||||
TextFormat, TextWrapMode, Ui, include_image, vec2,
|
TextBuffer, TextFormat, TextWrapMode, Ui, Vec2, include_image, vec2,
|
||||||
};
|
};
|
||||||
|
use egui::{Pos2, ScrollArea};
|
||||||
use egui_kittest::Harness;
|
use egui_kittest::Harness;
|
||||||
use egui_kittest::kittest::{NodeT as _, Queryable as _};
|
use egui_kittest::kittest::{NodeT as _, Queryable as _};
|
||||||
|
|
||||||
@@ -481,3 +481,81 @@ fn animated_scroll_beats_sticky_bottom() {
|
|||||||
"animated explicit scroll should leave the sticky bottom"
|
"animated explicit scroll should leave the sticky bottom"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tests that tooltips are shown correctly for buttons that are only shown on hover.
|
||||||
|
///
|
||||||
|
/// Basically, this tests that a tooltip overlapping the mouse cursor does not interfere with a
|
||||||
|
/// buttons hover state.
|
||||||
|
#[test]
|
||||||
|
fn tooltip_should_work_for_hover_button() {
|
||||||
|
let button_rect = Rect::from_min_size(Pos2::new(4.0, 4.0), Vec2::new(80.0, 20.0));
|
||||||
|
let mut harness = Harness::builder().with_size((320.0, 80.0)).build_ui(|ui| {
|
||||||
|
if ui.rect_contains_pointer(button_rect) {
|
||||||
|
ui.button("A tooltip should be shown")
|
||||||
|
.on_hover_text("My tooltip");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
harness.hover_at(button_rect.center());
|
||||||
|
|
||||||
|
harness.run();
|
||||||
|
|
||||||
|
harness.snapshot("test_tooltip_hover_regression");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure that hovering close to a widget doesn't cause a tooltip feedback loop (due to a
|
||||||
|
/// difference between `hovered` and `contains_pointer` caused by the interact radius).
|
||||||
|
#[test]
|
||||||
|
fn tooltip_covering_button_should_not_cause_feedback_loop() {
|
||||||
|
let mut harness = Harness::builder().with_size((200.0, 30.0)).build_ui(|ui| {
|
||||||
|
ui.button("A tooltip should be shown")
|
||||||
|
.on_hover_text("This tooltip is larger than the button");
|
||||||
|
});
|
||||||
|
|
||||||
|
harness.hover_at(
|
||||||
|
harness
|
||||||
|
.get_by_label("A tooltip should be shown")
|
||||||
|
.rect()
|
||||||
|
.left_center()
|
||||||
|
- Vec2::X,
|
||||||
|
);
|
||||||
|
|
||||||
|
harness.run();
|
||||||
|
|
||||||
|
harness.snapshot("tooltip_covering_button_should_not_cause_feedback_loop");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tests that a tooltip closes when the pointer moves onto a neighboring widget,
|
||||||
|
/// so that the neighbor can show its own tooltip.
|
||||||
|
///
|
||||||
|
/// The two buttons are only `item_spacing.y` (3 pt) apart, which is less than the
|
||||||
|
/// hit-test `interact_radius` (5 pt), so the first button is still close enough to
|
||||||
|
/// interact with when the pointer is on the second one.
|
||||||
|
#[test]
|
||||||
|
fn tooltip_should_hand_over_to_neighboring_widget() {
|
||||||
|
let mut harness = Harness::builder().with_size((300.0, 200.0)).build_ui(|ui| {
|
||||||
|
ui.button("Button A").on_hover_text("Tooltip A");
|
||||||
|
ui.button("Button B").on_hover_text("Tooltip B");
|
||||||
|
});
|
||||||
|
|
||||||
|
let a_rect = harness.get_by_label("Button A").rect();
|
||||||
|
let b_rect = harness.get_by_label("Button B").rect();
|
||||||
|
|
||||||
|
harness.hover_at(a_rect.center_bottom() - Vec2::Y);
|
||||||
|
harness.run();
|
||||||
|
assert!(
|
||||||
|
harness.query_by_label("Tooltip A").is_some(),
|
||||||
|
"Tooltip A should be shown when hovering Button A"
|
||||||
|
);
|
||||||
|
|
||||||
|
harness.hover_at(b_rect.center_top() + Vec2::Y);
|
||||||
|
harness.run();
|
||||||
|
assert!(
|
||||||
|
harness.query_by_label("Tooltip B").is_some(),
|
||||||
|
"Tooltip B should be shown when hovering Button B"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
harness.query_by_label("Tooltip A").is_none(),
|
||||||
|
"Tooltip A should be hidden when hovering Button B"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:59bef0e3593896985988c03171c710d8ed31ed8bb89c7a3f63c060c573e4fb74
|
||||||
|
size 6510
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:ed2f80921ab864b0df3303834a8ab943a3aa5e10456896991c2dca81be0c4968
|
||||||
|
size 3968
|
||||||
Reference in New Issue
Block a user