1
0
mirror of https://github.com/emilk/egui.git synced 2026-09-02 14:50:03 -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:
Lucas Meurer
2026-07-31 14:30:46 +02:00
committed by GitHub
parent c69834e65a
commit 36341c21fe
8 changed files with 140 additions and 14 deletions

View File

@@ -454,14 +454,12 @@ impl Area {
state.size = None;
}
state.pivot = pivot;
state.interactable = interactable;
if let Some(new_pos) = new_pos {
state.pivot_pos = Some(new_pos);
}
state.pivot_pos.get_or_insert_with(|| {
default_pos.unwrap_or_else(|| automatic_area_position(ctx, constrain_rect, layer_id))
});
state.interactable = interactable;
let size = *state.size.get_or_insert_with(|| {
sizing_pass = true;
@@ -484,6 +482,10 @@ impl Area {
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
let visible_last_frame = ctx.memory(|mem| mem.areas().visible_last_frame(&layer_id));

View File

@@ -180,6 +180,7 @@ pub struct Popup<'a> {
/// Default width passed to the Area
width: Option<f32>,
sense: Sense,
interactable: bool,
layout: Layout,
frame: Option<Frame>,
style: StyleModifier,
@@ -202,6 +203,7 @@ impl<'a> Popup<'a> {
gap: 0.0,
width: None,
sense: Sense::click(),
interactable: true,
layout: Layout::default(),
frame: None,
style: StyleModifier::default(),
@@ -369,6 +371,15 @@ impl<'a> Popup<'a> {
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.
#[inline]
pub fn sense(mut self, sense: Sense) -> Self {
@@ -546,6 +557,7 @@ impl<'a> Popup<'a> {
gap,
width,
sense,
interactable,
layout,
frame,
style,
@@ -570,6 +582,7 @@ impl<'a> Popup<'a> {
.pivot(pivot)
.fixed_pos(anchor)
.sense(sense)
.interactable(interactable)
.layout(layout)
.sizing_pass(!was_open_last_frame)
.info(info.unwrap_or_else(|| {

View File

@@ -129,7 +129,15 @@ impl Tooltip<'_> {
});
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| {
// By default, the text in tooltips aren't selectable.
@@ -192,6 +200,20 @@ impl Tooltip<'_> {
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?
///
/// 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:
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
&& response.ctx.viewport(|vp| {
vp.prev_pass
.widgets
.get_layer(tooltip_layer_id)
.any(|w| w.enabled && w.sense.interactive())
});
&& Self::had_interactive_widgets(&response.ctx, tooltip_id);
if tooltip_has_interactive_widget {
// We keep the tooltip open if hovered,

View File

@@ -466,7 +466,13 @@ impl ContextImpl {
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));
viewport.hits = if let Some(pos) = viewport.input.pointer.interact_pos() {

View File

@@ -1194,6 +1194,11 @@ impl Areas {
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.
pub(crate) fn order(&self) -> &[LayerId] {
&self.order