diff --git a/crates/egui/src/containers/popup.rs b/crates/egui/src/containers/popup.rs index 661de610..c397a2de 100644 --- a/crates/egui/src/containers/popup.rs +++ b/crates/egui/src/containers/popup.rs @@ -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) } diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index 94713499..9354a9fe 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -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>, + pub viewport_ui_cb: Option>, - 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, + pub output: PlatformOutput, + pub commands: Vec, } /// 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(&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( + &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, diff --git a/crates/egui/src/frame_state.rs b/crates/egui/src/frame_state.rs index 28c8c364..1a4f5a71 100644 --- a/crates/egui/src/frame_state.rs +++ b/crates/egui/src/frame_state.rs @@ -1,18 +1,18 @@ use crate::{id::IdSet, *}; #[derive(Clone, Debug, Default)] -pub(crate) struct TooltipFrameState { +pub struct TooltipFrameState { pub widget_tooltips: IdMap, } 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, - pub(crate) parent_stack: Vec, +pub struct AccessKitFrameState { + pub node_builders: IdMap, + pub parent_stack: Vec, } /// 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, + pub used_ids: IdMap, /// 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)>; 2], + pub scroll_target: [Option<(Rangef, Option)>; 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, + pub accesskit_state: Option, /// 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 { diff --git a/crates/egui/src/layers.rs b/crates/egui/src/layers.rs index b9b1fb5a..768099e2 100644 --- a/crates/egui/src/layers.rs +++ b/crates/egui/src/layers.rs @@ -48,8 +48,8 @@ impl Order { | Self::PanelResizeLine | Self::Middle | Self::Foreground + | Self::Tooltip | Self::Debug => true, - Self::Tooltip => false, } } diff --git a/crates/egui/src/response.rs b/crates/egui/src/response.rs index 8697a974..045b28a2 100644 --- a/crates/egui/src/response.rs +++ b/crates/egui/src/response.rs @@ -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()) { diff --git a/crates/egui/src/style.rs b/crates/egui/src/style.rs index fd1c13a8..f306a73d 100644 --- a/crates/egui/src/style.rs +++ b/crates/egui/src/style.rs @@ -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, diff --git a/crates/egui_demo_lib/src/demo/demo_app_windows.rs b/crates/egui_demo_lib/src/demo/demo_app_windows.rs index dc0e91f1..74c5edef 100644 --- a/crates/egui_demo_lib/src/demo/demo_app_windows.rs +++ b/crates/egui_demo_lib/src/demo/demo_app_windows.rs @@ -42,6 +42,7 @@ impl Default for Demos { Box::::default(), Box::::default(), Box::::default(), + Box::::default(), Box::::default(), Box::::default(), Box::::default(), diff --git a/crates/egui_demo_lib/src/demo/misc_demo_window.rs b/crates/egui_demo_lib/src/demo/misc_demo_window.rs index 6b73fe40..6cd46723 100644 --- a/crates/egui_demo_lib/src/demo/misc_demo_window.rs +++ b/crates/egui_demo_lib/src/demo/misc_demo_window.rs @@ -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| { diff --git a/crates/egui_demo_lib/src/demo/mod.rs b/crates/egui_demo_lib/src/demo/mod.rs index 8a911c9d..724134f8 100644 --- a/crates/egui_demo_lib/src/demo/mod.rs +++ b/crates/egui_demo_lib/src/demo/mod.rs @@ -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; diff --git a/crates/egui_demo_lib/src/demo/tooltips.rs b/crates/egui_demo_lib/src/demo/tooltips.rs new file mode 100644 index 00000000..f40edd54 --- /dev/null +++ b/crates/egui_demo_lib/src/demo/tooltips.rs @@ -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); + }); + } +} diff --git a/tests/test_size_pass/src/main.rs b/tests/test_size_pass/src/main.rs index f841cd1d..ab9ad17e 100644 --- a/tests/test_size_pass/src/main.rs +++ b/tests/test_size_pass/src/main.rs @@ -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"); + }); }); }) }