1use crate::animation::{Animation, EasingFunction};
7use crate::Theme;
8use egui::{vec2, Align, Align2, Color32, Key, Layout, Pos2, 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 closable: bool,
78 fade_animation: Animation<f32>,
79 is_open: Option<bool>,
80}
81
82impl Dialog {
83 pub fn new(id: impl Into<egui::Id>) -> Self {
85 Self {
86 id: id.into(),
87 title: None,
88 description: None,
89 size: DialogSize::Medium,
90 closable: true,
91 fade_animation: Animation::new(0.0, 1.0, 0.15).easing(EasingFunction::CubicOut),
92 is_open: None,
93 }
94 }
95
96 #[must_use]
98 pub const fn open(mut self, is_open: bool) -> Self {
99 self.is_open = Some(is_open);
100 self
101 }
102
103 #[must_use]
105 pub fn title(mut self, title: impl Into<String>) -> Self {
106 self.title = Some(title.into());
107 self
108 }
109
110 #[must_use]
112 pub fn description(mut self, description: impl Into<String>) -> Self {
113 self.description = Some(description.into());
114 self
115 }
116
117 #[must_use]
119 pub const fn size(mut self, size: DialogSize) -> Self {
120 self.size = size;
121 self
122 }
123
124 #[must_use]
126 pub const fn closable(mut self, closable: bool) -> Self {
127 self.closable = closable;
128 self
129 }
130
131 pub fn show(
133 &mut self,
134 ctx: &egui::Context,
135 theme: &Theme,
136 content: impl FnOnce(&mut Ui),
137 ) -> DialogResponse {
138 let mut closed = false;
139 let mut backdrop_clicked = false;
140
141 let state_id = self.id.with("dialog_state");
142 let mut is_open = self
143 .is_open
144 .unwrap_or_else(|| ctx.data_mut(|d| d.get_temp::<bool>(state_id).unwrap_or(false)));
145
146 if !is_open {
147 self.fade_animation.reset();
148 let dummy = egui::Area::new(self.id.with("dialog_empty"))
149 .order(egui::Order::Background)
150 .fixed_pos(egui::Pos2::ZERO)
151 .show(ctx, |_| {})
152 .response;
153 return DialogResponse {
154 response: dummy,
155 closed: false,
156 backdrop_clicked: false,
157 };
158 }
159
160 if !self.fade_animation.is_running() && !self.fade_animation.is_complete() {
161 self.fade_animation.start();
162 }
163
164 let dt = ctx.input(|i| i.unstable_dt);
165 self.fade_animation.update(dt);
166
167 if self.fade_animation.is_running() {
168 ctx.request_repaint();
169 }
170
171 let screen_rect = ctx.content_rect();
172 let dialog_width = self.size.max_width(screen_rect.width());
173 let eased = self.fade_animation.value();
174
175 let backdrop_alpha = (eased * f32::from(OVERLAY_ALPHA)) as u8;
177 let backdrop_color = Color32::from_rgba_unmultiplied(0, 0, 0, backdrop_alpha);
178
179 let backdrop_id = self.id.with("dialog_backdrop");
180 egui::Area::new(backdrop_id)
181 .order(egui::Order::Foreground)
182 .anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0))
183 .show(ctx, |ui| {
184 let sense = if self.closable {
186 Sense::click()
187 } else {
188 Sense::hover()
189 };
190 let backdrop_response = ui.allocate_response(screen_rect.size(), sense);
191 ui.painter().rect_filled(screen_rect, 0.0, backdrop_color);
192
193 if self.closable && backdrop_response.clicked() {
194 is_open = false;
195 closed = true;
196 backdrop_clicked = true;
197 self.fade_animation.reset();
198 }
199 });
200
201 let content_id = self.id.with("dialog_content");
203 let area_response = egui::Area::new(content_id)
204 .order(egui::Order::Foreground)
205 .anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0))
206 .show(ctx, |ui| {
207 let frame = egui::Frame::NONE
208 .fill(theme.background())
209 .stroke(Stroke::new(1.0, theme.border()))
210 .corner_radius(CORNER_RADIUS)
211 .shadow(egui::epaint::Shadow {
212 offset: [0, 4],
213 blur: 16,
214 spread: 0,
215 color: Color32::from_black_alpha(60),
216 })
217 .inner_margin(PADDING);
218
219 frame.show(ui, |ui| {
220 ui.set_width(dialog_width);
221 ui.spacing_mut().item_spacing.y = GAP;
222
223 let has_header = self.title.is_some() || self.description.is_some();
225 if has_header || self.closable {
226 ui.horizontal(|ui| {
227 ui.vertical(|ui| {
228 ui.spacing_mut().item_spacing.y = HEADER_GAP;
229
230 if let Some(title) = &self.title {
231 ui.label(
232 egui::RichText::new(title)
233 .size(theme.typography.xl)
234 .strong()
235 .color(theme.foreground()),
236 );
237 }
238
239 if let Some(desc) = &self.description {
240 ui.label(
241 egui::RichText::new(desc)
242 .size(theme.typography.base)
243 .color(theme.muted_foreground()),
244 );
245 }
246 });
247
248 ui.allocate_space(
249 ui.available_size() - vec2(CLOSE_BUTTON_SIZE + 4.0, 0.0),
250 );
251
252 if self.closable {
253 let (close_rect, close_response) = ui.allocate_exact_size(
254 vec2(CLOSE_BUTTON_SIZE, CLOSE_BUTTON_SIZE),
255 Sense::click(),
256 );
257
258 let close_color = if close_response.hovered() {
259 theme.foreground()
260 } else {
261 theme.muted_foreground()
262 };
263
264 let center = close_rect.center();
265 let half = CLOSE_BUTTON_SIZE * 0.35;
266 ui.painter().line_segment(
267 [
268 Pos2::new(center.x - half, center.y - half),
269 Pos2::new(center.x + half, center.y + half),
270 ],
271 Stroke::new(1.5, close_color),
272 );
273 ui.painter().line_segment(
274 [
275 Pos2::new(center.x + half, center.y - half),
276 Pos2::new(center.x - half, center.y + half),
277 ],
278 Stroke::new(1.5, close_color),
279 );
280
281 if close_response.clicked() {
282 is_open = false;
283 closed = true;
284 self.fade_animation.reset();
285 }
286 }
287 });
288 }
289
290 content(ui);
291 });
292 });
293
294 if self.closable && ctx.input(|i| i.key_pressed(Key::Escape)) {
295 is_open = false;
296 closed = true;
297 self.fade_animation.reset();
298 }
299
300 if self.is_open.is_none() {
302 let state_after_content =
303 ctx.data_mut(|d| d.get_temp::<bool>(state_id).unwrap_or(true));
304 if !state_after_content && is_open {
305 closed = true;
307 self.fade_animation.reset();
308 }
309
310 ctx.data_mut(|d| d.insert_temp(state_id, is_open));
312 }
313
314 DialogResponse {
315 response: area_response.response,
316 closed,
317 backdrop_clicked,
318 }
319 }
320}
321
322impl Default for Dialog {
323 fn default() -> Self {
324 Self::new("dialog")
325 }
326}
327
328pub struct DialogResponse {
330 pub response: egui::Response,
332 pub closed: bool,
334 pub backdrop_clicked: bool,
336}
337
338pub fn dialog_footer(ui: &mut Ui, content: impl FnOnce(&mut Ui)) {
344 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
345 ui.spacing_mut().item_spacing.x = FOOTER_GAP;
346 content(ui);
347 });
348}