Skip to content

Commit c0a9800

Browse files
authored
Support interactive widgets in tooltips (#4596)
* 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
1 parent 7b3752f commit c0a9800

File tree

11 files changed

+274
-72
lines changed

11 files changed

+274
-72
lines changed

crates/egui/src/containers/popup.rs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,15 @@ fn show_tooltip_at_avoid_dyn<'c, R>(
135135
.pivot(pivot)
136136
.fixed_pos(anchor)
137137
.default_width(ctx.style().spacing.tooltip_width)
138-
.interactable(false)
138+
.interactable(false) // Only affects the actual area, i.e. clicking and dragging it. The content can still be interactive.
139139
.show(ctx, |ui| {
140+
// By default the text in tooltips aren't selectable.
141+
// This means that most tooltips aren't interactable,
142+
// which also mean they won't stick around so you can click them.
143+
// Only tooltips that have actual interactive stuff (buttons, links, …)
144+
// will stick around when you try to click them.
145+
ui.style_mut().interaction.selectable_labels = false;
146+
140147
Frame::popup(&ctx.style()).show_dyn(ui, add_contents).inner
141148
});
142149

@@ -147,7 +154,18 @@ fn show_tooltip_at_avoid_dyn<'c, R>(
147154
inner
148155
}
149156

150-
fn tooltip_id(widget_id: Id, tooltip_count: usize) -> Id {
157+
/// What is the id of the next tooltip for this widget?
158+
pub fn next_tooltip_id(ctx: &Context, widget_id: Id) -> Id {
159+
let tooltip_count = ctx.frame_state(|fs| {
160+
fs.tooltip_state
161+
.widget_tooltips
162+
.get(&widget_id)
163+
.map_or(0, |state| state.tooltip_count)
164+
});
165+
tooltip_id(widget_id, tooltip_count)
166+
}
167+
168+
pub fn tooltip_id(widget_id: Id, tooltip_count: usize) -> Id {
151169
widget_id.with(tooltip_count)
152170
}
153171

crates/egui/src/context.rs

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -198,36 +198,39 @@ impl ContextImpl {
198198

199199
// ----------------------------------------------------------------------------
200200

201-
/// State stored per viewport
201+
/// State stored per viewport.
202+
///
203+
/// Mostly for internal use.
204+
/// Things here may move and change without warning.
202205
#[derive(Default)]
203-
struct ViewportState {
206+
pub struct ViewportState {
204207
/// The type of viewport.
205208
///
206209
/// This will never be [`ViewportClass::Embedded`],
207210
/// since those don't result in real viewports.
208-
class: ViewportClass,
211+
pub class: ViewportClass,
209212

210213
/// The latest delta
211-
builder: ViewportBuilder,
214+
pub builder: ViewportBuilder,
212215

213216
/// The user-code that shows the GUI, used for deferred viewports.
214217
///
215218
/// `None` for immediate viewports.
216-
viewport_ui_cb: Option<Arc<DeferredViewportUiCallback>>,
219+
pub viewport_ui_cb: Option<Arc<DeferredViewportUiCallback>>,
217220

218-
input: InputState,
221+
pub input: InputState,
219222

220223
/// State that is collected during a frame and then cleared
221-
frame_state: FrameState,
224+
pub frame_state: FrameState,
222225

223226
/// Has this viewport been updated this frame?
224-
used: bool,
227+
pub used: bool,
225228

226229
/// Written to during the frame.
227-
widgets_this_frame: WidgetRects,
230+
pub widgets_this_frame: WidgetRects,
228231

229232
/// Read
230-
widgets_prev_frame: WidgetRects,
233+
pub widgets_prev_frame: WidgetRects,
231234

232235
/// State related to repaint scheduling.
233236
repaint: ViewportRepaintInfo,
@@ -236,20 +239,20 @@ struct ViewportState {
236239
// Updated at the start of the frame:
237240
//
238241
/// Which widgets are under the pointer?
239-
hits: WidgetHits,
242+
pub hits: WidgetHits,
240243

241244
/// What widgets are being interacted with this frame?
242245
///
243246
/// Based on the widgets from last frame, and input in this frame.
244-
interact_widgets: InteractionSnapshot,
247+
pub interact_widgets: InteractionSnapshot,
245248

246249
// ----------------------
247250
// The output of a frame:
248251
//
249-
graphics: GraphicLayers,
252+
pub graphics: GraphicLayers,
250253
// Most of the things in `PlatformOutput` are not actually viewport dependent.
251-
output: PlatformOutput,
252-
commands: Vec<ViewportCommand>,
254+
pub output: PlatformOutput,
255+
pub commands: Vec<ViewportCommand>,
253256
}
254257

255258
/// What called [`Context::request_repaint`]?
@@ -3092,6 +3095,20 @@ impl Context {
30923095
self.read(|ctx| ctx.parent_viewport_id())
30933096
}
30943097

3098+
/// Read the state of the current viewport.
3099+
pub fn viewport<R>(&self, reader: impl FnOnce(&ViewportState) -> R) -> R {
3100+
self.write(|ctx| reader(ctx.viewport()))
3101+
}
3102+
3103+
/// Read the state of a specific current viewport.
3104+
pub fn viewport_for<R>(
3105+
&self,
3106+
viewport_id: ViewportId,
3107+
reader: impl FnOnce(&ViewportState) -> R,
3108+
) -> R {
3109+
self.write(|ctx| reader(ctx.viewport_for(viewport_id)))
3110+
}
3111+
30953112
/// For integrations: Set this to render a sync viewport.
30963113
///
30973114
/// This will only set the callback for the current thread,

crates/egui/src/frame_state.rs

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
use crate::{id::IdSet, *};
22

33
#[derive(Clone, Debug, Default)]
4-
pub(crate) struct TooltipFrameState {
4+
pub struct TooltipFrameState {
55
pub widget_tooltips: IdMap<PerWidgetTooltipState>,
66
}
77

88
impl TooltipFrameState {
9-
pub(crate) fn clear(&mut self) {
9+
pub fn clear(&mut self) {
1010
self.widget_tooltips.clear();
1111
}
1212
}
1313

1414
#[derive(Clone, Copy, Debug)]
15-
pub(crate) struct PerWidgetTooltipState {
15+
pub struct PerWidgetTooltipState {
1616
/// Bounding rectangle for all widget and all previous tooltips.
1717
pub bounding_rect: Rect,
1818

@@ -22,37 +22,37 @@ pub(crate) struct PerWidgetTooltipState {
2222

2323
#[cfg(feature = "accesskit")]
2424
#[derive(Clone)]
25-
pub(crate) struct AccessKitFrameState {
26-
pub(crate) node_builders: IdMap<accesskit::NodeBuilder>,
27-
pub(crate) parent_stack: Vec<Id>,
25+
pub struct AccessKitFrameState {
26+
pub node_builders: IdMap<accesskit::NodeBuilder>,
27+
pub parent_stack: Vec<Id>,
2828
}
2929

3030
/// State that is collected during a frame and then cleared.
3131
/// Short-term (single frame) memory.
3232
#[derive(Clone)]
33-
pub(crate) struct FrameState {
33+
pub struct FrameState {
3434
/// All [`Id`]s that were used this frame.
35-
pub(crate) used_ids: IdMap<Rect>,
35+
pub used_ids: IdMap<Rect>,
3636

3737
/// Starts off as the `screen_rect`, shrinks as panels are added.
3838
/// The [`CentralPanel`] does not change this.
3939
/// This is the area available to Window's.
40-
pub(crate) available_rect: Rect,
40+
pub available_rect: Rect,
4141

4242
/// Starts off as the `screen_rect`, shrinks as panels are added.
4343
/// The [`CentralPanel`] retracts from this.
44-
pub(crate) unused_rect: Rect,
44+
pub unused_rect: Rect,
4545

4646
/// How much space is used by panels.
47-
pub(crate) used_by_panels: Rect,
47+
pub used_by_panels: Rect,
4848

4949
/// If a tooltip has been shown this frame, where was it?
5050
/// This is used to prevent multiple tooltips to cover each other.
5151
/// Reset at the start of each frame.
52-
pub(crate) tooltip_state: TooltipFrameState,
52+
pub tooltip_state: TooltipFrameState,
5353

5454
/// The current scroll area should scroll to this range (horizontal, vertical).
55-
pub(crate) scroll_target: [Option<(Rangef, Option<Align>)>; 2],
55+
pub scroll_target: [Option<(Rangef, Option<Align>)>; 2],
5656

5757
/// The current scroll area should scroll by this much.
5858
///
@@ -63,19 +63,19 @@ pub(crate) struct FrameState {
6363
///
6464
/// A positive Y-value indicates the content is being moved down,
6565
/// as when swiping down on a touch-screen or track-pad with natural scrolling.
66-
pub(crate) scroll_delta: Vec2,
66+
pub scroll_delta: Vec2,
6767

6868
#[cfg(feature = "accesskit")]
69-
pub(crate) accesskit_state: Option<AccessKitFrameState>,
69+
pub accesskit_state: Option<AccessKitFrameState>,
7070

7171
/// Highlight these widgets this next frame. Read from this.
72-
pub(crate) highlight_this_frame: IdSet,
72+
pub highlight_this_frame: IdSet,
7373

7474
/// Highlight these widgets the next frame. Write to this.
75-
pub(crate) highlight_next_frame: IdSet,
75+
pub highlight_next_frame: IdSet,
7676

7777
#[cfg(debug_assertions)]
78-
pub(crate) has_debug_viewed_this_frame: bool,
78+
pub has_debug_viewed_this_frame: bool,
7979
}
8080

8181
impl Default for FrameState {

crates/egui/src/layers.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@ impl Order {
4848
| Self::PanelResizeLine
4949
| Self::Middle
5050
| Self::Foreground
51+
| Self::Tooltip
5152
| Self::Debug => true,
52-
Self::Tooltip => false,
5353
}
5454
}
5555

crates/egui/src/response.rs

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ use std::{any::Any, sync::Arc};
22

33
use crate::{
44
emath::{Align, Pos2, Rect, Vec2},
5-
menu, ComboBox, Context, CursorIcon, Id, LayerId, PointerButton, Sense, Ui, WidgetRect,
6-
WidgetText,
5+
menu, AreaState, ComboBox, Context, CursorIcon, Id, LayerId, Order, PointerButton, Sense, Ui,
6+
WidgetRect, WidgetText,
77
};
88

99
// ----------------------------------------------------------------------------
@@ -520,6 +520,20 @@ impl Response {
520520
/// For that, use [`Self::on_disabled_hover_ui`] instead.
521521
///
522522
/// If you call this multiple times the tooltips will stack underneath the previous ones.
523+
///
524+
/// The widget can contain interactive widgets, such as buttons and links.
525+
/// If so, it will stay open as the user moves their pointer over it.
526+
/// By default, the text of a tooltip is NOT selectable (i.e. interactive),
527+
/// but you can change this by setting [`style::Interaction::selectable_labels` from within the tooltip:
528+
///
529+
/// ```
530+
/// # egui::__run_test_ui(|ui| {
531+
/// ui.label("Hover me").on_hover_ui(|ui| {
532+
/// ui.style_mut().interaction.selectable_labels = true;
533+
/// ui.label("This text can be selected");
534+
/// });
535+
/// # });
536+
/// ```
523537
#[doc(alias = "tooltip")]
524538
pub fn on_hover_ui(self, add_contents: impl FnOnce(&mut Ui)) -> Self {
525539
if self.enabled && self.should_show_hover_ui() {
@@ -570,6 +584,41 @@ impl Response {
570584
return true;
571585
}
572586

587+
let is_tooltip_open = self.is_tooltip_open();
588+
589+
if is_tooltip_open {
590+
let tooltip_id = crate::next_tooltip_id(&self.ctx, self.id);
591+
let layer_id = LayerId::new(Order::Tooltip, tooltip_id);
592+
593+
let tooltip_has_interactive_widget = self.ctx.viewport(|vp| {
594+
vp.widgets_prev_frame
595+
.get_layer(layer_id)
596+
.any(|w| w.sense.interactive())
597+
});
598+
599+
if tooltip_has_interactive_widget {
600+
// We keep the tooltip open if hovered,
601+
// or if the pointer is on its way to it,
602+
// so that the user can interact with the tooltip
603+
// (i.e. click links that are in it).
604+
if let Some(area) = AreaState::load(&self.ctx, tooltip_id) {
605+
let rect = area.rect();
606+
let pointer_in_area_or_on_the_way_there = self.ctx.input(|i| {
607+
if let Some(pos) = i.pointer.hover_pos() {
608+
rect.contains(pos)
609+
|| rect.intersects_ray(pos, i.pointer.velocity().normalized())
610+
} else {
611+
false
612+
}
613+
});
614+
615+
if pointer_in_area_or_on_the_way_there {
616+
return true;
617+
}
618+
}
619+
}
620+
}
621+
573622
// Fast early-outs:
574623
if self.enabled {
575624
if !self.hovered || !self.ctx.input(|i| i.pointer.has_pointer()) {
@@ -605,7 +654,7 @@ impl Response {
605654
let tooltip_was_recently_shown = when_was_a_toolip_last_shown
606655
.map_or(false, |time| ((now - time) as f32) < tooltip_grace_time);
607656

608-
if !tooltip_was_recently_shown && !self.is_tooltip_open() {
657+
if !tooltip_was_recently_shown && !is_tooltip_open {
609658
if self.ctx.style().interaction.show_tooltips_only_when_still {
610659
// We only show the tooltip when the mouse pointer is still.
611660
if !self.ctx.input(|i| i.pointer.is_still()) {

crates/egui/src/style.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1092,7 +1092,7 @@ impl Default for Spacing {
10921092
icon_width_inner: 8.0,
10931093
icon_spacing: 4.0,
10941094
default_area_size: vec2(600.0, 400.0),
1095-
tooltip_width: 600.0,
1095+
tooltip_width: 500.0,
10961096
menu_width: 400.0,
10971097
menu_spacing: 2.0,
10981098
combo_height: 200.0,

crates/egui_demo_lib/src/demo/demo_app_windows.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ impl Default for Demos {
4242
Box::<super::table_demo::TableDemo>::default(),
4343
Box::<super::text_edit::TextEditDemo>::default(),
4444
Box::<super::text_layout::TextLayoutDemo>::default(),
45+
Box::<super::tooltips::Tooltips>::default(),
4546
Box::<super::widget_gallery::WidgetGallery>::default(),
4647
Box::<super::window_options::WindowOptions>::default(),
4748
Box::<super::tests::WindowResizeTest>::default(),

crates/egui_demo_lib/src/demo/misc_demo_window.rs

Lines changed: 1 addition & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -233,54 +233,25 @@ fn label_ui(ui: &mut egui::Ui) {
233233
#[cfg_attr(feature = "serde", serde(default))]
234234
pub struct Widgets {
235235
angle: f32,
236-
enabled: bool,
237236
password: String,
238237
}
239238

240239
impl Default for Widgets {
241240
fn default() -> Self {
242241
Self {
243242
angle: std::f32::consts::TAU / 3.0,
244-
enabled: true,
245243
password: "hunter2".to_owned(),
246244
}
247245
}
248246
}
249247

250248
impl Widgets {
251249
pub fn ui(&mut self, ui: &mut Ui) {
252-
let Self {
253-
angle,
254-
enabled,
255-
password,
256-
} = self;
250+
let Self { angle, password } = self;
257251
ui.vertical_centered(|ui| {
258252
ui.add(crate::egui_github_link_file_line!());
259253
});
260254

261-
let tooltip_ui = |ui: &mut Ui| {
262-
ui.heading("The name of the tooltip");
263-
ui.horizontal(|ui| {
264-
ui.label("This tooltip was created with");
265-
ui.monospace(".on_hover_ui(…)");
266-
});
267-
let _ = ui.button("A button you can never press");
268-
};
269-
let disabled_tooltip_ui = |ui: &mut Ui| {
270-
ui.heading("Different tooltip when widget is disabled");
271-
ui.horizontal(|ui| {
272-
ui.label("This tooltip was created with");
273-
ui.monospace(".on_disabled_hover_ui(…)");
274-
});
275-
};
276-
ui.checkbox(enabled, "Enabled");
277-
ui.add_enabled(
278-
*enabled,
279-
egui::Label::new("Tooltips can be more than just simple text."),
280-
)
281-
.on_hover_ui(tooltip_ui)
282-
.on_disabled_hover_ui(disabled_tooltip_ui);
283-
284255
ui.separator();
285256

286257
ui.horizontal(|ui| {

0 commit comments

Comments
 (0)