diff --git a/crates/egui/src/containers/area.rs b/crates/egui/src/containers/area.rs index 6af76256..147e4426 100644 --- a/crates/egui/src/containers/area.rs +++ b/crates/egui/src/containers/area.rs @@ -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, 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(); diff --git a/crates/egui/src/containers/combo_box.rs b/crates/egui/src/containers/combo_box.rs index 98cf0182..884a9c36 100644 --- a/crates/egui/src/containers/combo_box.rs +++ b/crates/egui/src/containers/combo_box.rs @@ -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; +pub type IconPainter = Box; /// 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, + )); } diff --git a/crates/egui/src/containers/mod.rs b/crates/egui/src/containers/mod.rs index abb44459..0d9587e6 100644 --- a/crates/egui/src/containers/mod.rs +++ b/crates/egui/src/containers/mod.rs @@ -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, }; diff --git a/crates/egui/src/containers/old_popup.rs b/crates/egui/src/containers/old_popup.rs new file mode 100644 index 00000000..c803ecf4 --- /dev/null +++ b/crates/egui/src/containers/old_popup.rs @@ -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( + ctx: &Context, + parent_layer: LayerId, + widget_id: Id, + add_contents: impl FnOnce(&mut Ui) -> R, +) -> Option { + 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( + ctx: &Context, + parent_layer: LayerId, + widget_id: Id, + add_contents: impl FnOnce(&mut Ui) -> R, +) -> Option { + 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( + ctx: &Context, + parent_layer: LayerId, + widget_id: Id, + widget_rect: &Rect, + add_contents: impl FnOnce(&mut Ui) -> R, +) -> Option { + 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( + ctx: &Context, + parent_layer: LayerId, + widget_id: Id, + suggested_position: Pos2, + add_contents: impl FnOnce(&mut Ui) -> R, +) -> Option { + 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, +) -> 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( + ui: &Ui, + popup_id: Id, + widget_response: &Response, + close_behavior: PopupCloseBehavior, + add_contents: impl FnOnce(&mut Ui) -> R, +) -> Option { + 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( + _parent_ui: &Ui, + popup_id: Id, + widget_response: &Response, + above_or_below: AboveOrBelow, + close_behavior: PopupCloseBehavior, + add_contents: impl FnOnce(&mut Ui) -> R, +) -> Option { + 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) +} diff --git a/crates/egui/src/containers/popup.rs b/crates/egui/src/containers/popup.rs index 81bf84a2..877ee420 100644 --- a/crates/egui/src/containers/popup.rs +++ b/crates/egui/src/containers/popup.rs @@ -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::(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 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::(when_was_a_toolip_last_shown_id(), now)); +impl From 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( - ctx: &Context, - parent_layer: LayerId, - widget_id: Id, - add_contents: impl FnOnce(&mut Ui) -> R, -) -> Option { - 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( - ctx: &Context, - parent_layer: LayerId, - widget_id: Id, - add_contents: impl FnOnce(&mut Ui) -> R, -) -> Option { - 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( - 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( - 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 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 { + 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, -) -> 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( - ui: &Ui, - popup_id: Id, - widget_response: &Response, - close_behavior: PopupCloseBehavior, - add_contents: impl FnOnce(&mut Ui) -> R, -) -> Option { - 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( - parent_ui: &Ui, - popup_id: Id, - widget_response: &Response, - above_or_below: AboveOrBelow, - close_behavior: PopupCloseBehavior, - add_contents: impl FnOnce(&mut Ui) -> R, -) -> Option { - if !parent_ui.memory(|mem| mem.is_popup_open(popup_id)) { - return None; +impl From 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, + 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, + sense: Sense, + layout: Layout, + frame: Option, +} + +impl<'a> Popup<'a> { + /// Create a new popup + pub fn new(id: Id, ctx: Context, anchor: impl Into, 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>, + 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) -> 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 { + 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 { + 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 { + 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(self, content: impl FnOnce(&mut Ui) -> R) -> Option> { + 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) + } } diff --git a/crates/egui/src/containers/tooltip.rs b/crates/egui/src/containers/tooltip.rs new file mode 100644 index 00000000..1cfc2a9c --- /dev/null +++ b/crates/egui/src/containers/tooltip.rs @@ -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, + 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(self, content: impl FnOnce(&mut crate::Ui) -> R) -> Option> { + 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::(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::(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)) + }) + } +} diff --git a/crates/egui/src/lib.rs b/crates/egui/src/lib.rs index 759f7e41..82b37c50 100644 --- a/crates/egui/src/lib.rs +++ b/crates/egui/src/lib.rs @@ -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, diff --git a/crates/egui/src/memory/mod.rs b/crates/egui/src/memory/mod.rs index 2c669f0f..3a6053f7 100644 --- a/crates/egui/src/memory/mod.rs +++ b/crates/egui/src/memory/mod.rs @@ -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, + popup: Option<(Id, Option)>, #[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>) { + self.popup = Some((popup_id, pos.into())); + } + + /// Get the position for this popup. + pub fn popup_position(&self, id: Id) -> Option { + self.popup + .and_then(|(popup_id, pos)| if popup_id == id { pos } else { None }) } /// Close the open popup, if any. diff --git a/crates/egui/src/response.rs b/crates/egui/src/response.rs index 131a420d..a5a702cf 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, 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. diff --git a/crates/egui_demo_lib/src/demo/context_menu.rs b/crates/egui_demo_lib/src/demo/context_menu.rs index 2dce3e76..35abc080 100644 --- a/crates/egui_demo_lib/src/demo/context_menu.rs +++ b/crates/egui_demo_lib/src/demo/context_menu.rs @@ -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() { 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 6f753c3a..80d32e69 100644 --- a/crates/egui_demo_lib/src/demo/demo_app_windows.rs +++ b/crates/egui_demo_lib/src/demo/demo_app_windows.rs @@ -78,6 +78,7 @@ impl Default for DemoGroups { Box::::default(), Box::::default(), Box::::default(), + Box::::default(), Box::::default(), Box::::default(), Box::::default(), diff --git a/crates/egui_demo_lib/src/demo/mod.rs b/crates/egui_demo_lib/src/demo/mod.rs index cb68a46f..8042f1fe 100644 --- a/crates/egui_demo_lib/src/demo/mod.rs +++ b/crates/egui_demo_lib/src/demo/mod.rs @@ -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; diff --git a/crates/egui_demo_lib/src/demo/popups.rs b/crates/egui_demo_lib/src/demo/popups.rs new file mode 100644 index 00000000..0eeb7627 --- /dev/null +++ b/crates/egui_demo_lib/src/demo/popups.rs @@ -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!()); + }); + } +} diff --git a/crates/egui_demo_lib/src/demo/tooltips.rs b/crates/egui_demo_lib/src/demo/tooltips.rs index ede4bb9f..0e391c55 100644 --- a/crates/egui_demo_lib/src/demo/tooltips.rs +++ b/crates/egui_demo_lib/src/demo/tooltips.rs @@ -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| { diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Context Menus.png b/crates/egui_demo_lib/tests/snapshots/demos/Context Menus.png index ea25033b..ac3c736a 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Context Menus.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Context Menus.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d4cc8e0919fed5bd1ef981658626dba728435ab95da8ee96ced1fb4838d535ff -size 11741 +oid sha256:eb2bc4a38f20ed0f5fced36e8e56936bee328b24a0a45127d5d3739d40331cb7 +size 15514 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Popups.png b/crates/egui_demo_lib/tests/snapshots/demos/Popups.png new file mode 100644 index 00000000..1575cbe0 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Popups.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4806984f9c801a054cea80b89664293680abaa57cf0a95cf9682f111e3794fc1 +size 25080 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Tooltips.png b/crates/egui_demo_lib/tests/snapshots/demos/Tooltips.png index 6d9aced1..f8bb020e 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/Tooltips.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/Tooltips.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c80158ac9c823f94d2830d1423236ad441dc7da31e748b6815c69663fa2a03d0 -size 59662 +oid sha256:92b70683a685869274749d057de174896e18dae5cb67e70221c3efdb7106cdda +size 63684 diff --git a/crates/egui_kittest/tests/popup.rs b/crates/egui_kittest/tests/popup.rs new file mode 100644 index 00000000..f55bf638 --- /dev/null +++ b/crates/egui_kittest/tests/popup.rs @@ -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); +} diff --git a/crates/emath/src/align.rs b/crates/emath/src/align.rs index 71f17244..b1b56755 100644 --- a/crates/emath/src/align.rs +++ b/crates/emath/src/align.rs @@ -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 { diff --git a/crates/emath/src/lib.rs b/crates/emath/src/lib.rs index 05210fbb..ae04f9ec 100644 --- a/crates/emath/src/lib.rs +++ b/crates/emath/src/lib.rs @@ -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::*, diff --git a/crates/emath/src/rect_align.rs b/crates/emath/src/rect_align.rs new file mode 100644 index 00000000..5a8102ad --- /dev/null +++ b/crates/emath/src/rect_align.rs @@ -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, + 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 + } +} diff --git a/examples/popups/src/main.rs b/examples/popups/src/main.rs index baed12c2..3da47583 100644 --- a/examples/popups/src/main.rs +++ b/examples/popups/src/main.rs @@ -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"); - }, - ); + }); }); } }