1use eframe::egui::{self, Color32, Key, Order, Pos2, Rounding, Sense, Stroke};
36
37use crate::ui::gui::theme::{colors, rounding, shadow, spacing};
38use crate::ui::gui::typography::{self, FontSize, FontWeight};
39
40const DIALOG_WIDTH: f32 = 400.0;
46
47const DIALOG_PADDING: f32 = 24.0;
49
50const BUTTON_HEIGHT: f32 = 36.0;
52
53const BUTTON_WIDTH: f32 = 100.0;
55
56const BUTTON_GAP: f32 = 12.0;
58
59const BACKDROP_ALPHA: u8 = 128;
61
62#[derive(Debug, Clone)]
68pub struct ModalButton {
69 pub label: String,
71 pub fill_color: Color32,
73 pub text_color: Color32,
75 pub stroke: Option<Stroke>,
77}
78
79impl ModalButton {
80 pub fn new(label: impl Into<String>) -> Self {
84 Self {
85 label: label.into(),
86 fill_color: colors::ACCENT,
87 text_color: Color32::WHITE,
88 stroke: None,
89 }
90 }
91
92 pub fn color(mut self, color: Color32) -> Self {
94 self.fill_color = color;
95 self
96 }
97
98 pub fn text_color(mut self, color: Color32) -> Self {
100 self.text_color = color;
101 self
102 }
103
104 pub fn stroke(mut self, stroke: Stroke) -> Self {
106 self.stroke = Some(stroke);
107 self
108 }
109
110 pub fn secondary(label: impl Into<String>) -> Self {
114 Self {
115 label: label.into(),
116 fill_color: colors::SURFACE_ELEVATED,
117 text_color: colors::TEXT_PRIMARY,
118 stroke: Some(Stroke::new(1.0, colors::BORDER)),
119 }
120 }
121
122 pub fn destructive(label: impl Into<String>) -> Self {
126 Self {
127 label: label.into(),
128 fill_color: colors::STATUS_ERROR,
129 text_color: Color32::WHITE,
130 stroke: None,
131 }
132 }
133}
134
135impl Default for ModalButton {
136 fn default() -> Self {
137 Self::secondary("Cancel")
138 }
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum ModalAction {
148 Confirmed,
150 Cancelled,
152 None,
154}
155
156impl ModalAction {
157 pub fn is_confirmed(&self) -> bool {
159 matches!(self, ModalAction::Confirmed)
160 }
161
162 pub fn is_cancelled(&self) -> bool {
164 matches!(self, ModalAction::Cancelled)
165 }
166
167 pub fn is_open(&self) -> bool {
169 matches!(self, ModalAction::None)
170 }
171}
172
173#[derive(Debug, Clone)]
187pub struct Modal {
188 id: String,
190 title: String,
192 message: String,
194 cancel_button: Option<ModalButton>,
197 confirm_button: ModalButton,
199 width: f32,
201}
202
203impl Modal {
204 pub fn new(title: impl Into<String>) -> Self {
211 Self {
212 id: "modal".to_string(),
213 title: title.into(),
214 message: String::new(),
215 cancel_button: Some(ModalButton::secondary("Cancel")),
216 confirm_button: ModalButton::new("Confirm"),
217 width: DIALOG_WIDTH,
218 }
219 }
220
221 pub fn id(mut self, id: impl Into<String>) -> Self {
226 self.id = id.into();
227 self
228 }
229
230 pub fn message(mut self, message: impl Into<String>) -> Self {
232 self.message = message.into();
233 self
234 }
235
236 pub fn cancel_button(mut self, button: ModalButton) -> Self {
238 self.cancel_button = Some(button);
239 self
240 }
241
242 pub fn no_cancel_button(mut self) -> Self {
246 self.cancel_button = None;
247 self
248 }
249
250 pub fn confirm_button(mut self, button: ModalButton) -> Self {
252 self.confirm_button = button;
253 self
254 }
255
256 pub fn width(mut self, width: f32) -> Self {
258 self.width = width;
259 self
260 }
261
262 pub fn show(&self, ctx: &egui::Context) -> ModalAction {
270 let mut action = ModalAction::None;
271
272 self.render_backdrop(ctx, &mut action);
274
275 self.render_dialog(ctx, &mut action);
277
278 if ctx.input(|i| i.key_pressed(Key::Escape)) {
280 action = ModalAction::Cancelled;
281 }
282
283 action
284 }
285
286 fn render_backdrop(&self, ctx: &egui::Context, action: &mut ModalAction) {
288 let screen_rect = ctx.screen_rect();
289
290 egui::Area::new(egui::Id::new(format!("{}_backdrop", self.id)))
291 .order(Order::Foreground)
292 .fixed_pos(Pos2::ZERO)
293 .show(ctx, |ui| {
294 ui.painter().rect_filled(
296 screen_rect,
297 Rounding::ZERO,
298 Color32::from_rgba_unmultiplied(0, 0, 0, BACKDROP_ALPHA),
299 );
300
301 let (_, response) = ui.allocate_exact_size(screen_rect.size(), Sense::click());
303 if response.clicked() {
304 *action = ModalAction::Cancelled;
305 }
306 });
307 }
308
309 fn render_dialog(&self, ctx: &egui::Context, action: &mut ModalAction) {
311 let screen_rect = ctx.screen_rect();
312
313 let dialog_x = (screen_rect.width() - self.width) / 2.0;
315
316 let estimated_height = 200.0;
319 let dialog_y = (screen_rect.height() - estimated_height) / 2.0;
320
321 let dialog_pos = Pos2::new(dialog_x, dialog_y);
322
323 egui::Area::new(egui::Id::new(format!("{}_dialog", self.id)))
324 .order(Order::Foreground)
325 .fixed_pos(dialog_pos)
326 .show(ctx, |ui| {
327 egui::Frame::none()
328 .fill(colors::SURFACE)
329 .rounding(Rounding::same(rounding::CARD))
330 .shadow(shadow::elevated())
331 .stroke(Stroke::new(1.0, colors::BORDER))
332 .inner_margin(egui::Margin::same(DIALOG_PADDING))
333 .show(ui, |ui| {
334 let inner_width = self.width - 2.0 * DIALOG_PADDING;
335 ui.set_min_width(inner_width);
336 ui.set_max_width(inner_width);
337
338 ui.label(
340 egui::RichText::new(&self.title)
341 .font(typography::font(FontSize::Heading, FontWeight::SemiBold))
342 .color(colors::TEXT_PRIMARY),
343 );
344
345 ui.add_space(spacing::MD);
346
347 if !self.message.is_empty() {
349 ui.label(
350 egui::RichText::new(&self.message)
351 .font(typography::font(FontSize::Body, FontWeight::Regular))
352 .color(colors::TEXT_SECONDARY),
353 );
354 }
355
356 ui.add_space(spacing::XL);
357
358 ui.horizontal(|ui| {
360 let button_count = if self.cancel_button.is_some() { 2 } else { 1 };
362 let total_button_width = button_count as f32 * BUTTON_WIDTH
363 + (button_count - 1) as f32 * BUTTON_GAP;
364 let available = ui.available_width() - total_button_width;
365 ui.add_space(available.max(0.0));
366
367 if let Some(cancel_btn) = &self.cancel_button {
369 let cancel_response = self.render_button(ui, cancel_btn);
370 if cancel_response.clicked() {
371 *action = ModalAction::Cancelled;
372 }
373 ui.add_space(BUTTON_GAP);
374 }
375
376 let confirm_response = self.render_button(ui, &self.confirm_button);
378 if confirm_response.clicked() {
379 *action = ModalAction::Confirmed;
380 }
381 });
382 });
383 });
384 }
385
386 fn render_button(&self, ui: &mut egui::Ui, button: &ModalButton) -> egui::Response {
388 let mut btn = egui::Button::new(
389 egui::RichText::new(&button.label)
390 .font(typography::font(FontSize::Body, FontWeight::Medium))
391 .color(button.text_color),
392 )
393 .fill(button.fill_color)
394 .rounding(Rounding::same(rounding::BUTTON));
395
396 if let Some(stroke) = button.stroke {
397 btn = btn.stroke(stroke);
398 }
399
400 ui.add_sized([BUTTON_WIDTH, BUTTON_HEIGHT], btn)
401 }
402}
403
404pub fn confirmation_dialog(
425 ctx: &egui::Context,
426 id: &str,
427 title: &str,
428 message: &str,
429 confirm_label: &str,
430 destructive: bool,
431) -> ModalAction {
432 let confirm_button = if destructive {
433 ModalButton::destructive(confirm_label)
434 } else {
435 ModalButton::new(confirm_label)
436 };
437
438 Modal::new(title)
439 .id(id)
440 .message(message)
441 .confirm_button(confirm_button)
442 .show(ctx)
443}
444
445#[cfg(test)]
450mod tests {
451 use super::*;
452
453 #[test]
454 fn test_modal_button_new() {
455 let button = ModalButton::new("Test");
456 assert_eq!(button.label, "Test");
457 assert_eq!(button.fill_color, colors::ACCENT);
458 assert_eq!(button.text_color, Color32::WHITE);
459 assert!(button.stroke.is_none());
460 }
461
462 #[test]
463 fn test_modal_button_color() {
464 let button = ModalButton::new("Test").color(colors::STATUS_SUCCESS);
465 assert_eq!(button.fill_color, colors::STATUS_SUCCESS);
466 }
467
468 #[test]
469 fn test_modal_button_text_color() {
470 let button = ModalButton::new("Test").text_color(colors::TEXT_PRIMARY);
471 assert_eq!(button.text_color, colors::TEXT_PRIMARY);
472 }
473
474 #[test]
475 fn test_modal_button_stroke() {
476 let stroke = Stroke::new(2.0, colors::BORDER);
477 let button = ModalButton::new("Test").stroke(stroke);
478 assert_eq!(button.stroke, Some(stroke));
479 }
480
481 #[test]
482 fn test_modal_button_secondary() {
483 let button = ModalButton::secondary("Cancel");
484 assert_eq!(button.label, "Cancel");
485 assert_eq!(button.fill_color, colors::SURFACE_ELEVATED);
486 assert_eq!(button.text_color, colors::TEXT_PRIMARY);
487 assert!(button.stroke.is_some());
488 }
489
490 #[test]
491 fn test_modal_button_destructive() {
492 let button = ModalButton::destructive("Delete");
493 assert_eq!(button.label, "Delete");
494 assert_eq!(button.fill_color, colors::STATUS_ERROR);
495 assert_eq!(button.text_color, Color32::WHITE);
496 assert!(button.stroke.is_none());
497 }
498
499 #[test]
500 fn test_modal_button_default() {
501 let button = ModalButton::default();
502 assert_eq!(button.label, "Cancel");
503 assert_eq!(button.fill_color, colors::SURFACE_ELEVATED);
504 }
505
506 #[test]
507 fn test_modal_action_is_confirmed() {
508 assert!(ModalAction::Confirmed.is_confirmed());
509 assert!(!ModalAction::Cancelled.is_confirmed());
510 assert!(!ModalAction::None.is_confirmed());
511 }
512
513 #[test]
514 fn test_modal_action_is_cancelled() {
515 assert!(!ModalAction::Confirmed.is_cancelled());
516 assert!(ModalAction::Cancelled.is_cancelled());
517 assert!(!ModalAction::None.is_cancelled());
518 }
519
520 #[test]
521 fn test_modal_action_is_open() {
522 assert!(!ModalAction::Confirmed.is_open());
523 assert!(!ModalAction::Cancelled.is_open());
524 assert!(ModalAction::None.is_open());
525 }
526
527 #[test]
528 fn test_modal_new() {
529 let modal = Modal::new("Test Title");
530 assert_eq!(modal.title, "Test Title");
531 assert_eq!(modal.message, "");
532 assert_eq!(modal.id, "modal");
533 assert_eq!(modal.width, DIALOG_WIDTH);
534 }
535
536 #[test]
537 fn test_modal_id() {
538 let modal = Modal::new("Test").id("custom_id");
539 assert_eq!(modal.id, "custom_id");
540 }
541
542 #[test]
543 fn test_modal_message() {
544 let modal = Modal::new("Test").message("Test message body");
545 assert_eq!(modal.message, "Test message body");
546 }
547
548 #[test]
549 fn test_modal_cancel_button() {
550 let modal = Modal::new("Test").cancel_button(ModalButton::new("Back"));
551 assert_eq!(modal.cancel_button.as_ref().unwrap().label, "Back");
552 }
553
554 #[test]
555 fn test_modal_no_cancel_button() {
556 let modal = Modal::new("Test").no_cancel_button();
557 assert!(modal.cancel_button.is_none());
558 }
559
560 #[test]
561 fn test_modal_confirm_button() {
562 let modal = Modal::new("Test").confirm_button(ModalButton::destructive("Delete"));
563 assert_eq!(modal.confirm_button.label, "Delete");
564 assert_eq!(modal.confirm_button.fill_color, colors::STATUS_ERROR);
565 }
566
567 #[test]
568 fn test_modal_width() {
569 let modal = Modal::new("Test").width(500.0);
570 assert_eq!(modal.width, 500.0);
571 }
572
573 #[test]
574 fn test_modal_builder_chain() {
575 let modal = Modal::new("Confirm Delete")
576 .id("delete_modal")
577 .message("Are you sure?")
578 .cancel_button(ModalButton::secondary("No"))
579 .confirm_button(ModalButton::destructive("Yes, Delete"))
580 .width(450.0);
581
582 assert_eq!(modal.title, "Confirm Delete");
583 assert_eq!(modal.id, "delete_modal");
584 assert_eq!(modal.message, "Are you sure?");
585 assert_eq!(modal.cancel_button.as_ref().unwrap().label, "No");
586 assert_eq!(modal.confirm_button.label, "Yes, Delete");
587 assert_eq!(modal.width, 450.0);
588 }
589}