Add `Popup` and `Tooltip`, unifying the previous behaviours (#5713)

This introduces new `Tooltip` and `Popup` structs that unify and extend
the old popups and tooltips.

`Popup` handles the positioning and optionally stores state on whether
the popup is open (for click based popups like `ComboBox`, menus,
context menus).
`Tooltip` is based on `Popup` and handles state of whether the tooltip
should be shown (which turns out to be quite complex to handles all the
edge cases).

Both `Popup` and `Tooltip` can easily be constructed from a `Response`
and then customized via builder methods.

This also introduces `PositionAlign`, for aligning something outside of
a `Rect` (in contrast to `Align2` for aligning inside a `Rect`). But I
don't like the name, any suggestions? Inspired by [mui's tooltip
positioning](https://mui.com/material-ui/react-tooltip/#positioned-tooltips).

* Part of #4607 
* [x] I have followed the instructions in the PR template

TODOs:
- [x] Automatic tooltip positioning based on available space
- [x] Review / fix / remove all code TODOs 
- [x] ~Update the helper fns on `Response` to be consistent in naming
and parameters (Some use tooltip, some hover_ui, some take &self, some
take self)~ actually, I think the naming and parameter make sense on
second thought
- [x] Make sure all old code is marked deprecated

For discussion during review:
- the following check in `show_tooltip_for` still necessary?:
```rust
     let is_touch_screen = ctx.input(|i| i.any_touches());
     let allow_placing_below = !is_touch_screen; // There is a finger below. TODO: Needed?
```
This commit is contained in:
lucasmerlin 2025-02-18 15:53:07 +01:00 committed by GitHub
parent 66c73b9cbf
commit a8e98d3f9b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1738 additions and 715 deletions

View File

@ -5,8 +5,8 @@
use emath::GuiRounding as _;
use crate::{
emath, pos2, Align2, Context, Id, InnerResponse, LayerId, NumExt, Order, Pos2, Rect, Response,
Sense, Ui, UiBuilder, UiKind, UiStackInfo, Vec2, WidgetRect, WidgetWithState,
emath, pos2, Align2, Context, Id, InnerResponse, LayerId, Layout, NumExt, Order, Pos2, Rect,
Response, Sense, Ui, UiBuilder, UiKind, UiStackInfo, Vec2, WidgetRect, WidgetWithState,
};
/// State of an [`Area`] that is persisted between frames.
@ -120,6 +120,7 @@ pub struct Area {
anchor: Option<(Align2, Vec2)>,
new_pos: Option<Pos2>,
fade_in: bool,
layout: Layout,
}
impl WidgetWithState for Area {
@ -145,6 +146,7 @@ impl Area {
pivot: Align2::LEFT_TOP,
anchor: None,
fade_in: true,
layout: Layout::default(),
}
}
@ -339,6 +341,13 @@ impl Area {
self.fade_in = fade_in;
self
}
/// Set the layout for the child Ui.
#[inline]
pub fn layout(mut self, layout: Layout) -> Self {
self.layout = layout;
self
}
}
pub(crate) struct Prepared {
@ -358,6 +367,7 @@ pub(crate) struct Prepared {
sizing_pass: bool,
fade_in: bool,
layout: Layout,
}
impl Area {
@ -390,6 +400,7 @@ impl Area {
constrain,
constrain_rect,
fade_in,
layout,
} = self;
let constrain_rect = constrain_rect.unwrap_or_else(|| ctx.screen_rect());
@ -516,6 +527,7 @@ impl Area {
constrain_rect,
sizing_pass,
fade_in,
layout,
}
}
}
@ -543,7 +555,8 @@ impl Prepared {
let mut ui_builder = UiBuilder::new()
.ui_stack_info(UiStackInfo::new(self.kind))
.layer_id(self.layer_id)
.max_rect(max_rect);
.max_rect(max_rect)
.layout(self.layout);
if !self.enabled {
ui_builder = ui_builder.disabled();

View File

@ -1,7 +1,7 @@
use epaint::Shape;
use crate::{
epaint, style::WidgetVisuals, vec2, Align2, Context, Id, InnerResponse, NumExt, Painter,
epaint, style::WidgetVisuals, vec2, Align2, Context, Id, InnerResponse, NumExt, Painter, Popup,
PopupCloseBehavior, Rect, Response, ScrollArea, Sense, Stroke, TextStyle, TextWrapMode, Ui,
UiBuilder, Vec2, WidgetInfo, WidgetText, WidgetType,
};
@ -9,15 +9,8 @@ use crate::{
#[allow(unused_imports)] // Documentation
use crate::style::Spacing;
/// Indicate whether a popup will be shown above or below the box.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum AboveOrBelow {
Above,
Below,
}
/// A function that paints the [`ComboBox`] icon
pub type IconPainter = Box<dyn FnOnce(&Ui, Rect, &WidgetVisuals, bool, AboveOrBelow)>;
pub type IconPainter = Box<dyn FnOnce(&Ui, Rect, &WidgetVisuals, bool)>;
/// A drop-down selection menu with a descriptive label.
///
@ -135,7 +128,6 @@ impl ComboBox {
/// rect: egui::Rect,
/// visuals: &egui::style::WidgetVisuals,
/// _is_open: bool,
/// _above_or_below: egui::AboveOrBelow,
/// ) {
/// let rect = egui::Rect::from_center_size(
/// rect.center(),
@ -154,10 +146,8 @@ impl ComboBox {
/// .show_ui(ui, |_ui| {});
/// # });
/// ```
pub fn icon(
mut self,
icon_fn: impl FnOnce(&Ui, Rect, &WidgetVisuals, bool, AboveOrBelow) + 'static,
) -> Self {
#[inline]
pub fn icon(mut self, icon_fn: impl FnOnce(&Ui, Rect, &WidgetVisuals, bool) + 'static) -> Self {
self.icon = Some(Box::new(icon_fn));
self
}
@ -322,22 +312,6 @@ fn combo_box_dyn<'c, R>(
let is_popup_open = ui.memory(|m| m.is_popup_open(popup_id));
let popup_height = ui.memory(|m| {
m.areas()
.get(popup_id)
.and_then(|state| state.size)
.map_or(100.0, |size| size.y)
});
let above_or_below =
if ui.next_widget_position().y + ui.spacing().interact_size.y + popup_height
< ui.ctx().screen_rect().bottom()
{
AboveOrBelow::Below
} else {
AboveOrBelow::Above
};
let wrap_mode = wrap_mode.unwrap_or_else(|| ui.wrap_mode());
let close_behavior = close_behavior.unwrap_or(PopupCloseBehavior::CloseOnClick);
@ -385,15 +359,9 @@ fn combo_box_dyn<'c, R>(
icon_rect.expand(visuals.expansion),
visuals,
is_popup_open,
above_or_below,
);
} else {
paint_default_icon(
ui.painter(),
icon_rect.expand(visuals.expansion),
visuals,
above_or_below,
);
paint_default_icon(ui.painter(), icon_rect.expand(visuals.expansion), visuals);
}
let text_rect = Align2::LEFT_CENTER.align_size_within_rect(galley.size(), rect);
@ -402,19 +370,15 @@ fn combo_box_dyn<'c, R>(
}
});
if button_response.clicked() {
ui.memory_mut(|mem| mem.toggle_popup(popup_id));
}
let height = height.unwrap_or_else(|| ui.spacing().combo_height);
let inner = crate::popup::popup_above_or_below_widget(
ui,
popup_id,
&button_response,
above_or_below,
close_behavior,
|ui| {
let inner = Popup::menu(&button_response)
.id(popup_id)
.width(button_response.rect.width())
.close_behavior(close_behavior)
.show(|ui| {
ui.set_min_width(ui.available_width());
ScrollArea::vertical()
.max_height(height)
.show(ui, |ui| {
@ -427,8 +391,8 @@ fn combo_box_dyn<'c, R>(
menu_contents(ui)
})
.inner
},
);
})
.map(|r| r.inner);
InnerResponse {
inner,
@ -484,33 +448,19 @@ fn button_frame(
response
}
fn paint_default_icon(
painter: &Painter,
rect: Rect,
visuals: &WidgetVisuals,
above_or_below: AboveOrBelow,
) {
fn paint_default_icon(painter: &Painter, rect: Rect, visuals: &WidgetVisuals) {
let rect = Rect::from_center_size(
rect.center(),
vec2(rect.width() * 0.7, rect.height() * 0.45),
);
match above_or_below {
AboveOrBelow::Above => {
// Upward pointing triangle
painter.add(Shape::convex_polygon(
vec![rect.left_bottom(), rect.right_bottom(), rect.center_top()],
visuals.fg_stroke.color,
Stroke::NONE,
));
}
AboveOrBelow::Below => {
// Downward pointing triangle
painter.add(Shape::convex_polygon(
vec![rect.left_top(), rect.right_top(), rect.center_bottom()],
visuals.fg_stroke.color,
Stroke::NONE,
));
}
}
// Downward pointing triangle
// Previously, we would show an up arrow when we expected the popup to open upwards
// (due to lack of space below the button), but this could look weird in edge cases, so this
// feature was removed. (See https://github.com/emilk/egui/pull/5713#issuecomment-2654420245)
painter.add(Shape::convex_polygon(
vec![rect.left_top(), rect.right_top(), rect.center_bottom()],
visuals.fg_stroke.color,
Stroke::NONE,
));
}

View File

@ -7,12 +7,14 @@ pub mod collapsing_header;
mod combo_box;
pub mod frame;
pub mod modal;
pub mod old_popup;
pub mod panel;
pub mod popup;
mod popup;
pub(crate) mod resize;
mod scene;
pub mod scroll_area;
mod sides;
mod tooltip;
pub(crate) mod window;
pub use {
@ -21,11 +23,13 @@ pub use {
combo_box::*,
frame::Frame,
modal::{Modal, ModalResponse},
old_popup::*,
panel::{CentralPanel, SidePanel, TopBottomPanel},
popup::*,
resize::Resize,
scene::Scene,
scroll_area::ScrollArea,
sides::Sides,
tooltip::*,
window::Window,
};

View File

@ -0,0 +1,211 @@
//! Old and deprecated API for popups. Use [`Popup`] instead.
#![allow(deprecated)]
use crate::containers::tooltip::Tooltip;
use crate::{
Align, Context, Id, LayerId, Layout, Popup, PopupAnchor, PopupCloseBehavior, Pos2, Rect,
Response, Ui, Widget, WidgetText,
};
use emath::RectAlign;
// ----------------------------------------------------------------------------
/// Show a tooltip at the current pointer position (if any).
///
/// Most of the time it is easier to use [`Response::on_hover_ui`].
///
/// See also [`show_tooltip_text`].
///
/// Returns `None` if the tooltip could not be placed.
///
/// ```
/// # egui::__run_test_ui(|ui| {
/// # #[allow(deprecated)]
/// if ui.ui_contains_pointer() {
/// egui::show_tooltip(ui.ctx(), ui.layer_id(), egui::Id::new("my_tooltip"), |ui| {
/// ui.label("Helpful text");
/// });
/// }
/// # });
/// ```
#[deprecated = "Use `egui::Tooltip` instead"]
pub fn show_tooltip<R>(
ctx: &Context,
parent_layer: LayerId,
widget_id: Id,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> Option<R> {
show_tooltip_at_pointer(ctx, parent_layer, widget_id, add_contents)
}
/// Show a tooltip at the current pointer position (if any).
///
/// Most of the time it is easier to use [`Response::on_hover_ui`].
///
/// See also [`show_tooltip_text`].
///
/// Returns `None` if the tooltip could not be placed.
///
/// ```
/// # egui::__run_test_ui(|ui| {
/// if ui.ui_contains_pointer() {
/// egui::show_tooltip_at_pointer(ui.ctx(), ui.layer_id(), egui::Id::new("my_tooltip"), |ui| {
/// ui.label("Helpful text");
/// });
/// }
/// # });
/// ```
#[deprecated = "Use `egui::Tooltip` instead"]
pub fn show_tooltip_at_pointer<R>(
ctx: &Context,
parent_layer: LayerId,
widget_id: Id,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> Option<R> {
Tooltip::new(widget_id, ctx.clone(), PopupAnchor::Pointer, parent_layer)
.gap(12.0)
.show(add_contents)
.map(|response| response.inner)
}
/// Show a tooltip under the given area.
///
/// If the tooltip does not fit under the area, it tries to place it above it instead.
#[deprecated = "Use `egui::Tooltip` instead"]
pub fn show_tooltip_for<R>(
ctx: &Context,
parent_layer: LayerId,
widget_id: Id,
widget_rect: &Rect,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> Option<R> {
Tooltip::new(widget_id, ctx.clone(), *widget_rect, parent_layer)
.show(add_contents)
.map(|response| response.inner)
}
/// Show a tooltip at the given position.
///
/// Returns `None` if the tooltip could not be placed.
#[deprecated = "Use `egui::Tooltip` instead"]
pub fn show_tooltip_at<R>(
ctx: &Context,
parent_layer: LayerId,
widget_id: Id,
suggested_position: Pos2,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> Option<R> {
Tooltip::new(widget_id, ctx.clone(), suggested_position, parent_layer)
.show(add_contents)
.map(|response| response.inner)
}
/// Show some text at the current pointer position (if any).
///
/// Most of the time it is easier to use [`Response::on_hover_text`].
///
/// See also [`show_tooltip`].
///
/// Returns `None` if the tooltip could not be placed.
///
/// ```
/// # egui::__run_test_ui(|ui| {
/// if ui.ui_contains_pointer() {
/// egui::show_tooltip_text(ui.ctx(), ui.layer_id(), egui::Id::new("my_tooltip"), "Helpful text");
/// }
/// # });
/// ```
#[deprecated = "Use `egui::Tooltip` instead"]
pub fn show_tooltip_text(
ctx: &Context,
parent_layer: LayerId,
widget_id: Id,
text: impl Into<WidgetText>,
) -> Option<()> {
show_tooltip(ctx, parent_layer, widget_id, |ui| {
crate::widgets::Label::new(text).ui(ui);
})
}
/// Was this tooltip visible last frame?
#[deprecated = "Use `Tooltip::was_tooltip_open_last_frame` instead"]
pub fn was_tooltip_open_last_frame(ctx: &Context, widget_id: Id) -> bool {
Tooltip::was_tooltip_open_last_frame(ctx, widget_id)
}
/// Indicate whether a popup will be shown above or below the box.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum AboveOrBelow {
Above,
Below,
}
/// Helper for [`popup_above_or_below_widget`].
#[deprecated = "Use `egui::Popup` instead"]
pub fn popup_below_widget<R>(
ui: &Ui,
popup_id: Id,
widget_response: &Response,
close_behavior: PopupCloseBehavior,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> Option<R> {
popup_above_or_below_widget(
ui,
popup_id,
widget_response,
AboveOrBelow::Below,
close_behavior,
add_contents,
)
}
/// Shows a popup above or below another widget.
///
/// Useful for drop-down menus (combo boxes) or suggestion menus under text fields.
///
/// The opened popup will have a minimum width matching its parent.
///
/// You must open the popup with [`crate::Memory::open_popup`] or [`crate::Memory::toggle_popup`].
///
/// Returns `None` if the popup is not open.
///
/// ```
/// # egui::__run_test_ui(|ui| {
/// let response = ui.button("Open popup");
/// let popup_id = ui.make_persistent_id("my_unique_id");
/// if response.clicked() {
/// ui.memory_mut(|mem| mem.toggle_popup(popup_id));
/// }
/// let below = egui::AboveOrBelow::Below;
/// let close_on_click_outside = egui::PopupCloseBehavior::CloseOnClickOutside;
/// # #[allow(deprecated)]
/// egui::popup_above_or_below_widget(ui, popup_id, &response, below, close_on_click_outside, |ui| {
/// ui.set_min_width(200.0); // if you want to control the size
/// ui.label("Some more info, or things you can select:");
/// ui.label("…");
/// });
/// # });
/// ```
#[deprecated = "Use `egui::Popup` instead"]
pub fn popup_above_or_below_widget<R>(
_parent_ui: &Ui,
popup_id: Id,
widget_response: &Response,
above_or_below: AboveOrBelow,
close_behavior: PopupCloseBehavior,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> Option<R> {
let response = Popup::from_response(widget_response)
.layout(Layout::top_down_justified(Align::LEFT))
.open_memory(None, close_behavior)
.id(popup_id)
.align(match above_or_below {
AboveOrBelow::Above => RectAlign::TOP_START,
AboveOrBelow::Below => RectAlign::BOTTOM_START,
})
.width(widget_response.rect.width())
.show(|ui| {
ui.set_min_width(ui.available_width());
add_contents(ui)
})?;
Some(response.inner)
}

View File

@ -1,334 +1,75 @@
//! Show popup windows, tooltips, context menus etc.
use pass_state::PerWidgetTooltipState;
use crate::{
pass_state, vec2, AboveOrBelow, Align, Align2, Area, AreaState, Context, Frame, Id,
InnerResponse, Key, LayerId, Layout, Order, Pos2, Rect, Response, Sense, Ui, UiKind, Vec2,
Widget, WidgetText,
Area, AreaState, Context, Frame, Id, InnerResponse, Key, LayerId, Layout, Order, Response,
Sense, Ui, UiKind,
};
use emath::{vec2, Align, Pos2, Rect, RectAlign, Vec2};
use std::iter::once;
// ----------------------------------------------------------------------------
/// What should we anchor the popup to?
/// The final position for the popup will be calculated based on [`RectAlign`]
/// and can be customized with [`Popup::align`] and [`Popup::align_alternatives`].
/// [`PopupAnchor`] is the parent rect of [`RectAlign`].
///
/// For [`PopupAnchor::Pointer`], [`PopupAnchor::PointerFixed`] and [`PopupAnchor::Position`],
/// the rect will be derived via [`Rect::from_pos`] (so a zero-sized rect at the given position).
///
/// The rect should be in global coordinates. `PopupAnchor::from(&response)` will automatically
/// do this conversion.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PopupAnchor {
/// Show the popup relative to some parent [`Rect`].
ParentRect(Rect),
fn when_was_a_toolip_last_shown_id() -> Id {
Id::new("when_was_a_toolip_last_shown")
/// Show the popup relative to the mouse pointer.
Pointer,
/// Remember the mouse position and show the popup relative to that (like a context menu).
PointerFixed,
/// Show the popup relative to a specific position.
Position(Pos2),
}
pub fn seconds_since_last_tooltip(ctx: &Context) -> f32 {
let when_was_a_toolip_last_shown =
ctx.data(|d| d.get_temp::<f64>(when_was_a_toolip_last_shown_id()));
if let Some(when_was_a_toolip_last_shown) = when_was_a_toolip_last_shown {
let now = ctx.input(|i| i.time);
(now - when_was_a_toolip_last_shown) as f32
} else {
f32::INFINITY
impl From<Rect> for PopupAnchor {
fn from(rect: Rect) -> Self {
Self::ParentRect(rect)
}
}
fn remember_that_tooltip_was_shown(ctx: &Context) {
let now = ctx.input(|i| i.time);
ctx.data_mut(|data| data.insert_temp::<f64>(when_was_a_toolip_last_shown_id(), now));
impl From<Pos2> for PopupAnchor {
fn from(pos: Pos2) -> Self {
Self::Position(pos)
}
}
// ----------------------------------------------------------------------------
/// Show a tooltip at the current pointer position (if any).
///
/// Most of the time it is easier to use [`Response::on_hover_ui`].
///
/// See also [`show_tooltip_text`].
///
/// Returns `None` if the tooltip could not be placed.
///
/// ```
/// # egui::__run_test_ui(|ui| {
/// if ui.ui_contains_pointer() {
/// egui::show_tooltip(ui.ctx(), ui.layer_id(), egui::Id::new("my_tooltip"), |ui| {
/// ui.label("Helpful text");
/// });
/// }
/// # });
/// ```
pub fn show_tooltip<R>(
ctx: &Context,
parent_layer: LayerId,
widget_id: Id,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> Option<R> {
show_tooltip_at_pointer(ctx, parent_layer, widget_id, add_contents)
}
/// Show a tooltip at the current pointer position (if any).
///
/// Most of the time it is easier to use [`Response::on_hover_ui`].
///
/// See also [`show_tooltip_text`].
///
/// Returns `None` if the tooltip could not be placed.
///
/// ```
/// # egui::__run_test_ui(|ui| {
/// if ui.ui_contains_pointer() {
/// egui::show_tooltip_at_pointer(ui.ctx(), ui.layer_id(), egui::Id::new("my_tooltip"), |ui| {
/// ui.label("Helpful text");
/// });
/// }
/// # });
/// ```
pub fn show_tooltip_at_pointer<R>(
ctx: &Context,
parent_layer: LayerId,
widget_id: Id,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> Option<R> {
ctx.input(|i| i.pointer.hover_pos()).map(|pointer_pos| {
let allow_placing_below = true;
// Add a small exclusion zone around the pointer to avoid tooltips
// covering what we're hovering over.
let mut pointer_rect = Rect::from_center_size(pointer_pos, Vec2::splat(24.0));
// Keep the left edge of the tooltip in line with the cursor:
pointer_rect.min.x = pointer_pos.x;
// Transform global coords to layer coords:
if let Some(from_global) = ctx.layer_transform_from_global(parent_layer) {
pointer_rect = from_global * pointer_rect;
impl From<&Response> for PopupAnchor {
fn from(response: &Response) -> Self {
let mut widget_rect = response.rect;
if let Some(to_global) = response.ctx.layer_transform_to_global(response.layer_id) {
widget_rect = to_global * widget_rect;
}
show_tooltip_at_dyn(
ctx,
parent_layer,
widget_id,
allow_placing_below,
&pointer_rect,
Box::new(add_contents),
)
})
}
/// Show a tooltip under the given area.
///
/// If the tooltip does not fit under the area, it tries to place it above it instead.
pub fn show_tooltip_for<R>(
ctx: &Context,
parent_layer: LayerId,
widget_id: Id,
widget_rect: &Rect,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> R {
let is_touch_screen = ctx.input(|i| i.any_touches());
let allow_placing_below = !is_touch_screen; // There is a finger below.
show_tooltip_at_dyn(
ctx,
parent_layer,
widget_id,
allow_placing_below,
widget_rect,
Box::new(add_contents),
)
}
/// Show a tooltip at the given position.
///
/// Returns `None` if the tooltip could not be placed.
pub fn show_tooltip_at<R>(
ctx: &Context,
parent_layer: LayerId,
widget_id: Id,
suggested_position: Pos2,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> R {
let allow_placing_below = true;
let rect = Rect::from_center_size(suggested_position, Vec2::ZERO);
show_tooltip_at_dyn(
ctx,
parent_layer,
widget_id,
allow_placing_below,
&rect,
Box::new(add_contents),
)
}
fn show_tooltip_at_dyn<'c, R>(
ctx: &Context,
parent_layer: LayerId,
widget_id: Id,
allow_placing_below: bool,
widget_rect: &Rect,
add_contents: Box<dyn FnOnce(&mut Ui) -> R + 'c>,
) -> R {
// Transform layer coords to global coords:
let mut widget_rect = *widget_rect;
if let Some(to_global) = ctx.layer_transform_to_global(parent_layer) {
widget_rect = to_global * widget_rect;
Self::ParentRect(widget_rect)
}
remember_that_tooltip_was_shown(ctx);
let mut state = ctx.pass_state_mut(|fs| {
// Remember that this is the widget showing the tooltip:
fs.layers
.entry(parent_layer)
.or_default()
.widget_with_tooltip = Some(widget_id);
fs.tooltips
.widget_tooltips
.get(&widget_id)
.copied()
.unwrap_or(PerWidgetTooltipState {
bounding_rect: widget_rect,
tooltip_count: 0,
})
});
let tooltip_area_id = tooltip_id(widget_id, state.tooltip_count);
let expected_tooltip_size = AreaState::load(ctx, tooltip_area_id)
.and_then(|area| area.size)
.unwrap_or(vec2(64.0, 32.0));
let screen_rect = ctx.screen_rect();
let (pivot, anchor) = find_tooltip_position(
screen_rect,
state.bounding_rect,
allow_placing_below,
expected_tooltip_size,
);
let InnerResponse { inner, response } = Area::new(tooltip_area_id)
.kind(UiKind::Popup)
.order(Order::Tooltip)
.pivot(pivot)
.fixed_pos(anchor)
.default_width(ctx.style().spacing.tooltip_width)
.sense(Sense::hover()) // don't click to bring to front
.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
});
state.tooltip_count += 1;
state.bounding_rect = state.bounding_rect.union(response.rect);
ctx.pass_state_mut(|fs| fs.tooltips.widget_tooltips.insert(widget_id, state));
inner
}
/// 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.pass_state(|fs| {
fs.tooltips
.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)
}
/// Returns `(PIVOT, POS)` to mean: put the `PIVOT` corner of the tooltip at `POS`.
///
/// Note: the position might need to be constrained to the screen,
/// (e.g. moved sideways if shown under the widget)
/// but the `Area` will take care of that.
fn find_tooltip_position(
screen_rect: Rect,
widget_rect: Rect,
allow_placing_below: bool,
tooltip_size: Vec2,
) -> (Align2, Pos2) {
let spacing = 4.0;
// Does it fit below?
if allow_placing_below
&& widget_rect.bottom() + spacing + tooltip_size.y <= screen_rect.bottom()
{
return (
Align2::LEFT_TOP,
widget_rect.left_bottom() + spacing * Vec2::DOWN,
);
impl PopupAnchor {
/// Get the rect the popup should be shown relative to.
/// Returns `Rect::from_pos` for [`PopupAnchor::Pointer`], [`PopupAnchor::PointerFixed`]
/// and [`PopupAnchor::Position`] (so the rect will be zero-sized).
pub fn rect(self, popup_id: Id, ctx: &Context) -> Option<Rect> {
match self {
Self::ParentRect(rect) => Some(rect),
Self::Pointer => ctx.pointer_hover_pos().map(Rect::from_pos),
Self::PointerFixed => ctx
.memory(|mem| mem.popup_position(popup_id))
.map(Rect::from_pos),
Self::Position(pos) => Some(Rect::from_pos(pos)),
}
}
// Does it fit above?
if screen_rect.top() + tooltip_size.y + spacing <= widget_rect.top() {
return (
Align2::LEFT_BOTTOM,
widget_rect.left_top() + spacing * Vec2::UP,
);
}
// Does it fit to the right?
if widget_rect.right() + spacing + tooltip_size.x <= screen_rect.right() {
return (
Align2::LEFT_TOP,
widget_rect.right_top() + spacing * Vec2::RIGHT,
);
}
// Does it fit to the left?
if screen_rect.left() + tooltip_size.x + spacing <= widget_rect.left() {
return (
Align2::RIGHT_TOP,
widget_rect.left_top() + spacing * Vec2::LEFT,
);
}
// It doesn't fit anywhere :(
// Just show it anyway:
(Align2::LEFT_TOP, screen_rect.left_top())
}
/// Show some text at the current pointer position (if any).
///
/// Most of the time it is easier to use [`Response::on_hover_text`].
///
/// See also [`show_tooltip`].
///
/// Returns `None` if the tooltip could not be placed.
///
/// ```
/// # egui::__run_test_ui(|ui| {
/// if ui.ui_contains_pointer() {
/// egui::show_tooltip_text(ui.ctx(), ui.layer_id(), egui::Id::new("my_tooltip"), "Helpful text");
/// }
/// # });
/// ```
pub fn show_tooltip_text(
ctx: &Context,
parent_layer: LayerId,
widget_id: Id,
text: impl Into<WidgetText>,
) -> Option<()> {
show_tooltip(ctx, parent_layer, widget_id, |ui| {
crate::widgets::Label::new(text).ui(ui);
})
}
/// Was this popup visible last frame?
pub fn was_tooltip_open_last_frame(ctx: &Context, widget_id: Id) -> bool {
let primary_tooltip_area_id = tooltip_id(widget_id, 0);
ctx.memory(|mem| {
mem.areas()
.visible_last_frame(&LayerId::new(Order::Tooltip, primary_tooltip_area_id))
})
}
/// Determines popup's close behavior
#[derive(Clone, Copy)]
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum PopupCloseBehavior {
/// Popup will be closed on click anywhere, inside or outside the popup.
///
@ -344,114 +85,480 @@ pub enum PopupCloseBehavior {
IgnoreClicks,
}
/// Helper for [`popup_above_or_below_widget`].
pub fn popup_below_widget<R>(
ui: &Ui,
popup_id: Id,
widget_response: &Response,
close_behavior: PopupCloseBehavior,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> Option<R> {
popup_above_or_below_widget(
ui,
popup_id,
widget_response,
AboveOrBelow::Below,
close_behavior,
add_contents,
)
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SetOpenCommand {
/// Set the open state to the given value
Bool(bool),
/// Toggle the open state
Toggle,
}
/// Shows a popup above or below another widget.
///
/// Useful for drop-down menus (combo boxes) or suggestion menus under text fields.
///
/// The opened popup will have a minimum width matching its parent.
///
/// You must open the popup with [`crate::Memory::open_popup`] or [`crate::Memory::toggle_popup`].
///
/// Returns `None` if the popup is not open.
///
/// ```
/// # egui::__run_test_ui(|ui| {
/// let response = ui.button("Open popup");
/// let popup_id = ui.make_persistent_id("my_unique_id");
/// if response.clicked() {
/// ui.memory_mut(|mem| mem.toggle_popup(popup_id));
/// }
/// let below = egui::AboveOrBelow::Below;
/// let close_on_click_outside = egui::popup::PopupCloseBehavior::CloseOnClickOutside;
/// egui::popup::popup_above_or_below_widget(ui, popup_id, &response, below, close_on_click_outside, |ui| {
/// ui.set_min_width(200.0); // if you want to control the size
/// ui.label("Some more info, or things you can select:");
/// ui.label("…");
/// });
/// # });
/// ```
pub fn popup_above_or_below_widget<R>(
parent_ui: &Ui,
popup_id: Id,
widget_response: &Response,
above_or_below: AboveOrBelow,
close_behavior: PopupCloseBehavior,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> Option<R> {
if !parent_ui.memory(|mem| mem.is_popup_open(popup_id)) {
return None;
impl From<bool> for SetOpenCommand {
fn from(b: bool) -> Self {
Self::Bool(b)
}
}
let (mut pos, pivot) = match above_or_below {
AboveOrBelow::Above => (widget_response.rect.left_top(), Align2::LEFT_BOTTOM),
AboveOrBelow::Below => (widget_response.rect.left_bottom(), Align2::LEFT_TOP),
};
/// How do we determine if the popup should be open or closed
enum OpenKind<'a> {
/// Always open
Open,
if let Some(to_global) = parent_ui
.ctx()
.layer_transform_to_global(parent_ui.layer_id())
{
pos = to_global * pos;
}
/// Always closed
Closed,
let frame = Frame::popup(parent_ui.style());
let frame_margin = frame.total_margin();
let inner_width = (widget_response.rect.width() - frame_margin.sum().x).max(0.0);
/// Open if the bool is true
Bool(&'a mut bool, PopupCloseBehavior),
parent_ui.ctx().pass_state_mut(|fs| {
fs.layers
.entry(parent_ui.layer_id())
.or_default()
.open_popups
.insert(popup_id)
});
/// Store the open state via [`crate::Memory`]
Memory {
set: Option<SetOpenCommand>,
close_behavior: PopupCloseBehavior,
},
}
let response = Area::new(popup_id)
.kind(UiKind::Popup)
.order(Order::Foreground)
.fixed_pos(pos)
.default_width(inner_width)
.pivot(pivot)
.show(parent_ui.ctx(), |ui| {
frame
.show(ui, |ui| {
ui.with_layout(Layout::top_down_justified(Align::LEFT), |ui| {
ui.set_min_width(inner_width);
add_contents(ui)
})
.inner
})
.inner
});
let should_close = match close_behavior {
PopupCloseBehavior::CloseOnClick => widget_response.clicked_elsewhere(),
PopupCloseBehavior::CloseOnClickOutside => {
widget_response.clicked_elsewhere() && response.response.clicked_elsewhere()
impl<'a> OpenKind<'a> {
/// Returns `true` if the popup should be open
fn is_open(&self, id: Id, ctx: &Context) -> bool {
match self {
OpenKind::Open => true,
OpenKind::Closed => false,
OpenKind::Bool(open, _) => **open,
OpenKind::Memory { .. } => ctx.memory(|mem| mem.is_popup_open(id)),
}
PopupCloseBehavior::IgnoreClicks => false,
};
if parent_ui.input(|i| i.key_pressed(Key::Escape)) || should_close {
parent_ui.memory_mut(|mem| mem.close_popup());
}
Some(response.inner)
}
/// Is the popup a popup, tooltip or menu?
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PopupKind {
Popup,
Tooltip,
Menu,
}
pub struct Popup<'a> {
id: Id,
ctx: Context,
anchor: PopupAnchor,
rect_align: RectAlign,
alternative_aligns: Option<&'a [RectAlign]>,
layer_id: LayerId,
open_kind: OpenKind<'a>,
kind: PopupKind,
/// Gap between the anchor and the popup
gap: f32,
/// Used later depending on close behavior
widget_clicked_elsewhere: bool,
/// Default width passed to the Area
width: Option<f32>,
sense: Sense,
layout: Layout,
frame: Option<Frame>,
}
impl<'a> Popup<'a> {
/// Create a new popup
pub fn new(id: Id, ctx: Context, anchor: impl Into<PopupAnchor>, layer_id: LayerId) -> Self {
Self {
id,
ctx,
anchor: anchor.into(),
open_kind: OpenKind::Open,
kind: PopupKind::Popup,
layer_id,
rect_align: RectAlign::BOTTOM_START,
alternative_aligns: None,
gap: 0.0,
widget_clicked_elsewhere: false,
width: None,
sense: Sense::click(),
layout: Layout::default(),
frame: None,
}
}
/// Set the kind of the popup. Used for [`Area::kind`] and [`Area::order`].
#[inline]
pub fn kind(mut self, kind: PopupKind) -> Self {
self.kind = kind;
self
}
/// Set the [`RectAlign`] of the popup relative to the [`PopupAnchor`].
/// This is the default position, and will be used if it fits.
/// See [`Self::align_alternatives`] for more on this.
#[inline]
pub fn align(mut self, position_align: RectAlign) -> Self {
self.rect_align = position_align;
self
}
/// Set alternative positions to try if the default one doesn't fit. Set to an empty slice to
/// always use the position you set with [`Self::align`].
/// By default, this will try [`RectAlign::symmetries`] and then [`RectAlign::MENU_ALIGNS`].
#[inline]
pub fn align_alternatives(mut self, alternatives: &'a [RectAlign]) -> Self {
self.alternative_aligns = Some(alternatives);
self
}
/// Show a popup relative to some widget.
/// The popup will be always open.
///
/// See [`Self::menu`] and [`Self::context_menu`] for common use cases.
pub fn from_response(response: &Response) -> Self {
let mut popup = Self::new(
response.id.with("popup"),
response.ctx.clone(),
response,
response.layer_id,
);
popup.widget_clicked_elsewhere = response.clicked_elsewhere();
popup
}
/// Show a popup when the widget was clicked.
/// Sets the layout to `Layout::top_down_justified(Align::Min)`.
pub fn menu(response: &Response) -> Self {
Self::from_response(response)
.open_memory(
if response.clicked() {
Some(SetOpenCommand::Toggle)
} else {
None
},
PopupCloseBehavior::CloseOnClick,
)
.layout(Layout::top_down_justified(Align::Min))
}
/// Show a context menu when the widget was secondary clicked.
/// Sets the layout to `Layout::top_down_justified(Align::Min)`.
/// In contrast to [`Self::menu`], this will open at the pointer position.
pub fn context_menu(response: &Response) -> Self {
Self::from_response(response)
.open_memory(
response
.secondary_clicked()
.then_some(SetOpenCommand::Bool(true)),
PopupCloseBehavior::CloseOnClick,
)
.layout(Layout::top_down_justified(Align::Min))
.at_pointer_fixed()
}
/// Force the popup to be open or closed.
#[inline]
pub fn open(mut self, open: bool) -> Self {
self.open_kind = if open {
OpenKind::Open
} else {
OpenKind::Closed
};
self
}
/// Store the open state via [`crate::Memory`].
/// You can set the state via the first [`SetOpenCommand`] param.
#[inline]
pub fn open_memory(
mut self,
set_state: impl Into<Option<SetOpenCommand>>,
close_behavior: PopupCloseBehavior,
) -> Self {
self.open_kind = OpenKind::Memory {
set: set_state.into(),
close_behavior,
};
self
}
/// Store the open state via a mutable bool.
#[inline]
pub fn open_bool(mut self, open: &'a mut bool, close_behavior: PopupCloseBehavior) -> Self {
self.open_kind = OpenKind::Bool(open, close_behavior);
self
}
/// Set the close behavior of the popup.
///
/// This will do nothing if [`Popup::open`] was called.
#[inline]
pub fn close_behavior(mut self, close_behavior: PopupCloseBehavior) -> Self {
match &mut self.open_kind {
OpenKind::Memory {
close_behavior: behavior,
..
}
| OpenKind::Bool(_, behavior) => {
*behavior = close_behavior;
}
_ => {}
}
self
}
/// Show the popup relative to the pointer.
#[inline]
pub fn at_pointer(mut self) -> Self {
self.anchor = PopupAnchor::Pointer;
self
}
/// Remember the pointer position at the time of opening the popup, and show the popup
/// relative to that.
#[inline]
pub fn at_pointer_fixed(mut self) -> Self {
self.anchor = PopupAnchor::PointerFixed;
self
}
/// Show the popup relative to a specific position.
#[inline]
pub fn at_position(mut self, position: Pos2) -> Self {
self.anchor = PopupAnchor::Position(position);
self
}
/// Show the popup relative to the given [`PopupAnchor`].
#[inline]
pub fn anchor(mut self, anchor: impl Into<PopupAnchor>) -> Self {
self.anchor = anchor.into();
self
}
/// Set the gap between the anchor and the popup.
#[inline]
pub fn gap(mut self, gap: f32) -> Self {
self.gap = gap;
self
}
/// Set the sense of the popup.
#[inline]
pub fn sense(mut self, sense: Sense) -> Self {
self.sense = sense;
self
}
/// Set the layout of the popup.
#[inline]
pub fn layout(mut self, layout: Layout) -> Self {
self.layout = layout;
self
}
/// The width that will be passed to [`Area::default_width`].
#[inline]
pub fn width(mut self, width: f32) -> Self {
self.width = Some(width);
self
}
/// Set the id of the Area.
#[inline]
pub fn id(mut self, id: Id) -> Self {
self.id = id;
self
}
/// Get the [`Context`]
pub fn ctx(&self) -> &Context {
&self.ctx
}
/// Return the [`PopupAnchor`] of the popup.
pub fn get_anchor(&self) -> PopupAnchor {
self.anchor
}
/// Return the anchor rect of the popup.
///
/// Returns `None` if the anchor is [`PopupAnchor::Pointer`] and there is no pointer.
pub fn get_anchor_rect(&self) -> Option<Rect> {
self.anchor.rect(self.id, &self.ctx)
}
/// Get the expected rect the popup will be shown in.
///
/// Returns `None` if the popup wasn't shown before or anchor is `PopupAnchor::Pointer` and
/// there is no pointer.
pub fn get_popup_rect(&self) -> Option<Rect> {
let size = self.get_expected_size();
if let Some(size) = size {
self.get_anchor_rect()
.map(|anchor| self.get_best_align().align_rect(&anchor, size, self.gap))
} else {
None
}
}
/// Get the id of the popup.
pub fn get_id(&self) -> Id {
self.id
}
/// Is the popup open?
pub fn is_open(&self) -> bool {
match &self.open_kind {
OpenKind::Open => true,
OpenKind::Closed => false,
OpenKind::Bool(open, _) => **open,
OpenKind::Memory { .. } => self.ctx.memory(|mem| mem.is_popup_open(self.id)),
}
}
pub fn get_expected_size(&self) -> Option<Vec2> {
AreaState::load(&self.ctx, self.id).and_then(|area| area.size)
}
/// Calculate the best alignment for the popup, based on the last size and screen rect.
pub fn get_best_align(&self) -> RectAlign {
let expected_popup_size = self
.get_expected_size()
.unwrap_or(vec2(self.width.unwrap_or(0.0), 0.0));
let Some(anchor_rect) = self.anchor.rect(self.id, &self.ctx) else {
return self.rect_align;
};
RectAlign::find_best_align(
#[allow(clippy::iter_on_empty_collections)]
once(self.rect_align).chain(
self.alternative_aligns
// Need the empty slice so the iters have the same type so we can unwrap_or
.map(|a| a.iter().copied().chain([].iter().copied()))
.unwrap_or(
self.rect_align
.symmetries()
.iter()
.copied()
.chain(RectAlign::MENU_ALIGNS.iter().copied()),
),
),
self.ctx.screen_rect(),
anchor_rect,
self.gap,
expected_popup_size,
)
}
/// Show the popup.
/// Returns `None` if the popup is not open or anchor is `PopupAnchor::Pointer` and there is
/// no pointer.
pub fn show<R>(self, content: impl FnOnce(&mut Ui) -> R) -> Option<InnerResponse<R>> {
let best_align = self.get_best_align();
let Popup {
id,
ctx,
anchor,
open_kind,
kind,
layer_id,
rect_align: _,
alternative_aligns: _,
gap,
widget_clicked_elsewhere,
width,
sense,
layout,
frame,
} = self;
let hover_pos = ctx.pointer_hover_pos();
if let OpenKind::Memory { set, .. } = open_kind {
ctx.memory_mut(|mem| match set {
Some(SetOpenCommand::Bool(open)) => {
if open {
match self.anchor {
PopupAnchor::PointerFixed => {
mem.open_popup_at(id, hover_pos);
}
_ => mem.open_popup(id),
}
} else {
mem.close_popup();
}
}
Some(SetOpenCommand::Toggle) => {
mem.toggle_popup(id);
}
None => {}
});
}
if !open_kind.is_open(id, &ctx) {
return None;
}
let (ui_kind, order) = match kind {
PopupKind::Popup => (UiKind::Popup, Order::Foreground),
PopupKind::Tooltip => (UiKind::Tooltip, Order::Tooltip),
PopupKind::Menu => (UiKind::Menu, Order::Foreground),
};
if kind == PopupKind::Popup {
ctx.pass_state_mut(|fs| {
fs.layers
.entry(layer_id)
.or_default()
.open_popups
.insert(id)
});
}
let anchor_rect = anchor.rect(id, &ctx)?;
let (pivot, anchor) = best_align.pivot_pos(&anchor_rect, gap);
let mut area = Area::new(id)
.order(order)
.kind(ui_kind)
.pivot(pivot)
.fixed_pos(anchor)
.sense(sense)
.layout(layout);
if let Some(width) = width {
area = area.default_width(width);
}
let frame = frame.unwrap_or_else(|| Frame::popup(&ctx.style()));
let response = area.show(&ctx, |ui| frame.show(ui, content).inner);
let should_close = |close_behavior| {
let should_close = match close_behavior {
PopupCloseBehavior::CloseOnClick => widget_clicked_elsewhere,
PopupCloseBehavior::CloseOnClickOutside => {
widget_clicked_elsewhere && response.response.clicked_elsewhere()
}
PopupCloseBehavior::IgnoreClicks => false,
};
should_close || ctx.input(|i| i.key_pressed(Key::Escape))
};
match open_kind {
OpenKind::Open | OpenKind::Closed => {}
OpenKind::Bool(open, close_behavior) => {
if should_close(close_behavior) {
*open = false;
}
}
OpenKind::Memory { close_behavior, .. } => {
if should_close(close_behavior) {
ctx.memory_mut(|mem| mem.close_popup());
}
}
}
Some(response)
}
}

View File

@ -0,0 +1,376 @@
use crate::pass_state::PerWidgetTooltipState;
use crate::{
AreaState, Context, Id, InnerResponse, LayerId, Layout, Order, Popup, PopupAnchor, PopupKind,
Response, Sense,
};
use emath::Vec2;
pub struct Tooltip<'a> {
pub popup: Popup<'a>,
layer_id: LayerId,
widget_id: Id,
}
impl<'a> Tooltip<'a> {
/// Show a tooltip that is always open
pub fn new(
widget_id: Id,
ctx: Context,
anchor: impl Into<PopupAnchor>,
layer_id: LayerId,
) -> Self {
Self {
// TODO(lucasmerlin): Set width somehow (we're missing context here)
popup: Popup::new(widget_id, ctx, anchor.into(), layer_id)
.kind(PopupKind::Tooltip)
.gap(4.0)
.sense(Sense::hover()),
layer_id,
widget_id,
}
}
/// Show a tooltip for a widget. Always open (as long as this function is called).
pub fn for_widget(response: &Response) -> Self {
let popup = Popup::from_response(response)
.kind(PopupKind::Tooltip)
.gap(4.0)
.width(response.ctx.style().spacing.tooltip_width)
.sense(Sense::hover());
Self {
popup,
layer_id: response.layer_id,
widget_id: response.id,
}
}
/// Show a tooltip when hovering an enabled widget.
pub fn for_enabled(response: &Response) -> Self {
let mut tooltip = Self::for_widget(response);
tooltip.popup = tooltip
.popup
.open(response.enabled() && Self::should_show_tooltip(response));
tooltip
}
/// Show a tooltip when hovering a disabled widget.
pub fn for_disabled(response: &Response) -> Self {
let mut tooltip = Self::for_widget(response);
tooltip.popup = tooltip
.popup
.open(!response.enabled() && Self::should_show_tooltip(response));
tooltip
}
/// Show the tooltip at the pointer position.
#[inline]
pub fn at_pointer(mut self) -> Self {
self.popup = self.popup.at_pointer();
self
}
/// Set the gap between the tooltip and the anchor
///
/// Default: 5.0
#[inline]
pub fn gap(mut self, gap: f32) -> Self {
self.popup = self.popup.gap(gap);
self
}
/// Set the layout of the tooltip
#[inline]
pub fn layout(mut self, layout: Layout) -> Self {
self.popup = self.popup.layout(layout);
self
}
/// Set the width of the tooltip
#[inline]
pub fn width(mut self, width: f32) -> Self {
self.popup = self.popup.width(width);
self
}
/// Show the tooltip
pub fn show<R>(self, content: impl FnOnce(&mut crate::Ui) -> R) -> Option<InnerResponse<R>> {
let Self {
mut popup,
layer_id: parent_layer,
widget_id,
} = self;
if !popup.is_open() {
return None;
}
let rect = popup.get_anchor_rect()?;
let mut state = popup.ctx().pass_state_mut(|fs| {
// Remember that this is the widget showing the tooltip:
fs.layers
.entry(parent_layer)
.or_default()
.widget_with_tooltip = Some(widget_id);
fs.tooltips
.widget_tooltips
.get(&widget_id)
.copied()
.unwrap_or(PerWidgetTooltipState {
bounding_rect: rect,
tooltip_count: 0,
})
});
let tooltip_area_id = Self::tooltip_id(widget_id, state.tooltip_count);
popup = popup.anchor(state.bounding_rect).id(tooltip_area_id);
let response = popup.show(|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;
content(ui)
});
// The popup might not be shown on at_pointer if there is no pointer.
if let Some(response) = &response {
state.tooltip_count += 1;
state.bounding_rect = state.bounding_rect.union(response.response.rect);
response
.response
.ctx
.pass_state_mut(|fs| fs.tooltips.widget_tooltips.insert(widget_id, state));
Self::remember_that_tooltip_was_shown(&response.response.ctx);
}
response
}
fn when_was_a_toolip_last_shown_id() -> Id {
Id::new("when_was_a_toolip_last_shown")
}
pub fn seconds_since_last_tooltip(ctx: &Context) -> f32 {
let when_was_a_toolip_last_shown =
ctx.data(|d| d.get_temp::<f64>(Self::when_was_a_toolip_last_shown_id()));
if let Some(when_was_a_toolip_last_shown) = when_was_a_toolip_last_shown {
let now = ctx.input(|i| i.time);
(now - when_was_a_toolip_last_shown) as f32
} else {
f32::INFINITY
}
}
fn remember_that_tooltip_was_shown(ctx: &Context) {
let now = ctx.input(|i| i.time);
ctx.data_mut(|data| data.insert_temp::<f64>(Self::when_was_a_toolip_last_shown_id(), now));
}
/// 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.pass_state(|fs| {
fs.tooltips
.widget_tooltips
.get(&widget_id)
.map_or(0, |state| state.tooltip_count)
});
Self::tooltip_id(widget_id, tooltip_count)
}
pub fn tooltip_id(widget_id: Id, tooltip_count: usize) -> Id {
widget_id.with(tooltip_count)
}
/// Should we show a tooltip for this response?
pub fn should_show_tooltip(response: &Response) -> bool {
if response.ctx.memory(|mem| mem.everything_is_visible()) {
return true;
}
let any_open_popups = response.ctx.prev_pass_state(|fs| {
fs.layers
.get(&response.layer_id)
.is_some_and(|layer| !layer.open_popups.is_empty())
});
if any_open_popups {
// Hide tooltips if the user opens a popup (menu, combo-box, etc.) in the same layer.
return false;
}
let style = response.ctx.style();
let tooltip_delay = style.interaction.tooltip_delay;
let tooltip_grace_time = style.interaction.tooltip_grace_time;
let (
time_since_last_scroll,
time_since_last_click,
time_since_last_pointer_movement,
pointer_pos,
pointer_dir,
) = response.ctx.input(|i| {
(
i.time_since_last_scroll(),
i.pointer.time_since_last_click(),
i.pointer.time_since_last_movement(),
i.pointer.hover_pos(),
i.pointer.direction(),
)
});
if time_since_last_scroll < tooltip_delay {
// See https://github.com/emilk/egui/issues/4781
// Note that this means we cannot have `ScrollArea`s in a tooltip.
response
.ctx
.request_repaint_after_secs(tooltip_delay - time_since_last_scroll);
return false;
}
let is_our_tooltip_open = response.is_tooltip_open();
if is_our_tooltip_open {
// 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 = response.ctx.viewport(|vp| {
vp.prev_pass
.widgets
.get_layer(tooltip_layer_id)
.any(|w| w.enabled && 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(&response.ctx, tooltip_id) {
let rect = area.rect();
if let Some(pos) = pointer_pos {
if rect.contains(pos) {
return true; // hovering interactive tooltip
}
if pointer_dir != Vec2::ZERO
&& rect.intersects_ray(pos, pointer_dir.normalized())
{
return true; // on the way to interactive tooltip
}
}
}
}
}
let clicked_more_recently_than_moved =
time_since_last_click < time_since_last_pointer_movement + 0.1;
if clicked_more_recently_than_moved {
// It is common to click a widget and then rest the mouse there.
// It would be annoying to then see a tooltip for it immediately.
// Similarly, clicking should hide the existing tooltip.
// Only hovering should lead to a tooltip, not clicking.
// The offset is only to allow small movement just right after the click.
return false;
}
if is_our_tooltip_open {
// Check if we should automatically stay open:
if pointer_pos.is_some_and(|pointer_pos| response.rect.contains(pointer_pos)) {
// Handle the case of a big tooltip that covers the widget:
return true;
}
}
let is_other_tooltip_open = response.ctx.prev_pass_state(|fs| {
if let Some(already_open_tooltip) = fs
.layers
.get(&response.layer_id)
.and_then(|layer| layer.widget_with_tooltip)
{
already_open_tooltip != response.id
} else {
false
}
});
if is_other_tooltip_open {
// We only allow one tooltip per layer. First one wins. It is up to that tooltip to close itself.
return false;
}
// Fast early-outs:
if response.enabled() {
if !response.hovered() || !response.ctx.input(|i| i.pointer.has_pointer()) {
return false;
}
} else if !response
.ctx
.rect_contains_pointer(response.layer_id, response.rect)
{
return false;
}
// There is a tooltip_delay before showing the first tooltip,
// but once one tooltip is show, moving the mouse cursor to
// another widget should show the tooltip for that widget right away.
// Let the user quickly move over some dead space to hover the next thing
let tooltip_was_recently_shown =
Self::seconds_since_last_tooltip(&response.ctx) < tooltip_grace_time;
if !tooltip_was_recently_shown && !is_our_tooltip_open {
if style.interaction.show_tooltips_only_when_still {
// We only show the tooltip when the mouse pointer is still.
if !response
.ctx
.input(|i| i.pointer.is_still() && i.smooth_scroll_delta == Vec2::ZERO)
{
// wait for mouse to stop
response.ctx.request_repaint();
return false;
}
}
let time_since_last_interaction = time_since_last_scroll
.min(time_since_last_pointer_movement)
.min(time_since_last_click);
let time_til_tooltip = tooltip_delay - time_since_last_interaction;
if 0.0 < time_til_tooltip {
// Wait until the mouse has been still for a while
response.ctx.request_repaint_after_secs(time_til_tooltip);
return false;
}
}
// We don't want tooltips of things while we are dragging them,
// but we do want tooltips while holding down on an item on a touch screen.
if response
.ctx
.input(|i| i.pointer.any_down() && i.pointer.has_moved_too_much_for_a_click)
{
return false;
}
// All checks passed: show the tooltip!
true
}
/// Was this tooltip visible last frame?
pub fn was_tooltip_open_last_frame(ctx: &Context, widget_id: Id) -> bool {
let primary_tooltip_area_id = Self::tooltip_id(widget_id, 0);
ctx.memory(|mem| {
mem.areas()
.visible_last_frame(&LayerId::new(Order::Tooltip, primary_tooltip_area_id))
})
}
}

View File

@ -458,7 +458,8 @@ pub use epaint::emath;
pub use ecolor::hex_color;
pub use ecolor::{Color32, Rgba};
pub use emath::{
lerp, pos2, remap, remap_clamp, vec2, Align, Align2, NumExt, Pos2, Rangef, Rect, Vec2, Vec2b,
lerp, pos2, remap, remap_clamp, vec2, Align, Align2, NumExt, Pos2, Rangef, Rect, RectAlign,
Vec2, Vec2b,
};
pub use epaint::{
mutex,

View File

@ -89,8 +89,12 @@ pub struct Memory {
/// Which popup-window is open (if any)?
/// Could be a combo box, color picker, menu, etc.
/// Optionally stores the position of the popup (usually this would be the position where
/// the user clicked).
/// If position is [`None`], the popup position will be calculated based on some configuration
/// (e.g. relative to some other widget).
#[cfg_attr(feature = "persistence", serde(skip))]
popup: Option<Id>,
popup: Option<(Id, Option<Pos2>)>,
#[cfg_attr(feature = "persistence", serde(skip))]
everything_is_visible: bool,
@ -1070,7 +1074,7 @@ impl Memory {
impl Memory {
/// Is the given popup open?
pub fn is_popup_open(&self, popup_id: Id) -> bool {
self.popup == Some(popup_id) || self.everything_is_visible()
self.popup.is_some_and(|(id, _)| id == popup_id) || self.everything_is_visible()
}
/// Is any popup open?
@ -1080,7 +1084,18 @@ impl Memory {
/// Open the given popup and close all others.
pub fn open_popup(&mut self, popup_id: Id) {
self.popup = Some(popup_id);
self.popup = Some((popup_id, None));
}
/// Open the popup and remember its position.
pub fn open_popup_at(&mut self, popup_id: Id, pos: impl Into<Option<Pos2>>) {
self.popup = Some((popup_id, pos.into()));
}
/// Get the position for this popup.
pub fn popup_position(&self, id: Id) -> Option<Pos2> {
self.popup
.and_then(|(popup_id, pos)| if popup_id == id { pos } else { None })
}
/// Close the open popup, if any.

View File

@ -2,8 +2,8 @@ use std::{any::Any, sync::Arc};
use crate::{
emath::{Align, Pos2, Rect, Vec2},
menu, pass_state, AreaState, Context, CursorIcon, Id, LayerId, Order, PointerButton, Sense, Ui,
WidgetRect, WidgetText,
menu, pass_state, Context, CursorIcon, Id, LayerId, PointerButton, Popup, PopupKind, Sense,
Tooltip, Ui, WidgetRect, WidgetText,
};
// ----------------------------------------------------------------------------
@ -550,36 +550,22 @@ impl Response {
/// ```
#[doc(alias = "tooltip")]
pub fn on_hover_ui(self, add_contents: impl FnOnce(&mut Ui)) -> Self {
if self.flags.contains(Flags::ENABLED) && self.should_show_hover_ui() {
self.show_tooltip_ui(add_contents);
}
Tooltip::for_enabled(&self).show(add_contents);
self
}
/// Show this UI when hovering if the widget is disabled.
pub fn on_disabled_hover_ui(self, add_contents: impl FnOnce(&mut Ui)) -> Self {
if !self.enabled() && self.should_show_hover_ui() {
crate::containers::show_tooltip_for(
&self.ctx,
self.layer_id,
self.id,
&self.rect,
add_contents,
);
}
Tooltip::for_disabled(&self).show(add_contents);
self
}
/// Like `on_hover_ui`, but show the ui next to cursor.
pub fn on_hover_ui_at_pointer(self, add_contents: impl FnOnce(&mut Ui)) -> Self {
if self.enabled() && self.should_show_hover_ui() {
crate::containers::show_tooltip_at_pointer(
&self.ctx,
self.layer_id,
self.id,
add_contents,
);
}
Tooltip::for_enabled(&self)
.at_pointer()
.gap(12.0)
.show(add_contents);
self
}
@ -587,13 +573,9 @@ impl Response {
///
/// This can be used to give attention to a widget during a tutorial.
pub fn show_tooltip_ui(&self, add_contents: impl FnOnce(&mut Ui)) {
crate::containers::show_tooltip_for(
&self.ctx,
self.layer_id,
self.id,
&self.rect,
add_contents,
);
Popup::from_response(self)
.kind(PopupKind::Tooltip)
.show(add_contents);
}
/// Always show this tooltip, even if disabled and the user isn't hovering it.
@ -607,180 +589,7 @@ impl Response {
/// Was the tooltip open last frame?
pub fn is_tooltip_open(&self) -> bool {
crate::popup::was_tooltip_open_last_frame(&self.ctx, self.id)
}
fn should_show_hover_ui(&self) -> bool {
if self.ctx.memory(|mem| mem.everything_is_visible()) {
return true;
}
let any_open_popups = self.ctx.prev_pass_state(|fs| {
fs.layers
.get(&self.layer_id)
.is_some_and(|layer| !layer.open_popups.is_empty())
});
if any_open_popups {
// Hide tooltips if the user opens a popup (menu, combo-box, etc) in the same layer.
return false;
}
let style = self.ctx.style();
let tooltip_delay = style.interaction.tooltip_delay;
let tooltip_grace_time = style.interaction.tooltip_grace_time;
let (
time_since_last_scroll,
time_since_last_click,
time_since_last_pointer_movement,
pointer_pos,
pointer_dir,
) = self.ctx.input(|i| {
(
i.time_since_last_scroll(),
i.pointer.time_since_last_click(),
i.pointer.time_since_last_movement(),
i.pointer.hover_pos(),
i.pointer.direction(),
)
});
if time_since_last_scroll < tooltip_delay {
// See https://github.com/emilk/egui/issues/4781
// Note that this means we cannot have `ScrollArea`s in a tooltip.
self.ctx
.request_repaint_after_secs(tooltip_delay - time_since_last_scroll);
return false;
}
let is_our_tooltip_open = self.is_tooltip_open();
if is_our_tooltip_open {
// Check if we should automatically stay open:
let tooltip_id = crate::next_tooltip_id(&self.ctx, self.id);
let tooltip_layer_id = LayerId::new(Order::Tooltip, tooltip_id);
let tooltip_has_interactive_widget = self.ctx.viewport(|vp| {
vp.prev_pass
.widgets
.get_layer(tooltip_layer_id)
.any(|w| w.enabled && 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();
if let Some(pos) = pointer_pos {
if rect.contains(pos) {
return true; // hovering interactive tooltip
}
if pointer_dir != Vec2::ZERO
&& rect.intersects_ray(pos, pointer_dir.normalized())
{
return true; // on the way to interactive tooltip
}
}
}
}
}
let clicked_more_recently_than_moved =
time_since_last_click < time_since_last_pointer_movement + 0.1;
if clicked_more_recently_than_moved {
// It is common to click a widget and then rest the mouse there.
// It would be annoying to then see a tooltip for it immediately.
// Similarly, clicking should hide the existing tooltip.
// Only hovering should lead to a tooltip, not clicking.
// The offset is only to allow small movement just right after the click.
return false;
}
if is_our_tooltip_open {
// Check if we should automatically stay open:
if pointer_pos.is_some_and(|pointer_pos| self.rect.contains(pointer_pos)) {
// Handle the case of a big tooltip that covers the widget:
return true;
}
}
let is_other_tooltip_open = self.ctx.prev_pass_state(|fs| {
if let Some(already_open_tooltip) = fs
.layers
.get(&self.layer_id)
.and_then(|layer| layer.widget_with_tooltip)
{
already_open_tooltip != self.id
} else {
false
}
});
if is_other_tooltip_open {
// We only allow one tooltip per layer. First one wins. It is up to that tooltip to close itself.
return false;
}
// Fast early-outs:
if self.enabled() {
if !self.hovered() || !self.ctx.input(|i| i.pointer.has_pointer()) {
return false;
}
} else if !self.ctx.rect_contains_pointer(self.layer_id, self.rect) {
return false;
}
// There is a tooltip_delay before showing the first tooltip,
// but once one tooltip is show, moving the mouse cursor to
// another widget should show the tooltip for that widget right away.
// Let the user quickly move over some dead space to hover the next thing
let tooltip_was_recently_shown =
crate::popup::seconds_since_last_tooltip(&self.ctx) < tooltip_grace_time;
if !tooltip_was_recently_shown && !is_our_tooltip_open {
if 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() && i.smooth_scroll_delta == Vec2::ZERO)
{
// wait for mouse to stop
self.ctx.request_repaint();
return false;
}
}
let time_since_last_interaction = time_since_last_scroll
.min(time_since_last_pointer_movement)
.min(time_since_last_click);
let time_til_tooltip = tooltip_delay - time_since_last_interaction;
if 0.0 < time_til_tooltip {
// Wait until the mouse has been still for a while
self.ctx.request_repaint_after_secs(time_til_tooltip);
return false;
}
}
// We don't want tooltips of things while we are dragging them,
// but we do want tooltips while holding down on an item on a touch screen.
if self
.ctx
.input(|i| i.pointer.any_down() && i.pointer.has_moved_too_much_for_a_click)
{
return false;
}
// All checks passed: show the tooltip!
true
Tooltip::was_tooltip_open_last_frame(&self.ctx, self.id)
}
/// Like `on_hover_text`, but show the text next to cursor.

View File

@ -1,3 +1,5 @@
use egui::{ComboBox, Popup};
#[derive(Clone, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct ContextMenus {}
@ -32,6 +34,20 @@ impl crate::View for ContextMenus {
}
});
ui.horizontal(|ui| {
let response = ui.button("New menu");
Popup::menu(&response).show(Self::nested_menus);
let response = ui.button("New context menu");
Popup::context_menu(&response).show(Self::nested_menus);
ComboBox::new("Hi", "Hi").show_ui(ui, |ui| {
_ = ui.selectable_label(false, "I have some long text that should be wrapped");
_ = ui.selectable_label(false, "Short");
_ = ui.selectable_label(false, "Medium length");
});
});
ui.vertical_centered(|ui| {
ui.add(crate::egui_github_link_file!());
});
@ -51,6 +67,7 @@ impl ContextMenus {
ui.close_menu();
}
let _ = ui.button("Item");
ui.menu_button("Recursive", Self::nested_menus)
});
ui.menu_button("SubMenu", |ui| {
if ui.button("Open…").clicked() {

View File

@ -78,6 +78,7 @@ impl Default for DemoGroups {
Box::<super::multi_touch::MultiTouch>::default(),
Box::<super::painting::Painting>::default(),
Box::<super::panels::Panels>::default(),
Box::<super::popups::PopupsDemo>::default(),
Box::<super::scene::SceneDemo>::default(),
Box::<super::screenshot::Screenshot>::default(),
Box::<super::scrolling::Scrolling>::default(),

View File

@ -23,6 +23,7 @@ pub mod paint_bezier;
pub mod painting;
pub mod panels;
pub mod password;
mod popups;
pub mod scene;
pub mod screenshot;
pub mod scrolling;

View File

@ -0,0 +1,181 @@
use egui::{vec2, Align2, ComboBox, Frame, Id, Popup, PopupCloseBehavior, RectAlign, Tooltip, Ui};
/// Showcase [`Popup`].
#[derive(Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(default))]
pub struct PopupsDemo {
align4: RectAlign,
gap: f32,
#[cfg_attr(feature = "serde", serde(skip))]
close_behavior: PopupCloseBehavior,
popup_open: bool,
}
impl PopupsDemo {
fn apply_options<'a>(&self, popup: Popup<'a>) -> Popup<'a> {
popup
.align(self.align4)
.gap(self.gap)
.close_behavior(self.close_behavior)
}
}
impl Default for PopupsDemo {
fn default() -> Self {
Self {
align4: RectAlign::default(),
gap: 4.0,
close_behavior: PopupCloseBehavior::CloseOnClick,
popup_open: false,
}
}
}
impl crate::Demo for PopupsDemo {
fn name(&self) -> &'static str {
"\u{2755} Popups"
}
fn show(&mut self, ctx: &egui::Context, open: &mut bool) {
egui::Window::new(self.name())
.open(open)
.resizable(false)
.default_width(250.0)
.constrain(false)
.show(ctx, |ui| {
use crate::View as _;
self.ui(ui);
});
}
}
impl crate::View for PopupsDemo {
fn ui(&mut self, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
ui.style_mut().spacing.item_spacing.x = 0.0;
let align_combobox = |ui: &mut Ui, label: &str, align: &mut Align2| {
let aligns = [
(Align2::LEFT_TOP, "Left top"),
(Align2::LEFT_CENTER, "Left center"),
(Align2::LEFT_BOTTOM, "Left bottom"),
(Align2::CENTER_TOP, "Center top"),
(Align2::CENTER_CENTER, "Center center"),
(Align2::CENTER_BOTTOM, "Center bottom"),
(Align2::RIGHT_TOP, "Right top"),
(Align2::RIGHT_CENTER, "Right center"),
(Align2::RIGHT_BOTTOM, "Right bottom"),
];
ui.label(label);
ComboBox::new(label, "")
.selected_text(aligns.iter().find(|(a, _)| a == align).unwrap().1)
.show_ui(ui, |ui| {
for (align2, name) in &aligns {
ui.selectable_value(align, *align2, *name);
}
});
};
ui.label("Align4(");
align_combobox(ui, "parent: ", &mut self.align4.parent);
ui.label(", ");
align_combobox(ui, "child: ", &mut self.align4.child);
ui.label(") ");
let presets = [
(RectAlign::TOP_START, "Top start"),
(RectAlign::TOP, "Top"),
(RectAlign::TOP_END, "Top end"),
(RectAlign::RIGHT_START, "Right start"),
(RectAlign::RIGHT, "Right Center"),
(RectAlign::RIGHT_END, "Right end"),
(RectAlign::BOTTOM_START, "Bottom start"),
(RectAlign::BOTTOM, "Bottom"),
(RectAlign::BOTTOM_END, "Bottom end"),
(RectAlign::LEFT_START, "Left start"),
(RectAlign::LEFT, "Left"),
(RectAlign::LEFT_END, "Left end"),
];
ui.label(" Presets: ");
ComboBox::new("Preset", "")
.selected_text(
presets
.iter()
.find(|(a, _)| a == &self.align4)
.map_or("Select", |(_, name)| *name),
)
.show_ui(ui, |ui| {
for (align4, name) in &presets {
ui.selectable_value(&mut self.align4, *align4, *name);
}
});
});
ui.horizontal(|ui| {
ui.label("Gap:");
ui.add(egui::DragValue::new(&mut self.gap));
});
ui.horizontal(|ui| {
ui.label("Close behavior:");
ui.selectable_value(
&mut self.close_behavior,
PopupCloseBehavior::CloseOnClick,
"Close on click",
)
.on_hover_text("Closes when the user clicks anywhere (inside or outside)");
ui.selectable_value(
&mut self.close_behavior,
PopupCloseBehavior::CloseOnClickOutside,
"Close on click outside",
)
.on_hover_text("Closes when the user clicks outside the popup");
ui.selectable_value(
&mut self.close_behavior,
PopupCloseBehavior::IgnoreClicks,
"Ignore clicks",
)
.on_hover_text("Close only when the button is clicked again");
});
ui.checkbox(&mut self.popup_open, "Show popup");
let response = Frame::group(ui.style())
.inner_margin(vec2(0.0, 25.0))
.show(ui, |ui| {
ui.vertical_centered(|ui| ui.button("Click, right-click and hover me!"))
.inner
})
.inner;
self.apply_options(Popup::menu(&response).id(Id::new("menu")))
.show(|ui| {
_ = ui.button("Menu item 1");
_ = ui.button("Menu item 2");
});
self.apply_options(Popup::context_menu(&response).id(Id::new("context_menu")))
.show(|ui| {
_ = ui.button("Context menu item 1");
_ = ui.button("Context menu item 2");
});
if self.popup_open {
self.apply_options(Popup::from_response(&response).id(Id::new("popup")))
.show(|ui| {
ui.label("Popup contents");
});
}
let mut tooltip = Tooltip::for_enabled(&response);
tooltip.popup = self.apply_options(tooltip.popup);
tooltip.show(|ui| {
ui.label("Tooltips are popups, too!");
});
ui.vertical_centered(|ui| {
ui.add(crate::egui_github_link_file!());
});
}
}

View File

@ -83,6 +83,9 @@ impl Tooltips {
ui.label("You can select this text.");
});
ui.label("This tooltip shows at the mouse cursor.")
.on_hover_text_at_pointer("Move me around!!");
ui.separator(); // ---------------------------------------------------------
let tooltip_ui = |ui: &mut egui::Ui| {

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d4cc8e0919fed5bd1ef981658626dba728435ab95da8ee96ced1fb4838d535ff
size 11741
oid sha256:eb2bc4a38f20ed0f5fced36e8e56936bee328b24a0a45127d5d3739d40331cb7
size 15514

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4806984f9c801a054cea80b89664293680abaa57cf0a95cf9682f111e3794fc1
size 25080

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c80158ac9c823f94d2830d1423236ad441dc7da31e748b6815c69663fa2a03d0
size 59662
oid sha256:92b70683a685869274749d057de174896e18dae5cb67e70221c3efdb7106cdda
size 63684

View File

@ -0,0 +1,31 @@
use kittest::Queryable;
#[test]
fn test_interactive_tooltip() {
struct State {
link_clicked: bool,
}
let mut harness = egui_kittest::Harness::new_ui_state(
|ui, state| {
ui.label("I have a tooltip").on_hover_ui(|ui| {
if ui.link("link").clicked() {
state.link_clicked = true;
}
});
},
State {
link_clicked: false,
},
);
harness.get_by_label_contains("tooltip").hover();
harness.run();
harness.get_by_label("link").hover();
harness.run();
harness.get_by_label("link").simulate_click();
harness.run();
assert!(harness.state().link_clicked);
}

View File

@ -50,6 +50,16 @@ impl Align {
}
}
/// Returns the inverse alignment.
/// `Min` becomes `Max`, `Center` stays the same, `Max` becomes `Min`.
pub fn flip(self) -> Self {
match self {
Self::Min => Self::Max,
Self::Center => Self::Center,
Self::Max => Self::Min,
}
}
/// Returns a range of given size within a specified range.
///
/// If the requested `size` is bigger than the size of `range`, then the returned
@ -170,6 +180,24 @@ impl Align2 {
vec2(self.x().to_sign(), self.y().to_sign())
}
/// Flip on the x-axis
/// e.g. `TOP_LEFT` -> `TOP_RIGHT`
pub fn flip_x(self) -> Self {
Self([self.x().flip(), self.y()])
}
/// Flip on the y-axis
/// e.g. `TOP_LEFT` -> `BOTTOM_LEFT`
pub fn flip_y(self) -> Self {
Self([self.x(), self.y().flip()])
}
/// Flip on both axes
/// e.g. `TOP_LEFT` -> `BOTTOM_RIGHT`
pub fn flip(self) -> Self {
Self([self.x().flip(), self.y().flip()])
}
/// Used e.g. to anchor a piece of text to a part of the rectangle.
/// Give a position within the rect, specified by the aligns
pub fn anchor_rect(self, rect: Rect) -> Rect {

View File

@ -34,6 +34,7 @@ mod ordered_float;
mod pos2;
mod range;
mod rect;
mod rect_align;
mod rect_transform;
mod rot2;
pub mod smart_aim;
@ -50,6 +51,7 @@ pub use self::{
pos2::*,
range::Rangef,
rect::*,
rect_align::RectAlign,
rect_transform::*,
rot2::*,
ts_transform::*,

View File

@ -0,0 +1,279 @@
use crate::{Align2, Pos2, Rect, Vec2};
/// Position a child [`Rect`] relative to a parent [`Rect`].
///
/// The corner from [`RectAlign::child`] on the new rect will be aligned to
/// the corner from [`RectAlign::parent`] on the original rect.
///
/// There are helper constants for the 12 common menu positions:
/// ```text
/// ┌───────────┐ ┌────────┐ ┌─────────┐
/// │ TOP_START │ │ TOP │ │ TOP_END │
/// └───────────┘ └────────┘ └─────────┘
/// ┌──────────┐ ┌────────────────────────────────────┐ ┌───────────┐
/// │LEFT_START│ │ │ │RIGHT_START│
/// └──────────┘ │ │ └───────────┘
/// ┌──────────┐ │ │ ┌───────────┐
/// │ LEFT │ │ some_rect │ │ RIGHT │
/// └──────────┘ │ │ └───────────┘
/// ┌──────────┐ │ │ ┌───────────┐
/// │ LEFT_END │ │ │ │ RIGHT_END │
/// └──────────┘ └────────────────────────────────────┘ └───────────┘
/// ┌────────────┐ ┌──────┐ ┌──────────┐
/// │BOTTOM_START│ │BOTTOM│ │BOTTOM_END│
/// └────────────┘ └──────┘ └──────────┘
/// ```
// There is no `new` function on purpose, since writing out `parent` and `child` is more
// reasonable.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct RectAlign {
/// The alignment in the parent (original) rect.
pub parent: Align2,
/// The alignment in the child (new) rect.
pub child: Align2,
}
impl Default for RectAlign {
fn default() -> Self {
Self::BOTTOM_START
}
}
impl RectAlign {
/// Along the top edge, leftmost.
pub const TOP_START: Self = Self {
parent: Align2::LEFT_TOP,
child: Align2::LEFT_BOTTOM,
};
/// Along the top edge, centered.
pub const TOP: Self = Self {
parent: Align2::CENTER_TOP,
child: Align2::CENTER_BOTTOM,
};
/// Along the top edge, rightmost.
pub const TOP_END: Self = Self {
parent: Align2::RIGHT_TOP,
child: Align2::RIGHT_BOTTOM,
};
/// Along the right edge, topmost.
pub const RIGHT_START: Self = Self {
parent: Align2::RIGHT_TOP,
child: Align2::LEFT_TOP,
};
/// Along the right edge, centered.
pub const RIGHT: Self = Self {
parent: Align2::RIGHT_CENTER,
child: Align2::LEFT_CENTER,
};
/// Along the right edge, bottommost.
pub const RIGHT_END: Self = Self {
parent: Align2::RIGHT_BOTTOM,
child: Align2::LEFT_BOTTOM,
};
/// Along the bottom edge, rightmost.
pub const BOTTOM_END: Self = Self {
parent: Align2::RIGHT_BOTTOM,
child: Align2::RIGHT_TOP,
};
/// Along the bottom edge, centered.
pub const BOTTOM: Self = Self {
parent: Align2::CENTER_BOTTOM,
child: Align2::CENTER_TOP,
};
/// Along the bottom edge, leftmost.
pub const BOTTOM_START: Self = Self {
parent: Align2::LEFT_BOTTOM,
child: Align2::LEFT_TOP,
};
/// Along the left edge, bottommost.
pub const LEFT_END: Self = Self {
parent: Align2::LEFT_BOTTOM,
child: Align2::RIGHT_BOTTOM,
};
/// Along the left edge, centered.
pub const LEFT: Self = Self {
parent: Align2::LEFT_CENTER,
child: Align2::RIGHT_CENTER,
};
/// Along the left edge, topmost.
pub const LEFT_START: Self = Self {
parent: Align2::LEFT_TOP,
child: Align2::RIGHT_TOP,
};
/// The 12 most common menu positions as an array, for use with [`RectAlign::find_best_align`].
pub const MENU_ALIGNS: [Self; 12] = [
Self::BOTTOM_START,
Self::BOTTOM_END,
Self::TOP_START,
Self::TOP_END,
Self::RIGHT_END,
Self::RIGHT_START,
Self::LEFT_END,
Self::LEFT_START,
// These come last on purpose, we prefer the corner ones
Self::TOP,
Self::RIGHT,
Self::BOTTOM,
Self::LEFT,
];
/// Align in the parent rect.
pub fn parent(&self) -> Align2 {
self.parent
}
/// Align in the child rect.
pub fn child(&self) -> Align2 {
self.child
}
/// Convert an [`Align2`] to an [`RectAlign`], positioning the child rect inside the parent.
pub fn from_align2(align: Align2) -> Self {
Self {
parent: align,
child: align,
}
}
/// The center of the child rect will be aligned to a corner of the parent rect.
pub fn over_corner(align: Align2) -> Self {
Self {
parent: align,
child: Align2::CENTER_CENTER,
}
}
/// Position the child rect outside the parent rect.
pub fn outside(align: Align2) -> Self {
Self {
parent: align,
child: align.flip(),
}
}
/// Calculate the child rect based on a size and some optional gap.
pub fn align_rect(&self, parent_rect: &Rect, size: Vec2, gap: f32) -> Rect {
let (pivot, anchor) = self.pivot_pos(parent_rect, gap);
pivot.anchor_size(anchor, size)
}
/// Returns a [`Align2`] and a [`Pos2`] that you can e.g. use with `Area::fixed_pos`
/// and `Area::pivot` to align an `Area` to some rect.
pub fn pivot_pos(&self, parent_rect: &Rect, gap: f32) -> (Align2, Pos2) {
(self.child(), self.anchor(parent_rect, gap))
}
/// Returns a sign vector (-1, 0 or 1 in each direction) that can be used as an offset to the
/// child rect, creating a gap between the rects while keeping the edges aligned.
pub fn gap_vector(&self) -> Vec2 {
let mut gap = -self.child.to_sign();
// Align the edges in these cases
match *self {
Self::TOP_START | Self::TOP_END | Self::BOTTOM_START | Self::BOTTOM_END => {
gap.x = 0.0;
}
Self::LEFT_START | Self::LEFT_END | Self::RIGHT_START | Self::RIGHT_END => {
gap.y = 0.0;
}
_ => {}
}
gap
}
/// Calculator the anchor point for the child rect, based on the parent rect and an optional gap.
pub fn anchor(&self, parent_rect: &Rect, gap: f32) -> Pos2 {
let pos = self.parent.pos_in_rect(parent_rect);
let offset = self.gap_vector() * gap;
pos + offset
}
/// Flip the alignment on the x-axis.
pub fn flip_x(self) -> Self {
Self {
parent: self.parent.flip_x(),
child: self.child.flip_x(),
}
}
/// Flip the alignment on the y-axis.
pub fn flip_y(self) -> Self {
Self {
parent: self.parent.flip_y(),
child: self.child.flip_y(),
}
}
/// Flip the alignment on both axes.
pub fn flip(self) -> Self {
Self {
parent: self.parent.flip(),
child: self.child.flip(),
}
}
/// Returns the 3 alternative [`RectAlign`]s that are flipped in various ways, for use
/// with [`RectAlign::find_best_align`].
pub fn symmetries(self) -> [Self; 3] {
[self.flip_x(), self.flip_y(), self.flip()]
}
/// Look for the [`RectAlign`] that fits best in the available space.
///
/// See also:
/// - [`RectAlign::symmetries`] to calculate alternatives
/// - [`RectAlign::MENU_ALIGNS`] for the 12 common menu positions
pub fn find_best_align(
mut values_to_try: impl Iterator<Item = Self>,
available_space: Rect,
parent_rect: Rect,
gap: f32,
size: Vec2,
) -> Self {
let area = size.x * size.y;
let blocked_area = |pos: Self| {
let rect = pos.align_rect(&parent_rect, size, gap);
area - available_space.intersect(rect).area()
};
let first = values_to_try.next().unwrap_or_default();
if blocked_area(first) == 0.0 {
return first;
}
let mut best_area = blocked_area(first);
let mut best = first;
for align in values_to_try {
let blocked = blocked_area(align);
if blocked == 0.0 {
return align;
}
if blocked < best_area {
best = align;
best_area = blocked;
}
}
best
}
}

View File

@ -1,7 +1,7 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release
#![allow(rustdoc::missing_crate_level_docs)] // it's an example
use eframe::egui::{popup_below_widget, CentralPanel, ComboBox, Id, PopupCloseBehavior};
use eframe::egui::{CentralPanel, ComboBox, Popup, PopupCloseBehavior};
fn main() -> Result<(), eframe::Error> {
env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`).
@ -42,23 +42,14 @@ impl eframe::App for MyApp {
ui.label("PopupCloseBehavior::IgnoreClicks popup");
let response = ui.button("Open");
let popup_id = Id::new("popup_id");
if response.clicked() {
ui.memory_mut(|mem| mem.toggle_popup(popup_id));
}
popup_below_widget(
ui,
popup_id,
&response,
PopupCloseBehavior::IgnoreClicks,
|ui| {
Popup::menu(&response)
.close_behavior(PopupCloseBehavior::IgnoreClicks)
.show(|ui| {
ui.set_min_width(310.0);
ui.label("This popup will be open until you press the button again");
ui.checkbox(&mut self.checkbox, "Checkbox");
},
);
});
});
}
}