egui_charts/ui_kit/modal/
modal.rs1use crate::localization as l10n;
6use egui::{Color32, Context, Id, Ui};
7
8use crate::theme::ThemeExt;
9use crate::tokens::DESIGN_TOKENS;
10use crate::ui_kit::dialog::{dialog_frame, title_row};
11
12pub struct Modal {
14 id: Id,
15 title: String,
16 width: Option<f32>,
17 height: Option<f32>,
18 min_height: Option<f32>,
19 closable: bool,
20 dim_background: bool,
21 rounding: Option<f32>,
22 fill: Option<Color32>,
23 backdrop: Option<Color32>,
24 top: Option<f32>,
25}
26
27impl Modal {
28 pub fn new(id: impl std::hash::Hash + std::fmt::Debug, title: impl Into<String>) -> Self {
30 Self {
31 id: Id::unique(id),
32 title: title.into(),
33 width: None,
34 height: None,
35 min_height: None,
36 closable: true,
37 dim_background: true,
38 rounding: None,
39 fill: None,
40 backdrop: None,
41 top: None,
42 }
43 }
44
45 pub fn width(mut self, width: f32) -> Self {
47 self.width = Some(width);
48 self
49 }
50
51 pub fn height(mut self, height: f32) -> Self {
55 self.height = Some(height);
56 self
57 }
58
59 pub fn min_height(mut self, height: f32) -> Self {
61 self.min_height = Some(height);
62 self
63 }
64
65 pub fn closable(mut self, closable: bool) -> Self {
67 self.closable = closable;
68 self
69 }
70
71 pub fn fill(mut self, fill: Color32) -> Self {
73 self.fill = Some(fill);
74 self
75 }
76
77 pub fn backdrop(mut self, color: Color32) -> Self {
79 self.backdrop = Some(color);
80 self
81 }
82
83 pub fn rounding(mut self, rounding: f32) -> Self {
85 self.rounding = Some(rounding);
86 self
87 }
88
89 pub fn top(mut self, top: f32) -> Self {
91 self.top = Some(top);
92 self
93 }
94
95 pub fn dim_background(mut self, dim: bool) -> Self {
97 self.dim_background = dim;
98 self
99 }
100
101 pub fn show<R>(
107 self,
108 ctx: &Context,
109 open: &mut bool,
110 add_contents: impl FnOnce(&mut Ui) -> R,
111 ) -> Option<ModalResponse<R>> {
112 if !*open {
113 return None;
114 }
115
116 let mut frame = dialog_frame(ctx);
117 if let Some(fill) = self.fill {
118 frame = frame.fill(fill);
119 }
120 if let Some(rounding) = self.rounding {
121 frame = frame.corner_radius(rounding);
122 }
123 let screen_height =
124 (ctx.content_rect().height() - 2.0 * ctx.active_theme().spacing.xxxl).max(0.0);
125 let height = self.height.map(|height| height.min(screen_height));
126 let size = egui::vec2(
127 self.width
128 .unwrap_or(DESIGN_TOKENS.sizing.dialog.default_width)
129 .min((ctx.content_rect().width() - 48.0).max(0.0)),
130 height
131 .or(self.min_height)
132 .unwrap_or(DESIGN_TOKENS.sizing.dialog.default_min_height)
133 .min(screen_height),
134 ) - frame.total_margin().sum();
135 let size = size.max(egui::Vec2::ZERO);
136 let max_height = (height.unwrap_or(screen_height) - frame.total_margin().sum().y).max(0.0);
137 let backdrop = if self.dim_background {
138 self.backdrop.unwrap_or(ctx.active_theme().ui.modal_overlay)
139 } else {
140 Color32::TRANSPARENT
141 };
142
143 let mut close_clicked = false;
144 let mut modal = egui::Modal::new(self.id);
145 if let Some(top) = self.top {
146 let screen = ctx.content_rect();
147 let top = top.clamp(
148 screen.top() + 24.0,
149 (screen.bottom() - height.unwrap_or(0.0) - 24.0).max(screen.top() + 24.0),
150 );
151 modal = modal.area(
152 egui::Area::new(self.id)
153 .order(egui::Order::Foreground)
154 .pivot(egui::Align2::CENTER_TOP)
155 .fixed_pos(egui::pos2(screen.center().x, top)),
156 );
157 }
158 let response = modal.frame(frame).backdrop_color(backdrop).show(ctx, |ui| {
159 ui.set_width(size.x);
160 ui.set_min_height(size.y);
161 ui.set_max_height(max_height);
163 if !self.title.is_empty() {
164 close_clicked = title_row(ui, &self.title, self.closable);
165 }
166 add_contents(ui)
167 });
168
169 if !self.title.is_empty() {
170 let translated_label = l10n::text(ctx, &self.title);
171 response.response.widget_info(|| {
172 egui::WidgetInfo::labeled(egui::Role::Dialog, true, &translated_label)
173 });
174 ctx.accesskit_node_builder(response.response.id, |node| node.set_modal());
175 }
176
177 let close_requested = self.closable && (close_clicked || response.should_close());
179 if close_requested {
180 *open = false;
181 }
182
183 Some(ModalResponse {
184 inner: response.inner,
185 close_requested,
186 })
187 }
188}
189
190pub struct ModalResponse<R> {
192 pub inner: R,
194 pub close_requested: bool,
196}
197
198pub struct SimpleModal {
200 modal: Modal,
201 open: bool,
202}
203
204impl SimpleModal {
205 pub fn new(id: impl std::hash::Hash + std::fmt::Debug, title: impl Into<String>) -> Self {
207 Self {
208 modal: Modal::new(id, title),
209 open: false,
210 }
211 }
212
213 pub fn width(mut self, width: f32) -> Self {
215 self.modal = self.modal.width(width);
216 self
217 }
218
219 pub fn closable(mut self, closable: bool) -> Self {
221 self.modal = self.modal.closable(closable);
222 self
223 }
224
225 pub fn open(&mut self) {
227 self.open = true;
228 }
229
230 pub fn close(&mut self) {
232 self.open = false;
233 }
234
235 pub fn is_open(&self) -> bool {
237 self.open
238 }
239
240 pub fn show<R>(
242 &mut self,
243 ctx: &Context,
244 add_contents: impl FnOnce(&mut Ui) -> R,
245 ) -> Option<ModalResponse<R>> {
246 let modal = Modal::new(self.modal.id, &self.modal.title)
248 .width(
249 self.modal
250 .width
251 .unwrap_or(DESIGN_TOKENS.sizing.dialog.default_width),
252 )
253 .closable(self.modal.closable)
254 .dim_background(self.modal.dim_background);
255
256 modal.show(ctx, &mut self.open, add_contents)
257 }
258}
259
260pub fn show_modal<R>(
262 ctx: &Context,
263 id: impl std::hash::Hash + std::fmt::Debug,
264 title: impl Into<String>,
265 open: &mut bool,
266 add_contents: impl FnOnce(&mut Ui) -> R,
267) -> Option<ModalResponse<R>> {
268 Modal::new(id, title).show(ctx, open, add_contents)
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use egui::{Pos2, RawInput, Rect, ScrollArea, Vec2};
275
276 #[test]
277 fn scrolling_modals_follow_the_window_and_shrinking_content() {
278 for fixed in [false, true] {
279 let ctx = Context::default();
280 let mut open = true;
281 let frame = |screen_height: f32, rows: usize, open: &mut bool| {
282 let mut area = Id::NULL;
283 let mut title_top = 0.0;
284 let screen = Rect::from_min_size(Pos2::ZERO, Vec2::new(1000.0, screen_height));
285 for _ in 0..3 {
286 ctx.run_ui(
287 RawInput {
288 screen_rect: Some(screen),
289 ..Default::default()
290 },
291 |ui| {
292 let modal = Modal::new("scroll", "Title").width(500.0);
293 let modal = if fixed { modal.height(800.0) } else { modal };
294 modal.show(ui.ctx(), open, |ui| {
295 area = ui.layer_id().id;
296 title_top = ui.min_rect().top();
297 ScrollArea::vertical()
298 .max_height(ui.available_height())
299 .show(ui, |ui| {
300 for i in 0..rows {
301 ui.label(format!("Row {i}"));
302 }
303 });
304 });
305 },
306 )
307 .drop_without_applying_deltas();
308 }
309 let rect = ctx.memory(|m| m.area_rect(area)).unwrap();
310 assert!(
311 rect.top() >= 23.0 && rect.bottom() <= screen_height - 23.0,
312 "fixed {fixed}: {rect:?}"
313 );
314 assert!(title_top >= 24.0, "title above the window");
315 rect
316 };
317 let tall = frame(900.0, 100, &mut open);
318 assert!(tall.height() >= 790.0, "fixed {fixed}: {tall:?}");
319 let short = frame(450.0, 100, &mut open);
320 assert!(short.height() <= 403.0);
321 let small = frame(900.0, 3, &mut open);
322 if !fixed {
323 assert!(small.height() < 300.0, "{small:?}");
324 }
325 }
326 }
327}