Add `Ui::close` and `Response::should_close` (#5729)

This adds a generic way of telling containers to close from their child
`Ui`s.

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

---------

Co-authored-by: Emil Ernerfeldt <emil.ernerfeldt@gmail.com>
This commit is contained in:
lucasmerlin 2025-02-20 17:59:29 +01:00 committed by GitHub
parent 264749b8af
commit f5b058b908
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 233 additions and 52 deletions

View File

@ -556,7 +556,8 @@ impl Prepared {
.ui_stack_info(UiStackInfo::new(self.kind))
.layer_id(self.layer_id)
.max_rect(max_rect)
.layout(self.layout);
.layout(self.layout)
.closable();
if !self.enabled {
ui_builder = ui_builder.disabled();
@ -611,6 +612,12 @@ impl Prepared {
response.rect = final_rect;
response.interact_rect = final_rect;
// TODO(lucasmerlin): Can the area response be based on Ui::response? Then this won't be needed
// Bubble up the close event
if content_ui.should_close() {
response.set_close();
}
ctx.memory_mut(|m| m.areas_mut().set_state(layer_id, state));
if sizing_pass {

View File

@ -0,0 +1,20 @@
use std::sync::atomic::AtomicBool;
#[derive(Debug, Default)]
pub struct ClosableTag {
pub close: AtomicBool,
}
impl ClosableTag {
pub const NAME: &'static str = "egui_close_tag";
/// Set close to `true`
pub fn set_close(&self) {
self.close.store(true, std::sync::atomic::Ordering::Relaxed);
}
/// Returns `true` if [`ClosableTag::set_close`] has been called.
pub fn should_close(&self) -> bool {
self.close.load(std::sync::atomic::Ordering::Relaxed)
}
}

View File

@ -2,7 +2,8 @@ use std::hash::Hash;
use crate::{
emath, epaint, pos2, remap, remap_clamp, vec2, Context, Id, InnerResponse, NumExt, Rect,
Response, Sense, Stroke, TextStyle, TextWrapMode, Ui, Vec2, WidgetInfo, WidgetText, WidgetType,
Response, Sense, Stroke, TextStyle, TextWrapMode, Ui, UiBuilder, UiKind, UiStackInfo, Vec2,
WidgetInfo, WidgetText, WidgetType,
};
use emath::GuiRounding as _;
use epaint::{Shape, StrokeKind};
@ -203,11 +204,16 @@ impl CollapsingState {
add_body: impl FnOnce(&mut Ui) -> R,
) -> Option<InnerResponse<R>> {
let openness = self.openness(ui.ctx());
let builder = UiBuilder::new()
.ui_stack_info(UiStackInfo::new(UiKind::Collapsible))
.closable();
if openness <= 0.0 {
self.store(ui.ctx()); // we store any earlier toggling as promised in the docstring
None
} else if openness < 1.0 {
Some(ui.scope(|child_ui| {
Some(ui.scope_builder(builder, |child_ui| {
let max_height = if self.state.open && self.state.open_height.is_none() {
// First frame of expansion.
// We don't know full height yet, but we will next frame.
@ -226,6 +232,9 @@ impl CollapsingState {
let mut min_rect = child_ui.min_rect();
self.state.open_height = Some(min_rect.height());
if child_ui.should_close() {
self.state.open = false;
}
self.store(child_ui.ctx()); // remember the height
// Pretend children took up at most `max_height` space:
@ -234,7 +243,10 @@ impl CollapsingState {
ret
}))
} else {
let ret_response = ui.scope(add_body);
let ret_response = ui.scope_builder(builder, add_body);
if ret_response.response.should_close() {
self.state.open = false;
}
let full_size = ret_response.response.rect.size();
self.state.open_height = Some(full_size.y);
self.store(ui.ctx()); // remember the height

View File

@ -3,6 +3,7 @@
//! For instance, a [`Frame`] adds a frame and background to some contained UI.
pub(crate) mod area;
pub mod close_tag;
pub mod collapsing_header;
mod combo_box;
pub mod frame;

View File

@ -150,7 +150,10 @@ impl<T> ModalResponse<T> {
let escape_clicked =
|| ctx.input_mut(|i| i.consume_key(crate::Modifiers::NONE, crate::Key::Escape));
let ui_close_called = self.response.should_close();
self.backdrop_response.clicked()
|| ui_close_called
|| (self.is_top_modal && !self.any_popup_open && escape_clicked())
}
}

View File

@ -542,7 +542,9 @@ impl<'a> Popup<'a> {
PopupCloseBehavior::IgnoreClicks => false,
};
should_close || ctx.input(|i| i.key_pressed(Key::Escape))
should_close
|| ctx.input(|i| i.key_pressed(Key::Escape))
|| response.response.should_close()
};
match open_kind {

View File

@ -434,7 +434,7 @@ impl Window<'_> {
) -> Option<InnerResponse<Option<R>>> {
let Window {
title,
open,
mut open,
area,
frame,
resize,
@ -634,7 +634,7 @@ impl Window<'_> {
title_bar.ui(
&mut area_content_ui,
&content_response,
open,
open.as_deref_mut(),
&mut collapsing,
collapsible,
);
@ -650,6 +650,12 @@ impl Window<'_> {
let full_response = area.end(ctx, area_content_ui);
if full_response.should_close() {
if let Some(open) = open {
*open = false;
}
}
let inner_response = InnerResponse {
inner: content_inner,
response: full_response,

View File

@ -88,7 +88,7 @@ pub fn zoom_menu_buttons(ui: &mut Ui) {
.clicked()
{
zoom_in(ui.ctx());
ui.close_menu();
ui.close();
}
if ui
@ -99,7 +99,7 @@ pub fn zoom_menu_buttons(ui: &mut Ui) {
.clicked()
{
zoom_out(ui.ctx());
ui.close_menu();
ui.close();
}
if ui
@ -110,6 +110,6 @@ pub fn zoom_menu_buttons(ui: &mut Ui) {
.clicked()
{
ui.ctx().set_zoom_factor(1.0);
ui.close_menu();
ui.close();
}
}

View File

@ -364,7 +364,10 @@ impl MenuRoot {
let menu_state = self.menu_state.read();
let escape_pressed = button.ctx.input(|i| i.key_pressed(Key::Escape));
if menu_state.response.is_close() || escape_pressed {
if menu_state.response.is_close()
|| escape_pressed
|| inner_response.response.should_close()
{
return (MenuResponse::Close, Some(inner_response));
}
}
@ -667,6 +670,9 @@ impl MenuState {
) -> Option<R> {
let (sub_response, response) = self.submenu(id).map(|sub| {
let inner_response = menu_popup(ctx, parent_layer, sub, id, add_contents);
if inner_response.response.should_close() {
sub.write().close();
}
(sub.read().response, inner_response.inner)
})?;
self.cascade_close_response(sub_response);

View File

@ -133,6 +133,9 @@ bitflags::bitflags! {
/// Note that this can be `true` even if the user did not interact with the widget,
/// for instance if an existing slider value was clamped to the given range.
const CHANGED = 1<<11;
/// Should this container be closed?
const CLOSE = 1<<12;
}
}
@ -528,6 +531,21 @@ impl Response {
self.flags.set(Flags::CHANGED, true);
}
/// Should the container be closed?
///
/// Will e.g. be set by calling [`Ui::close`] in a child [`Ui`] or by calling
/// [`Self::set_close`].
pub fn should_close(&self) -> bool {
self.flags.contains(Flags::CLOSE)
}
/// Set the [`Flags::CLOSE`] flag.
///
/// Can be used to e.g. signal that a container should be closed.
pub fn set_close(&mut self) {
self.flags.set(Flags::CLOSE, true);
}
/// Show this UI if the widget was hovered (i.e. a tooltip).
///
/// The text will not be visible if the widget is not enabled.
@ -909,13 +927,13 @@ impl Response {
/// let response = ui.add(Label::new("Right-click me!").sense(Sense::click()));
/// response.context_menu(|ui| {
/// if ui.button("Close the menu").clicked() {
/// ui.close_menu();
/// ui.close();
/// }
/// });
/// # });
/// ```
///
/// See also: [`Ui::menu_button`] and [`Ui::close_menu`].
/// See also: [`Ui::menu_button`] and [`Ui::close`].
pub fn context_menu(&self, add_contents: impl FnOnce(&mut Ui)) -> Option<InnerResponse<()>> {
menu::context_menu(self, add_contents)
}

View File

@ -1,11 +1,13 @@
#![warn(missing_docs)] // Let's keep `Ui` well-documented.
#![allow(clippy::use_self)]
use std::{any::Any, hash::Hash, sync::Arc};
use emath::GuiRounding as _;
use epaint::mutex::RwLock;
use std::{any::Any, hash::Hash, sync::Arc};
use crate::close_tag::ClosableTag;
#[cfg(debug_assertions)]
use crate::Stroke;
use crate::{
containers::{CollapsingHeader, CollapsingResponse, Frame},
ecolor::Hsva,
@ -26,11 +28,9 @@ use crate::{
},
Align, Color32, Context, CursorIcon, DragAndDrop, Id, InnerResponse, InputState, LayerId,
Memory, Order, Painter, PlatformOutput, Pos2, Rangef, Rect, Response, Rgba, RichText, Sense,
Style, TextStyle, TextWrapMode, UiBuilder, UiStack, UiStackInfo, Vec2, WidgetRect, WidgetText,
Style, TextStyle, TextWrapMode, UiBuilder, UiKind, UiStack, UiStackInfo, Vec2, WidgetRect,
WidgetText,
};
#[cfg(debug_assertions)]
use crate::Stroke;
// ----------------------------------------------------------------------------
/// This is what you use to place widgets.
@ -1095,7 +1095,8 @@ impl Ui {
// This is the inverse of Context::read_response. We prefer a response
// based on last frame's widget rect since the one from this frame is Rect::NOTHING until
// Ui::interact_bg is called or the Ui is dropped.
self.ctx()
let mut response = self
.ctx()
.viewport(|viewport| {
viewport
.prev_pass
@ -1107,7 +1108,11 @@ impl Ui {
.map(|widget_rect| self.ctx().get_response(widget_rect))
.expect(
"Since we always call Context::create_widget in Ui::new, this should never be None",
)
);
if self.should_close() {
response.set_close();
}
response
}
/// Update the [`WidgetRect`] created in [`Ui::new`] or [`Ui::new_child`] with the current
@ -1121,7 +1126,7 @@ impl Ui {
fs.used_ids.remove(&self.unique_id);
});
// This will update the WidgetRect that was first created in `Ui::new`.
self.ctx().create_widget(
let mut response = self.ctx().create_widget(
WidgetRect {
id: self.unique_id,
layer_id: self.layer_id(),
@ -1131,7 +1136,11 @@ impl Ui {
enabled: self.enabled,
},
false,
)
);
if self.should_close() {
response.set_close();
}
response
}
/// Interact with the background of this [`Ui`],
@ -1165,6 +1174,69 @@ impl Ui {
pub fn ui_contains_pointer(&self) -> bool {
self.rect_contains_pointer(self.min_rect())
}
/// Find and close the first closable parent.
///
/// Use [`UiBuilder::closable`] to make a [`Ui`] closable.
/// You can then use [`Ui::should_close`] to check if it should be closed.
///
/// This is implemented for all egui containers, e.g. [`crate::Popup`], [`crate::Modal`],
/// [`crate::Area`], [`crate::Window`], [`crate::CollapsingHeader`], etc.
///
/// What exactly happens when you close a container depends on the container implementation.
/// [`crate::Area`] e.g. will return true from it's [`Response::should_close`] method.
///
/// If you want to close a specific kind of container, use [`Ui::close_kind`] instead.
pub fn close(&self) {
let tag = self.stack.iter().find_map(|stack| {
stack
.info
.tags
.get_downcast::<ClosableTag>(ClosableTag::NAME)
});
if let Some(tag) = tag {
tag.set_close();
} else {
#[cfg(feature = "log")]
log::warn!("Called ui.close() on a Ui that has no closable parent.");
}
}
/// Find and close the first closable parent of a specific [`UiKind`].
///
/// This is useful if you want to e.g. close a [`crate::Window`]. Since it contains a
/// `Collapsible`, [`Ui::close`] would close the `Collapsible` instead.
/// You can close the [`crate::Window`] by calling `ui.close_kind(UiKind::Window)`.
pub fn close_kind(&self, ui_kind: UiKind) {
let tag = self
.stack
.iter()
.filter(|stack| stack.info.kind == Some(ui_kind))
.find_map(|stack| {
stack
.info
.tags
.get_downcast::<ClosableTag>(ClosableTag::NAME)
});
if let Some(tag) = tag {
tag.set_close();
} else {
#[cfg(feature = "log")]
log::warn!("Called ui.close_kind({ui_kind:?}) on ui with no such closable parent.");
}
}
/// Was [`Ui::close`] called on this [`Ui`] or any of its children?
/// Only works if the [`Ui`] was created with [`UiBuilder::closable`].
///
/// You can also check via this [`Ui`]'s [`Response::should_close`].
pub fn should_close(&self) -> bool {
self.stack
.info
.tags
.get_downcast(ClosableTag::NAME)
.is_some_and(|tag: &ClosableTag| tag.should_close())
}
}
/// # Allocating space: where do I put my widgets?
@ -2900,11 +2972,9 @@ impl Ui {
/// Close the menu we are in (including submenus), if any.
///
/// See also: [`Self::menu_button`] and [`Response::context_menu`].
pub fn close_menu(&mut self) {
if let Some(menu_state) = &mut self.menu_state {
menu_state.write().close();
}
self.menu_state = None;
#[deprecated = "Use `ui.close()` or `ui.close_kind(UiKind::Menu)` instead"]
pub fn close_menu(&self) {
self.close_kind(UiKind::Menu);
}
pub(crate) fn set_menu_state(&mut self, menu_state: Option<Arc<RwLock<MenuState>>>) {
@ -2921,14 +2991,14 @@ impl Ui {
/// ui.menu_button("My menu", |ui| {
/// ui.menu_button("My sub-menu", |ui| {
/// if ui.button("Close the menu").clicked() {
/// ui.close_menu();
/// ui.close();
/// }
/// });
/// });
/// # });
/// ```
///
/// See also: [`Self::close_menu`] and [`Response::context_menu`].
/// See also: [`Self::close`] and [`Response::context_menu`].
pub fn menu_button<R>(
&mut self,
title: impl Into<WidgetText>,
@ -2952,7 +3022,7 @@ impl Ui {
/// ui.menu_image_button(title, img, |ui| {
/// ui.menu_button("My sub-menu", |ui| {
/// if ui.button("Close the menu").clicked() {
/// ui.close_menu();
/// ui.close();
/// }
/// });
/// });
@ -2960,7 +3030,7 @@ impl Ui {
/// ```
///
///
/// See also: [`Self::close_menu`] and [`Response::context_menu`].
/// See also: [`Self::close`] and [`Response::context_menu`].
#[inline]
pub fn menu_image_button<'a, R>(
&mut self,
@ -2986,14 +3056,14 @@ impl Ui {
/// ui.menu_image_text_button(img, title, |ui| {
/// ui.menu_button("My sub-menu", |ui| {
/// if ui.button("Close the menu").clicked() {
/// ui.close_menu();
/// ui.close();
/// }
/// });
/// });
/// # });
/// ```
///
/// See also: [`Self::close_menu`] and [`Response::context_menu`].
/// See also: [`Self::close`] and [`Response::context_menu`].
#[inline]
pub fn menu_image_text_button<'a, R>(
&mut self,

View File

@ -1,9 +1,9 @@
use std::{hash::Hash, sync::Arc};
use crate::{Id, LayerId, Layout, Rect, Sense, Style, UiStackInfo};
use crate::close_tag::ClosableTag;
#[allow(unused_imports)] // Used for doclinks
use crate::Ui;
use crate::{Id, LayerId, Layout, Rect, Sense, Style, UiStackInfo};
/// Build a [`Ui`] as the child of another [`Ui`].
///
@ -125,6 +125,7 @@ impl UiBuilder {
}
/// Set if you want sense clicks and/or drags. Default is [`Sense::hover`].
///
/// The sense will be registered below the Senses of any widgets contained in this [`Ui`], so
/// if the user clicks a button contained within this [`Ui`], that button will receive the click
/// instead.
@ -135,4 +136,19 @@ impl UiBuilder {
self.sense = Some(sense);
self
}
/// Make this [`Ui`] closable.
///
/// Calling [`Ui::close`] in a child [`Ui`] will mark this [`Ui`] for closing.
/// After [`Ui::close`] was called, [`Ui::should_close`] and [`crate::Response::should_close`] will
/// return `true` (for this frame).
///
/// This works by adding a [`ClosableTag`] to the [`UiStackInfo`].
#[inline]
pub fn closable(mut self) -> Self {
self.ui_stack_info
.tags
.insert(ClosableTag::NAME, Some(Arc::new(ClosableTag::default())));
self
}
}

View File

@ -53,6 +53,9 @@ pub enum UiKind {
/// An [`crate::Area`] that is not of any other kind.
GenericArea,
/// A collapsible container, e.g. a [`crate::CollapsingHeader`].
Collapsible,
}
impl UiKind {
@ -81,6 +84,7 @@ impl UiKind {
| Self::Frame
| Self::ScrollArea
| Self::Resize
| Self::Collapsible
| Self::TableCell => false,
Self::Window

View File

@ -333,7 +333,7 @@ fn integration_ui(ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
ui.ctx()
.send_viewport_cmd(egui::ViewportCommand::Fullscreen(false));
ui.close_menu();
ui.close();
}
});
}

View File

@ -384,12 +384,12 @@ impl WrapApp {
.clicked()
{
ui.ctx().memory_mut(|mem| *mem = Default::default());
ui.close_menu();
ui.close();
}
if ui.button("Reset everything").clicked() {
*cmd = Command::ResetEverything;
ui.close_menu();
ui.close();
}
});
}

View File

@ -59,25 +59,25 @@ impl ContextMenus {
ui.set_max_width(200.0); // To make sure we wrap long text
if ui.button("Open…").clicked() {
ui.close_menu();
ui.close();
}
ui.menu_button("SubMenu", |ui| {
ui.menu_button("SubMenu", |ui| {
if ui.button("Open…").clicked() {
ui.close_menu();
ui.close();
}
let _ = ui.button("Item");
ui.menu_button("Recursive", Self::nested_menus)
});
ui.menu_button("SubMenu", |ui| {
if ui.button("Open…").clicked() {
ui.close_menu();
ui.close();
}
let _ = ui.button("Item");
});
let _ = ui.button("Item");
if ui.button("Open…").clicked() {
ui.close_menu();
ui.close();
}
});
ui.menu_button("SubMenu", |ui| {
@ -86,7 +86,7 @@ impl ContextMenus {
let _ = ui.button("Item3");
let _ = ui.button("Item4");
if ui.button("Open…").clicked() {
ui.close_menu();
ui.close();
}
});
let _ = ui.button("Very long text for this item that should be wrapped");

View File

@ -234,7 +234,7 @@ impl DemoWindows {
ui.set_style(ui.ctx().style()); // ignore the "menu" style set by `menu_button`.
self.demo_list_ui(ui);
if ui.ui_contains_pointer() && ui.input(|i| i.pointer.any_click()) {
ui.close_menu();
ui.close();
}
});
@ -345,7 +345,7 @@ fn file_menu_button(ui: &mut Ui) {
.clicked()
{
ui.ctx().memory_mut(|mem| mem.reset_areas());
ui.close_menu();
ui.close();
}
if ui
@ -357,7 +357,7 @@ fn file_menu_button(ui: &mut Ui) {
.clicked()
{
ui.ctx().memory_mut(|mem| *mem = Default::default());
ui.close_menu();
ui.close();
}
});
}

View File

@ -96,7 +96,9 @@ impl crate::View for Modals {
*save_modal_open = true;
}
if ui.button("Cancel").clicked() {
*user_modal_open = false;
// You can call `ui.close()` to close the modal.
// (This causes the current modals `should_close` to return true)
ui.close();
}
},
);
@ -123,7 +125,7 @@ impl crate::View for Modals {
}
if ui.button("No Thanks").clicked() {
*save_modal_open = false;
ui.close();
}
},
);

View File

@ -153,6 +153,10 @@ impl crate::View for PopupsDemo {
.show(|ui| {
_ = ui.button("Menu item 1");
_ = ui.button("Menu item 2");
if ui.button("I always close the menu").clicked() {
ui.close();
}
});
self.apply_options(Popup::context_menu(&response).id(Id::new("context_menu")))

View File

@ -1,4 +1,4 @@
use egui::Vec2b;
use egui::{UiKind, Vec2b};
#[derive(Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
@ -149,6 +149,16 @@ impl crate::View for WindowOptions {
self.disabled_time = ui.input(|i| i.time);
}
egui::reset_button(ui, self, "Reset");
if ui
.button("Close")
.on_hover_text("You can collapse / close Windows via Ui::close")
.clicked()
{
// Calling close would close the collapsible within the window
// ui.close();
// Instead, we close the window itself
ui.close_kind(UiKind::Window);
}
ui.add(crate::egui_github_link_file!());
});
}

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3411a4a8939b7e731c9c1a6331921b0ac905f4e3e86a51af70bdb38d9446f5e1
size 35193
oid sha256:e67b1e676ff994cb9557939db3dca5ddd15c69d167afd96c0957a2a3b75c0fd8
size 36007