From 66df116fa08c268894970639c125dc7c5b3429b8 Mon Sep 17 00:00:00 2001 From: lucasmerlin Date: Fri, 20 Feb 2026 19:40:47 +0100 Subject: [PATCH] Add popup backdrops --- crates/egui/src/containers/modal.rs | 58 +++++++++++++++-------------- crates/egui/src/containers/popup.rs | 45 ++++++++++++++++++++-- crates/egui/src/style.rs | 30 +++++++++++++++ 3 files changed, 102 insertions(+), 31 deletions(-) diff --git a/crates/egui/src/containers/modal.rs b/crates/egui/src/containers/modal.rs index 23190ddf6..cdd38f010 100644 --- a/crates/egui/src/containers/modal.rs +++ b/crates/egui/src/containers/modal.rs @@ -1,9 +1,22 @@ -use emath::{Align2, Vec2}; +use emath::{Align2, Rect, Vec2}; use crate::{ - Area, Color32, Context, Frame, Id, InnerResponse, Order, Response, Sense, Ui, UiBuilder, UiKind, + Area, Color32, Context, Frame, Id, InnerResponse, Order, Response, Sense, Ui, UiKind, }; +/// Paint a full-screen backdrop on the given [`Ui`] and return whether +/// a click landed outside `content_rect` (i.e. on the backdrop). +/// +/// This is used by both [`Modal`] and [`crate::Popup`]. +pub(crate) fn paint_backdrop(ui: &mut Ui, color: Color32) -> bool { + let bg_rect = ui.ctx().viewport_rect(); + + let response = ui.interact(bg_rect, ui.unique_id().with("backdrop"), Sense::click_and_drag()); + ui.painter().rect_filled(response.rect, 0.0, color); + + response.clicked() && !ui.response().contains_pointer() +} + /// A modal dialog. /// /// Similar to a [`crate::Window`] but centered and with a backdrop that @@ -26,7 +39,7 @@ impl Modal { pub fn new(id: Id) -> Self { Self { area: Self::default_area(id), - backdrop_color: Color32::from_black_alpha(100), + backdrop_color: Color32::PLACEHOLDER, frame: None, } } @@ -57,7 +70,7 @@ impl Modal { /// Set the backdrop color of the modal. /// - /// Default is `Color32::from_black_alpha(100)`. + /// Default comes from [`crate::Visuals::modal_backdrop_color`]. #[inline] pub fn backdrop_color(mut self, color: Color32) -> Self { self.backdrop_color = color; @@ -77,42 +90,34 @@ impl Modal { pub fn show(self, ctx: &Context, content: impl FnOnce(&mut Ui) -> T) -> ModalResponse { let Self { area, - backdrop_color, + mut backdrop_color, frame, } = self; + if backdrop_color == Color32::PLACEHOLDER { + backdrop_color = ctx.global_style().visuals.modal_backdrop_color; + } + let is_top_modal = ctx.memory_mut(|mem| { mem.set_modal_layer(area.layer()); mem.top_modal_layer() == Some(area.layer()) }); let any_popup_open = crate::Popup::is_any_open(ctx); let InnerResponse { - inner: (inner, backdrop_response), + inner: (inner, backdrop_clicked), response, } = area.show(ctx, |ui| { - let bg_rect = ui.ctx().content_rect(); - let bg_sense = Sense::CLICK | Sense::DRAG; - let mut backdrop = ui.new_child(UiBuilder::new().sense(bg_sense).max_rect(bg_rect)); - backdrop.set_min_size(bg_rect.size()); - ui.painter().rect_filled(bg_rect, 0.0, backdrop_color); - let backdrop_response = backdrop.response(); + let backdrop_clicked = paint_backdrop(ui, backdrop_color); let frame = frame.unwrap_or_else(|| Frame::popup(ui.style())); + let inner = frame.show(ui, content).inner; - // We need the extra scope with the sense since frame can't have a sense and since we - // need to prevent the clicks from passing through to the backdrop. - let inner = ui - .scope_builder(UiBuilder::new().sense(Sense::CLICK | Sense::DRAG), |ui| { - frame.show(ui, content).inner - }) - .inner; - - (inner, backdrop_response) + (inner, backdrop_clicked) }); ModalResponse { response, - backdrop_response, + backdrop_clicked, inner, is_top_modal, any_popup_open, @@ -125,11 +130,8 @@ pub struct ModalResponse { /// The response of the modal contents pub response: Response, - /// The response of the modal backdrop. - /// - /// A click on this means the user clicked outside the modal, - /// in which case you might want to close the modal. - pub backdrop_response: Response, + /// Whether the backdrop was clicked (i.e. a click landed outside the modal). + pub backdrop_clicked: bool, /// The inner response from the content closure pub inner: T, @@ -157,7 +159,7 @@ impl ModalResponse { let ui_close_called = self.response.should_close(); - self.backdrop_response.clicked() + self.backdrop_clicked || ui_close_called || (self.is_top_modal && !self.any_popup_open && escape_clicked()) } diff --git a/crates/egui/src/containers/popup.rs b/crates/egui/src/containers/popup.rs index 0fb2a9f2a..6bdccaad2 100644 --- a/crates/egui/src/containers/popup.rs +++ b/crates/egui/src/containers/popup.rs @@ -5,8 +5,8 @@ use std::iter::once; use emath::{Align, Pos2, Rect, RectAlign, Vec2, vec2}; use crate::{ - Area, AreaState, Context, Frame, Id, InnerResponse, Key, LayerId, Layout, Order, Response, - Sense, Ui, UiKind, UiStackInfo, + Area, AreaState, Color32, Context, Frame, Id, InnerResponse, Key, LayerId, Layout, Order, + Response, Sense, Ui, UiKind, UiStackInfo, containers::menu::{MenuConfig, MenuState, menu_style}, style::StyleModifier, }; @@ -185,6 +185,8 @@ pub struct Popup<'a> { layout: Layout, frame: Option, style: StyleModifier, + /// `None` = use style default, `Some(None)` = no backdrop, `Some(Some(color))` = this color + backdrop_color: Option>, } impl<'a> Popup<'a> { @@ -207,6 +209,7 @@ impl<'a> Popup<'a> { layout: Layout::default(), frame: None, style: StyleModifier::default(), + backdrop_color: None, } } @@ -410,6 +413,22 @@ impl<'a> Popup<'a> { self } + /// Show a backdrop behind the popup. + /// + /// The backdrop covers the entire screen, blocking interaction with the rest of the UI. + /// + /// - `None` — no backdrop is shown. + /// - `Some(color)` — show a backdrop with this color. + /// - `Some(Color32::PLACEHOLDER)` — use the default from [`crate::Visuals::popup_backdrop_color`]. + /// + /// By default, this is controlled by [`crate::Visuals::popup_backdrop_color`]. + /// Calling this method overrides the global style for this popup. + #[inline] + pub fn backdrop(mut self, color: Option) -> Self { + self.backdrop_color = Some(color); + self + } + /// Get the [`Context`] pub fn ctx(&self) -> &Context { &self.ctx @@ -553,6 +572,7 @@ impl<'a> Popup<'a> { layout, frame, style, + backdrop_color, } = self; if kind != PopupKind::Tooltip { @@ -588,7 +608,26 @@ impl<'a> Popup<'a> { area = area.default_width(width); } + // Resolve backdrop color: per-instance override, or fall back to global style + let resolved_backdrop = match backdrop_color { + Some(explicit) => explicit, + None => ctx.global_style().visuals.popup_backdrop_color, + }; + let resolved_backdrop = match resolved_backdrop { + Some(color) if color == Color32::PLACEHOLDER => { + ctx.global_style().visuals.popup_backdrop_color + } + other => other, + }; + + let mut backdrop_clicked = false; let mut response = area.show(&ctx, |ui| { + backdrop_clicked = if let Some(color) = resolved_backdrop { + super::modal::paint_backdrop(ui, color) && was_open_last_frame + } else { + false + }; + style.apply(ui.style_mut()); let frame = frame.unwrap_or_else(|| Frame::popup(ui.style())); frame.show(ui, content).inner @@ -600,7 +639,7 @@ impl<'a> Popup<'a> { let closed_by_click = match close_behavior { PopupCloseBehavior::CloseOnClick => close_click, PopupCloseBehavior::CloseOnClickOutside => { - close_click && response.response.clicked_elsewhere() + backdrop_clicked || (close_click && response.response.clicked_elsewhere()) } PopupCloseBehavior::IgnoreClicks => false, }; diff --git a/crates/egui/src/style.rs b/crates/egui/src/style.rs index a555b9ace..794d61601 100644 --- a/crates/egui/src/style.rs +++ b/crates/egui/src/style.rs @@ -1024,6 +1024,20 @@ pub struct Visuals { pub popup_shadow: Shadow, + /// The backdrop color for modals. + /// + /// Default is `Color32::from_black_alpha(100)`. + pub modal_backdrop_color: Color32, + + /// The default backdrop color for popups. + /// + /// If `Some`, popups show a backdrop by default. + /// If `None` (default), popups don't show a backdrop unless explicitly enabled + /// via [`crate::Popup::backdrop`]. + /// + /// Individual popups can still override this with [`crate::Popup::backdrop`]. + pub popup_backdrop_color: Option, + pub resize_corner_size: f32, /// How the text cursor acts. @@ -1460,6 +1474,9 @@ impl Visuals { color: Color32::from_black_alpha(96), }, + modal_backdrop_color: Color32::from_black_alpha(100), + popup_backdrop_color: None, + resize_corner_size: 12.0, text_cursor: Default::default(), @@ -2152,6 +2169,8 @@ impl Visuals { panel_fill, popup_shadow, + modal_backdrop_color, + popup_backdrop_color, resize_corner_size, @@ -2333,6 +2352,17 @@ impl Visuals { ui.label("Shadow"); ui.add(popup_shadow); ui.end_row(); + + ui.label("Modal backdrop"); + ui.color_edit_button_srgba(modal_backdrop_color); + ui.end_row(); + + ui_optional_color( + ui, + popup_backdrop_color, + Color32::from_black_alpha(100), + "Popup backdrop", + ); }); });