Skip to content

Commit

Permalink
Support interactive widgets in tooltips (#4596)
Browse files Browse the repository at this point in the history
* Closes #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
  • Loading branch information
emilk authored Jun 3, 2024
1 parent 7b3752f commit c0a9800
Show file tree
Hide file tree
Showing 11 changed files with 274 additions and 72 deletions.
22 changes: 20 additions & 2 deletions crates/egui/src/containers/popup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
});

Expand All @@ -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)
}

Expand Down
47 changes: 32 additions & 15 deletions crates/egui/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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`]?
Expand Down Expand Up @@ -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,
Expand Down
36 changes: 18 additions & 18 deletions crates/egui/src/frame_state.rs
Original file line number Diff line number Diff line change
@@ -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,

Expand All @@ -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.
///
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion crates/egui/src/layers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ impl Order {
| Self::PanelResizeLine
| Self::Middle
| Self::Foreground
| Self::Tooltip
| Self::Debug => true,
Self::Tooltip => false,
}
}

Expand Down
55 changes: 52 additions & 3 deletions crates/egui/src/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

// ----------------------------------------------------------------------------
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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()) {
Expand Down
2 changes: 1 addition & 1 deletion crates/egui/src/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/egui_demo_lib/src/demo/demo_app_windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
31 changes: 1 addition & 30 deletions crates/egui_demo_lib/src/demo/misc_demo_window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,54 +233,25 @@ fn label_ui(ui: &mut egui::Ui) {
#[cfg_attr(feature = "serde", serde(default))]
pub struct Widgets {
angle: f32,
enabled: bool,
password: String,
}

impl Default for Widgets {
fn default() -> Self {
Self {
angle: std::f32::consts::TAU / 3.0,
enabled: true,
password: "hunter2".to_owned(),
}
}
}

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| {
Expand Down
Loading

0 comments on commit c0a9800

Please sign in to comment.