Support interactive widgets in tooltips (#4596)

* Closes https://github.com/emilk/egui/issues/1010

### In short
You can now put interactive widgets, like buttons and hyperlinks, in an
tooltip using `on_hover_ui`. If you do, the tooltip will stay open as
long as the user hovers it.

There is a new demo for this in the egui demo app (egui.rs):


![interactive-tooltips](https://github.com/emilk/egui/assets/1148717/97335ba6-fa3e-40dd-9da0-1276a051dbf2)

### Design
Tooltips can now contain interactive widgets, such as buttons and links.
If they do, they will stay open when the user moves their pointer over
them.

Widgets that do not contain interactive widgets disappear as soon as you
no longer hover the underlying widget, just like before. This is so that
they won't annoy the user.

To ensure not all tooltips with text in them are considered interactive,
`selectable_labels` is `false` for tooltips contents by default. If you
want selectable text in tooltips, either change the `selectable_labels`
setting, or use `Label::selectable`.

```rs
ui.label("Hover me").on_hover_ui(|ui| {
    ui.style_mut().interaction.selectable_labels = true;
    ui.label("This text can be selected.");

    ui.add(egui::Label::new("This too.").selectable(true));
});
```

### Changes
* Layers in `Order::Tooltip` can now be interacted with
This commit is contained in:
Emil Ernerfeldt 2024-06-03 11:37:06 +02:00 committed by GitHub
parent 7b3752fde9
commit c0a9800d05
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 274 additions and 72 deletions

View File

@ -135,8 +135,15 @@ fn show_tooltip_at_avoid_dyn<'c, R>(
.pivot(pivot)
.fixed_pos(anchor)
.default_width(ctx.style().spacing.tooltip_width)
.interactable(false)
.interactable(false) // Only affects the actual area, i.e. clicking and dragging it. The content can still be interactive.
.show(ctx, |ui| {
// By default the text in tooltips aren't selectable.
// This means that most tooltips aren't interactable,
// which also mean they won't stick around so you can click them.
// Only tooltips that have actual interactive stuff (buttons, links, …)
// will stick around when you try to click them.
ui.style_mut().interaction.selectable_labels = false;
Frame::popup(&ctx.style()).show_dyn(ui, add_contents).inner
});
@ -147,7 +154,18 @@ fn show_tooltip_at_avoid_dyn<'c, R>(
inner
}
fn tooltip_id(widget_id: Id, tooltip_count: usize) -> Id {
/// What is the id of the next tooltip for this widget?
pub fn next_tooltip_id(ctx: &Context, widget_id: Id) -> Id {
let tooltip_count = ctx.frame_state(|fs| {
fs.tooltip_state
.widget_tooltips
.get(&widget_id)
.map_or(0, |state| state.tooltip_count)
});
tooltip_id(widget_id, tooltip_count)
}
pub fn tooltip_id(widget_id: Id, tooltip_count: usize) -> Id {
widget_id.with(tooltip_count)
}

View File

@ -198,36 +198,39 @@ impl ContextImpl {
// ----------------------------------------------------------------------------
/// State stored per viewport
/// State stored per viewport.
///
/// Mostly for internal use.
/// Things here may move and change without warning.
#[derive(Default)]
struct ViewportState {
pub struct ViewportState {
/// The type of viewport.
///
/// This will never be [`ViewportClass::Embedded`],
/// since those don't result in real viewports.
class: ViewportClass,
pub class: ViewportClass,
/// The latest delta
builder: ViewportBuilder,
pub builder: ViewportBuilder,
/// The user-code that shows the GUI, used for deferred viewports.
///
/// `None` for immediate viewports.
viewport_ui_cb: Option<Arc<DeferredViewportUiCallback>>,
pub viewport_ui_cb: Option<Arc<DeferredViewportUiCallback>>,
input: InputState,
pub input: InputState,
/// State that is collected during a frame and then cleared
frame_state: FrameState,
pub frame_state: FrameState,
/// Has this viewport been updated this frame?
used: bool,
pub used: bool,
/// Written to during the frame.
widgets_this_frame: WidgetRects,
pub widgets_this_frame: WidgetRects,
/// Read
widgets_prev_frame: WidgetRects,
pub widgets_prev_frame: WidgetRects,
/// State related to repaint scheduling.
repaint: ViewportRepaintInfo,
@ -236,20 +239,20 @@ struct ViewportState {
// Updated at the start of the frame:
//
/// Which widgets are under the pointer?
hits: WidgetHits,
pub hits: WidgetHits,
/// What widgets are being interacted with this frame?
///
/// Based on the widgets from last frame, and input in this frame.
interact_widgets: InteractionSnapshot,
pub interact_widgets: InteractionSnapshot,
// ----------------------
// The output of a frame:
//
graphics: GraphicLayers,
pub graphics: GraphicLayers,
// Most of the things in `PlatformOutput` are not actually viewport dependent.
output: PlatformOutput,
commands: Vec<ViewportCommand>,
pub output: PlatformOutput,
pub commands: Vec<ViewportCommand>,
}
/// What called [`Context::request_repaint`]?
@ -3092,6 +3095,20 @@ impl Context {
self.read(|ctx| ctx.parent_viewport_id())
}
/// Read the state of the current viewport.
pub fn viewport<R>(&self, reader: impl FnOnce(&ViewportState) -> R) -> R {
self.write(|ctx| reader(ctx.viewport()))
}
/// Read the state of a specific current viewport.
pub fn viewport_for<R>(
&self,
viewport_id: ViewportId,
reader: impl FnOnce(&ViewportState) -> R,
) -> R {
self.write(|ctx| reader(ctx.viewport_for(viewport_id)))
}
/// For integrations: Set this to render a sync viewport.
///
/// This will only set the callback for the current thread,

View File

@ -1,18 +1,18 @@
use crate::{id::IdSet, *};
#[derive(Clone, Debug, Default)]
pub(crate) struct TooltipFrameState {
pub struct TooltipFrameState {
pub widget_tooltips: IdMap<PerWidgetTooltipState>,
}
impl TooltipFrameState {
pub(crate) fn clear(&mut self) {
pub fn clear(&mut self) {
self.widget_tooltips.clear();
}
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct PerWidgetTooltipState {
pub struct PerWidgetTooltipState {
/// Bounding rectangle for all widget and all previous tooltips.
pub bounding_rect: Rect,
@ -22,37 +22,37 @@ pub(crate) struct PerWidgetTooltipState {
#[cfg(feature = "accesskit")]
#[derive(Clone)]
pub(crate) struct AccessKitFrameState {
pub(crate) node_builders: IdMap<accesskit::NodeBuilder>,
pub(crate) parent_stack: Vec<Id>,
pub struct AccessKitFrameState {
pub node_builders: IdMap<accesskit::NodeBuilder>,
pub parent_stack: Vec<Id>,
}
/// State that is collected during a frame and then cleared.
/// Short-term (single frame) memory.
#[derive(Clone)]
pub(crate) struct FrameState {
pub struct FrameState {
/// All [`Id`]s that were used this frame.
pub(crate) used_ids: IdMap<Rect>,
pub used_ids: IdMap<Rect>,
/// Starts off as the `screen_rect`, shrinks as panels are added.
/// The [`CentralPanel`] does not change this.
/// This is the area available to Window's.
pub(crate) available_rect: Rect,
pub available_rect: Rect,
/// Starts off as the `screen_rect`, shrinks as panels are added.
/// The [`CentralPanel`] retracts from this.
pub(crate) unused_rect: Rect,
pub unused_rect: Rect,
/// How much space is used by panels.
pub(crate) used_by_panels: Rect,
pub used_by_panels: Rect,
/// If a tooltip has been shown this frame, where was it?
/// This is used to prevent multiple tooltips to cover each other.
/// Reset at the start of each frame.
pub(crate) tooltip_state: TooltipFrameState,
pub tooltip_state: TooltipFrameState,
/// The current scroll area should scroll to this range (horizontal, vertical).
pub(crate) scroll_target: [Option<(Rangef, Option<Align>)>; 2],
pub scroll_target: [Option<(Rangef, Option<Align>)>; 2],
/// The current scroll area should scroll by this much.
///
@ -63,19 +63,19 @@ pub(crate) struct FrameState {
///
/// A positive Y-value indicates the content is being moved down,
/// as when swiping down on a touch-screen or track-pad with natural scrolling.
pub(crate) scroll_delta: Vec2,
pub scroll_delta: Vec2,
#[cfg(feature = "accesskit")]
pub(crate) accesskit_state: Option<AccessKitFrameState>,
pub accesskit_state: Option<AccessKitFrameState>,
/// Highlight these widgets this next frame. Read from this.
pub(crate) highlight_this_frame: IdSet,
pub highlight_this_frame: IdSet,
/// Highlight these widgets the next frame. Write to this.
pub(crate) highlight_next_frame: IdSet,
pub highlight_next_frame: IdSet,
#[cfg(debug_assertions)]
pub(crate) has_debug_viewed_this_frame: bool,
pub has_debug_viewed_this_frame: bool,
}
impl Default for FrameState {

View File

@ -48,8 +48,8 @@ impl Order {
| Self::PanelResizeLine
| Self::Middle
| Self::Foreground
| Self::Tooltip
| Self::Debug => true,
Self::Tooltip => false,
}
}

View File

@ -2,8 +2,8 @@ use std::{any::Any, sync::Arc};
use crate::{
emath::{Align, Pos2, Rect, Vec2},
menu, ComboBox, Context, CursorIcon, Id, LayerId, PointerButton, Sense, Ui, WidgetRect,
WidgetText,
menu, AreaState, ComboBox, Context, CursorIcon, Id, LayerId, Order, PointerButton, Sense, Ui,
WidgetRect, WidgetText,
};
// ----------------------------------------------------------------------------
@ -520,6 +520,20 @@ impl Response {
/// For that, use [`Self::on_disabled_hover_ui`] instead.
///
/// If you call this multiple times the tooltips will stack underneath the previous ones.
///
/// The widget can contain interactive widgets, such as buttons and links.
/// If so, it will stay open as the user moves their pointer over it.
/// By default, the text of a tooltip is NOT selectable (i.e. interactive),
/// but you can change this by setting [`style::Interaction::selectable_labels` from within the tooltip:
///
/// ```
/// # egui::__run_test_ui(|ui| {
/// ui.label("Hover me").on_hover_ui(|ui| {
/// ui.style_mut().interaction.selectable_labels = true;
/// ui.label("This text can be selected");
/// });
/// # });
/// ```
#[doc(alias = "tooltip")]
pub fn on_hover_ui(self, add_contents: impl FnOnce(&mut Ui)) -> Self {
if self.enabled && self.should_show_hover_ui() {
@ -570,6 +584,41 @@ impl Response {
return true;
}
let is_tooltip_open = self.is_tooltip_open();
if is_tooltip_open {
let tooltip_id = crate::next_tooltip_id(&self.ctx, self.id);
let layer_id = LayerId::new(Order::Tooltip, tooltip_id);
let tooltip_has_interactive_widget = self.ctx.viewport(|vp| {
vp.widgets_prev_frame
.get_layer(layer_id)
.any(|w| w.sense.interactive())
});
if tooltip_has_interactive_widget {
// We keep the tooltip open if hovered,
// or if the pointer is on its way to it,
// so that the user can interact with the tooltip
// (i.e. click links that are in it).
if let Some(area) = AreaState::load(&self.ctx, tooltip_id) {
let rect = area.rect();
let pointer_in_area_or_on_the_way_there = self.ctx.input(|i| {
if let Some(pos) = i.pointer.hover_pos() {
rect.contains(pos)
|| rect.intersects_ray(pos, i.pointer.velocity().normalized())
} else {
false
}
});
if pointer_in_area_or_on_the_way_there {
return true;
}
}
}
}
// Fast early-outs:
if self.enabled {
if !self.hovered || !self.ctx.input(|i| i.pointer.has_pointer()) {
@ -605,7 +654,7 @@ impl Response {
let tooltip_was_recently_shown = when_was_a_toolip_last_shown
.map_or(false, |time| ((now - time) as f32) < tooltip_grace_time);
if !tooltip_was_recently_shown && !self.is_tooltip_open() {
if !tooltip_was_recently_shown && !is_tooltip_open {
if self.ctx.style().interaction.show_tooltips_only_when_still {
// We only show the tooltip when the mouse pointer is still.
if !self.ctx.input(|i| i.pointer.is_still()) {

View File

@ -1092,7 +1092,7 @@ impl Default for Spacing {
icon_width_inner: 8.0,
icon_spacing: 4.0,
default_area_size: vec2(600.0, 400.0),
tooltip_width: 600.0,
tooltip_width: 500.0,
menu_width: 400.0,
menu_spacing: 2.0,
combo_height: 200.0,

View File

@ -42,6 +42,7 @@ impl Default for Demos {
Box::<super::table_demo::TableDemo>::default(),
Box::<super::text_edit::TextEditDemo>::default(),
Box::<super::text_layout::TextLayoutDemo>::default(),
Box::<super::tooltips::Tooltips>::default(),
Box::<super::widget_gallery::WidgetGallery>::default(),
Box::<super::window_options::WindowOptions>::default(),
Box::<super::tests::WindowResizeTest>::default(),

View File

@ -233,7 +233,6 @@ fn label_ui(ui: &mut egui::Ui) {
#[cfg_attr(feature = "serde", serde(default))]
pub struct Widgets {
angle: f32,
enabled: bool,
password: String,
}
@ -241,7 +240,6 @@ impl Default for Widgets {
fn default() -> Self {
Self {
angle: std::f32::consts::TAU / 3.0,
enabled: true,
password: "hunter2".to_owned(),
}
}
@ -249,38 +247,11 @@ impl Default for Widgets {
impl Widgets {
pub fn ui(&mut self, ui: &mut Ui) {
let Self {
angle,
enabled,
password,
} = self;
let Self { angle, password } = self;
ui.vertical_centered(|ui| {
ui.add(crate::egui_github_link_file_line!());
});
let tooltip_ui = |ui: &mut Ui| {
ui.heading("The name of the tooltip");
ui.horizontal(|ui| {
ui.label("This tooltip was created with");
ui.monospace(".on_hover_ui(…)");
});
let _ = ui.button("A button you can never press");
};
let disabled_tooltip_ui = |ui: &mut Ui| {
ui.heading("Different tooltip when widget is disabled");
ui.horizontal(|ui| {
ui.label("This tooltip was created with");
ui.monospace(".on_disabled_hover_ui(…)");
});
};
ui.checkbox(enabled, "Enabled");
ui.add_enabled(
*enabled,
egui::Label::new("Tooltips can be more than just simple text."),
)
.on_hover_ui(tooltip_ui)
.on_disabled_hover_ui(disabled_tooltip_ui);
ui.separator();
ui.horizontal(|ui| {

View File

@ -32,6 +32,7 @@ pub mod tests;
pub mod text_edit;
pub mod text_layout;
pub mod toggle_switch;
pub mod tooltips;
pub mod widget_gallery;
pub mod window_options;

View File

@ -0,0 +1,85 @@
#[derive(Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct Tooltips {
enabled: bool,
}
impl Default for Tooltips {
fn default() -> Self {
Self { enabled: true }
}
}
impl super::Demo for Tooltips {
fn name(&self) -> &'static str {
"🗖 Tooltips"
}
fn show(&mut self, ctx: &egui::Context, open: &mut bool) {
use super::View as _;
let window = egui::Window::new("Tooltips")
.constrain(false) // So we can test how tooltips behave close to the screen edge
.resizable(false)
.scroll(false)
.open(open);
window.show(ctx, |ui| self.ui(ui));
}
}
impl super::View for Tooltips {
fn ui(&mut self, ui: &mut egui::Ui) {
ui.spacing_mut().item_spacing.y = 8.0;
ui.vertical_centered(|ui| {
ui.add(crate::egui_github_link_file_line!());
});
ui.label("All labels in this demo have tooltips.")
.on_hover_text("Yes, even this one.");
ui.label("Some widgets have multiple tooltips!")
.on_hover_text("The first tooltip.")
.on_hover_text("The second tooltip.");
ui.label("Tooltips can contain interactive widgets.")
.on_hover_ui(|ui| {
ui.label("This tooltip contains a link:");
ui.hyperlink_to("www.egui.rs", "https://www.egui.rs/")
.on_hover_text("The tooltip has a tooltip in it!");
});
ui.label("You can put selectable text in tooltips too.")
.on_hover_ui(|ui| {
ui.style_mut().interaction.selectable_labels = true;
ui.label("You can select this text.");
});
ui.separator(); // ---------------------------------------------------------
let tooltip_ui = |ui: &mut egui::Ui| {
ui.horizontal(|ui| {
ui.label("This tooltip was created with");
ui.code(".on_hover_ui(…)");
});
};
let disabled_tooltip_ui = |ui: &mut egui::Ui| {
ui.label("A fifferent tooltip when widget is disabled.");
ui.horizontal(|ui| {
ui.label("This tooltip was created with");
ui.code(".on_disabled_hover_ui(…)");
});
};
ui.label("You can have different tooltips depending on whether or not a widget is enabled or not:")
.on_hover_text("Check the tooltip of the button below, and see how it changes dependning on whether or not it is enabled.");
ui.horizontal(|ui| {
ui.checkbox(&mut self.enabled, "Enabled")
.on_hover_text("Controls whether or not the button below is enabled.");
ui.add_enabled(self.enabled, egui::Button::new("Sometimes clickable"))
.on_hover_ui(tooltip_ui)
.on_disabled_hover_ui(disabled_tooltip_ui);
});
}
}

View File

@ -8,9 +8,56 @@ fn main() -> eframe::Result<()> {
let options = eframe::NativeOptions::default();
eframe::run_simple_native("My egui App", options, move |ctx, _frame| {
// A bottom panel to force the tooltips to consider if the fit below or under the widget:
egui::TopBottomPanel::bottom("bottom").show(ctx, |ui| {
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.label("Single tooltips:");
for i in 0..3 {
ui.label(format!("Hover label {i} for a tooltip"))
.on_hover_text("There is some text here");
}
});
ui.vertical(|ui| {
ui.label("Double tooltips:");
for i in 0..3 {
ui.label(format!("Hover label {i} for two tooltips"))
.on_hover_text("First tooltip")
.on_hover_text("Second tooltip");
}
});
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::BOTTOM), |ui| {
ui.label("Hover for tooltip")
.on_hover_text("This is a rather long tooltip that needs careful positioning.");
});
});
egui::CentralPanel::default().show(ctx, |ui| {
if ui.button("Reset egui memory").clicked() {
ctx.memory_mut(|mem| *mem = Default::default());
ui.horizontal(|ui| {
if ui.button("Reset egui memory").clicked() {
ctx.memory_mut(|mem| *mem = Default::default());
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::BOTTOM), |ui| {
ui.label("Hover for tooltip").on_hover_text(
"This is a rather long tooltip that needs careful positioning.",
);
ui.label("Hover for interactive tooltip").on_hover_ui(|ui| {
ui.label("This tooltip has a button:");
let _ = ui.button("Clicking me does nothing");
});
});
});
let has_tooltip = ui
.label("This label has a tooltip at the mouse cursor")
.on_hover_text_at_pointer("Told you!")
.is_tooltip_open();
let response = ui.label("This label gets a tooltip when the previous label is hovered");
if has_tooltip {
response.show_tooltip_text("The ever-present tooltip!");
}
ui.separator();
@ -43,6 +90,19 @@ fn main() -> eframe::Result<()> {
alternatives.len(),
|i| alternatives[i],
);
egui::ComboBox::from_id_source("combo")
.selected_text("ComboBox")
.width(100.0)
.show_ui(ui, |ui| {
ui.ctx()
.debug_painter()
.debug_rect(ui.max_rect(), egui::Color32::RED, "");
ui.label("Hello");
ui.label("World");
ui.label("Hellooooooooooooooooooooooooo");
});
});
})
}