1use crate::animation::{Animation, EasingFunction};
7use crate::Theme;
8use egui::{vec2, Align, Align2, Color32, Key, Layout, Sense, Stroke, Ui};
9
10const CORNER_RADIUS: f32 = 8.0; const PADDING: f32 = 24.0; const GAP: f32 = 16.0; const HEADER_GAP: f32 = 8.0; const FOOTER_GAP: f32 = 8.0; const OVERLAY_ALPHA: u8 = 128; const CLOSE_BUTTON_SIZE: f32 = 16.0;
18
19#[derive(Debug, Clone, Copy, PartialEq)]
21pub enum DialogSize {
22 Small,
24 Medium,
26 Large,
28 ExtraLarge,
30 FullScreen,
32 Custom(f32),
34}
35
36impl DialogSize {
37 fn max_width(self, screen_width: f32) -> f32 {
38 let max = screen_width - 32.0; match self {
40 Self::Small => 384.0_f32.min(max),
41 Self::Medium => 512.0_f32.min(max),
42 Self::Large => 672.0_f32.min(max),
43 Self::ExtraLarge => 896.0_f32.min(max),
44 Self::FullScreen => max,
45 Self::Custom(w) => w.min(max),
46 }
47 }
48}
49
50pub struct Dialog {
73 id: egui::Id,
74 title: Option<String>,
75 description: Option<String>,
76 size: DialogSize,
77 fixed_height: Option<f32>,
78 closable: bool,
79 fade_animation: Animation<f32>,
80 is_open: Option<bool>,
81}
82
83impl Dialog {
84 pub fn new(id: impl Into<egui::Id>) -> Self {
86 Self {
87 id: id.into(),
88 title: None,
89 description: None,
90 size: DialogSize::Medium,
91 fixed_height: None,
92 closable: true,
93 fade_animation: Animation::new(0.0, 1.0, 0.15).easing(EasingFunction::CubicOut),
94 is_open: None,
95 }
96 }
97
98 #[must_use]
100 pub const fn open(mut self, is_open: bool) -> Self {
101 self.is_open = Some(is_open);
102 self
103 }
104
105 #[must_use]
107 pub fn title(mut self, title: impl Into<String>) -> Self {
108 self.title = Some(title.into());
109 self
110 }
111
112 #[must_use]
114 pub fn description(mut self, description: impl Into<String>) -> Self {
115 self.description = Some(description.into());
116 self
117 }
118
119 #[must_use]
121 pub const fn size(mut self, size: DialogSize) -> Self {
122 self.size = size;
123 self
124 }
125
126 #[must_use]
128 pub const fn height(mut self, height: f32) -> Self {
129 self.fixed_height = Some(height);
130 self
131 }
132
133 #[must_use]
135 pub const fn closable(mut self, closable: bool) -> Self {
136 self.closable = closable;
137 self
138 }
139
140 pub fn show(
142 &mut self,
143 ctx: &egui::Context,
144 theme: &Theme,
145 content: impl FnOnce(&mut Ui),
146 ) -> DialogResponse {
147 let mut closed = false;
148 let mut backdrop_clicked = false;
149
150 let state_id = self.id.with("dialog_state");
151 let mut is_open = self
152 .is_open
153 .unwrap_or_else(|| ctx.data_mut(|d| d.get_temp::<bool>(state_id).unwrap_or(false)));
154
155 if !is_open {
156 self.fade_animation.reset();
157 let dummy = egui::Area::new(self.id.with("dialog_empty"))
158 .order(egui::Order::Background)
159 .fixed_pos(egui::Pos2::ZERO)
160 .show(ctx, |_| {})
161 .response;
162 return DialogResponse {
163 response: dummy,
164 closed: false,
165 backdrop_clicked: false,
166 };
167 }
168
169 if !self.fade_animation.is_running() && !self.fade_animation.is_complete() {
170 self.fade_animation.start();
171 }
172
173 let dt = ctx.input(|i| i.unstable_dt);
174 self.fade_animation.update(dt);
175
176 if self.fade_animation.is_running() {
177 ctx.request_repaint();
178 }
179
180 let screen_rect = ctx.content_rect();
181 let dialog_width = self.size.max_width(screen_rect.width());
182 let eased = self.fade_animation.value();
183
184 let backdrop_alpha = (eased * f32::from(OVERLAY_ALPHA)) as u8;
186 let backdrop_color = Color32::from_rgba_unmultiplied(0, 0, 0, backdrop_alpha);
187
188 let backdrop_id = self.id.with("dialog_backdrop");
189 egui::Area::new(backdrop_id)
190 .order(egui::Order::Foreground)
191 .anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0))
192 .show(ctx, |ui| {
193 let sense = if self.closable {
195 Sense::click()
196 } else {
197 Sense::hover()
198 };
199 let backdrop_response = ui.allocate_response(screen_rect.size(), sense);
200 ui.painter().rect_filled(screen_rect, 0.0, backdrop_color);
201
202 if self.closable && backdrop_response.clicked() {
203 is_open = false;
204 closed = true;
205 backdrop_clicked = true;
206 self.fade_animation.reset();
207 }
208 });
209
210 let frame = egui::Frame::NONE
213 .fill(theme.background())
214 .stroke(Stroke::new(1.0, theme.border()))
215 .corner_radius(CORNER_RADIUS)
216 .shadow(egui::epaint::Shadow {
217 offset: [0, 4],
218 blur: 16,
219 spread: 0,
220 color: Color32::from_black_alpha(60),
221 })
222 .inner_margin(PADDING);
223
224 let mut win_closed = false;
225 let area_response = if let Some(h) = self.fixed_height {
226 let mut win_open = true;
227 let resp = egui::Window::new("")
228 .id(self.id.with("dialog_content"))
229 .open(&mut win_open)
230 .order(egui::Order::Foreground)
231 .anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0))
232 .fixed_size(vec2(dialog_width, h))
233 .title_bar(false)
234 .resizable(false)
235 .collapsible(false)
236 .frame(frame)
237 .show(ctx, |ui| {
238 ui.spacing_mut().item_spacing.y = GAP;
239 render_header(
240 ui,
241 theme,
242 self.title.as_ref(),
243 self.description.as_ref(),
244 self.closable,
245 &mut closed,
246 &mut self.fade_animation,
247 );
248 content(ui);
249 });
250 if !win_open {
251 win_closed = true;
252 }
253 resp.map_or_else(
254 || {
255 ctx.data_mut(|_| {});
256 egui::Area::new(self.id.with("dialog_empty2"))
257 .order(egui::Order::Background)
258 .fixed_pos(egui::Pos2::ZERO)
259 .show(ctx, |_| {})
260 .response
261 },
262 |r| r.response,
263 )
264 } else {
265 let content_id = self.id.with("dialog_content");
266 egui::Area::new(content_id)
267 .order(egui::Order::Foreground)
268 .anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0))
269 .show(ctx, |ui| {
270 frame.show(ui, |ui| {
271 ui.set_width(dialog_width);
272 ui.spacing_mut().item_spacing.y = GAP;
273 render_header(
274 ui,
275 theme,
276 self.title.as_ref(),
277 self.description.as_ref(),
278 self.closable,
279 &mut closed,
280 &mut self.fade_animation,
281 );
282 content(ui);
283 });
284 })
285 .response
286 };
287
288 if win_closed {
289 closed = true;
290 self.fade_animation.reset();
291 }
292
293 if self.closable && ctx.input(|i| i.key_pressed(Key::Escape)) {
294 is_open = false;
295 closed = true;
296 self.fade_animation.reset();
297 }
298
299 if self.is_open.is_none() {
301 let state_after_content =
302 ctx.data_mut(|d| d.get_temp::<bool>(state_id).unwrap_or(true));
303 if !state_after_content && is_open {
304 closed = true;
306 self.fade_animation.reset();
307 }
308
309 ctx.data_mut(|d| d.insert_temp(state_id, is_open));
311 }
312
313 DialogResponse {
314 response: area_response,
315 closed,
316 backdrop_clicked,
317 }
318 }
319}
320
321impl Default for Dialog {
322 fn default() -> Self {
323 Self::new("dialog")
324 }
325}
326
327pub struct DialogResponse {
329 pub response: egui::Response,
331 pub closed: bool,
333 pub backdrop_clicked: bool,
335}
336
337fn render_header(
342 ui: &mut Ui,
343 theme: &Theme,
344 title: Option<&String>,
345 description: Option<&String>,
346 closable: bool,
347 closed: &mut bool,
348 fade_animation: &mut crate::animation::Animation<f32>,
349) {
350 let has_header = title.is_some() || description.is_some();
351 if !has_header && !closable {
352 return;
353 }
354 ui.horizontal(|ui| {
355 ui.vertical(|ui| {
356 ui.spacing_mut().item_spacing.y = HEADER_GAP;
357 if let Some(t) = title {
358 ui.label(
359 egui::RichText::new(t)
360 .size(theme.typography.xl)
361 .strong()
362 .color(theme.foreground()),
363 );
364 }
365 if let Some(d) = description {
366 ui.label(
367 egui::RichText::new(d)
368 .size(theme.typography.base)
369 .color(theme.muted_foreground()),
370 );
371 }
372 });
373 ui.allocate_space(ui.available_size() - vec2(CLOSE_BUTTON_SIZE + 4.0, 0.0));
374 if closable {
375 let (close_rect, close_response) =
376 ui.allocate_exact_size(vec2(CLOSE_BUTTON_SIZE, CLOSE_BUTTON_SIZE), Sense::click());
377 let close_color = if close_response.hovered() {
378 theme.foreground()
379 } else {
380 theme.muted_foreground()
381 };
382 crate::icon::draw_close(ui.painter(), close_rect, close_color);
383 if close_response.clicked() {
384 *closed = true;
385 fade_animation.reset();
386 }
387 }
388 });
389}
390
391pub fn dialog_footer(ui: &mut Ui, content: impl FnOnce(&mut Ui)) {
393 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
394 ui.spacing_mut().item_spacing.x = FOOTER_GAP;
395 content(ui);
396 });
397}