1use std::path::Path;
6
7use egui::{
8 Area, Button, CornerRadius, Frame, Id, Modal, Order, RichText, Sense, Stroke, Vec2, pos2,
9};
10use egui_file_dialog::{DialogState, FileDialog as EguiFileDialog, Filter};
11use euclid::Length;
12use log::{error, warn};
13use servo::{
14 AlertDialog, AuthenticationRequest, BluetoothDeviceSelectionRequest, ColorPicker,
15 ConfirmDialog, ContextMenu, ContextMenuItem, DeviceIndependentPixel, EmbedderControlId,
16 FilePicker, PermissionRequest, PromptDialog, RgbColor, SelectElement, SelectElementOption,
17 SelectElementOptionOrOptgroup, SimpleDialog,
18};
19
20const MINIMUM_UI_ELEMENT_WIDTH: f32 = 150.0;
23
24#[expect(clippy::large_enum_variant)]
25pub enum Dialog {
26 File {
27 dialog: EguiFileDialog,
28 maybe_picker: Option<FilePicker>,
29 },
30 Alert(Option<AlertDialog>),
31 Confirm(Option<ConfirmDialog>),
32 Prompt(Option<PromptDialog>),
33 Authentication {
34 username: String,
35 password: String,
36 request: Option<AuthenticationRequest>,
37 },
38 Permission {
39 message: String,
40 request: Option<PermissionRequest>,
41 },
42 SelectDevice {
43 request: Option<BluetoothDeviceSelectionRequest>,
44 selected_device_index: usize,
45 },
46 SelectElement {
47 maybe_prompt: Option<SelectElement>,
48 toolbar_offset: Length<f32, DeviceIndependentPixel>,
49 },
50 ColorPicker {
51 current_color: egui::Color32,
52 maybe_prompt: Option<ColorPicker>,
53 toolbar_offset: Length<f32, DeviceIndependentPixel>,
54 },
55 ContextMenu {
56 menu: Option<ContextMenu>,
57 toolbar_offset: Length<f32, DeviceIndependentPixel>,
58 },
59}
60
61impl Dialog {
62 pub fn new_file_dialog(file_picker: FilePicker) -> Self {
63 let mut dialog = EguiFileDialog::new();
64 if !file_picker.filter_patterns().is_empty() {
65 let filter_patterns = file_picker.filter_patterns().to_owned();
66 let filter = Filter::new(move |path: &Path| {
67 path.extension()
68 .and_then(|e| e.to_str())
69 .is_some_and(|ext| {
70 let ext = ext.to_lowercase();
71 filter_patterns.iter().any(|pattern| ext == pattern.0)
72 })
73 });
74 dialog = dialog
75 .add_file_filter("All Supported Types", filter)
76 .default_file_filter("All Supported Types");
77 }
78
79 Dialog::File {
80 dialog,
81 maybe_picker: Some(file_picker),
82 }
83 }
84
85 pub fn new_simple_dialog(dialog: SimpleDialog) -> Self {
86 match dialog {
87 SimpleDialog::Alert(alert_dialog) => Self::Alert(Some(alert_dialog)),
88 SimpleDialog::Confirm(confirm_dialog) => Self::Confirm(Some(confirm_dialog)),
89 SimpleDialog::Prompt(prompt_dialog) => Self::Prompt(Some(prompt_dialog)),
90 }
91 }
92
93 pub fn new_authentication_dialog(authentication_request: AuthenticationRequest) -> Self {
94 Dialog::Authentication {
95 username: String::new(),
96 password: String::new(),
97 request: Some(authentication_request),
98 }
99 }
100
101 pub fn new_permission_request_dialog(permission_request: PermissionRequest) -> Self {
102 let message = format!(
103 "Do you want to grant permission for {:?}?",
104 permission_request.feature()
105 );
106 Dialog::Permission {
107 message,
108 request: Some(permission_request),
109 }
110 }
111
112 pub fn new_device_selection_dialog(request: BluetoothDeviceSelectionRequest) -> Self {
113 Dialog::SelectDevice {
114 request: Some(request),
115 selected_device_index: 0,
116 }
117 }
118
119 pub fn new_select_element_dialog(
120 prompt: SelectElement,
121 toolbar_offset: Length<f32, DeviceIndependentPixel>,
122 ) -> Self {
123 Dialog::SelectElement {
124 maybe_prompt: Some(prompt),
125 toolbar_offset,
126 }
127 }
128
129 pub fn new_color_picker_dialog(
130 prompt: ColorPicker,
131 toolbar_offset: Length<f32, DeviceIndependentPixel>,
132 ) -> Self {
133 let current_color = prompt
134 .current_color()
135 .map(|color| egui::Color32::from_rgb(color.red, color.green, color.blue))
136 .unwrap_or_default();
137 Dialog::ColorPicker {
138 current_color,
139 maybe_prompt: Some(prompt),
140 toolbar_offset,
141 }
142 }
143
144 pub fn update(&mut self, ctx: &egui::Context) -> bool {
146 enum DialogAction {
147 Dismiss,
148 Submit,
149 Continue,
150 }
151
152 match self {
153 Dialog::File {
154 dialog,
155 maybe_picker,
156 } => {
157 let action = maybe_picker
158 .as_mut()
159 .map(|picker| {
160 if *dialog.state() == DialogState::Closed {
161 if picker.allow_select_multiple() {
162 dialog.pick_multiple();
163 } else {
164 dialog.pick_file();
165 }
166 }
167
168 let state = dialog.update(ctx).state();
169 match state {
170 DialogState::Open => DialogAction::Continue,
171 DialogState::Picked(path) => {
172 let paths = std::slice::from_ref(path);
173 picker.select(paths);
174 DialogAction::Submit
175 },
176 DialogState::PickedMultiple(paths) => {
177 picker.select(paths);
178 DialogAction::Submit
179 },
180 DialogState::Cancelled | DialogState::Closed => DialogAction::Dismiss,
181 }
182 })
183 .unwrap_or(DialogAction::Dismiss);
184
185 match action {
186 DialogAction::Dismiss => {
187 if let Some(picker) = maybe_picker.take() {
188 picker.dismiss();
189 }
190 },
191 DialogAction::Submit => {
192 if let Some(picker) = maybe_picker.take() {
193 picker.submit();
194 }
195 },
196 DialogAction::Continue => {},
197 }
198 matches!(action, DialogAction::Continue)
199 },
200 Dialog::Alert(maybe_alert_dialog) => {
201 let Some(alert_dialog) = maybe_alert_dialog else {
202 return false;
203 };
204
205 let mut is_open = true;
206 Modal::new("Alert".into()).show(ctx, |ui| {
207 make_dialog_label(alert_dialog.message(), ui, None);
208 egui::Sides::new().show(
209 ui,
210 |_ui| {},
211 |ui| {
212 if ui.button("Close").clicked() ||
213 ui.input(|i| i.key_pressed(egui::Key::Escape))
214 {
215 is_open = false;
216 }
217 },
218 );
219 });
220
221 if !is_open && let Some(alert_dialog) = maybe_alert_dialog.take() {
222 alert_dialog.confirm();
223 }
224 is_open
225 },
226 Dialog::Confirm(maybe_confirm_dialog) => {
227 let Some(confirm_dialog) = maybe_confirm_dialog else {
228 return false;
229 };
230
231 let mut dialog_action = DialogAction::Continue;
232 Modal::new("Confirm".into()).show(ctx, |ui| {
233 make_dialog_label(confirm_dialog.message(), ui, None);
234 egui::Sides::new().show(
235 ui,
236 |_ui| {},
237 |ui| {
238 if ui.button("Ok").clicked() ||
239 ui.input(|i| i.key_pressed(egui::Key::Enter))
240 {
241 dialog_action = DialogAction::Submit;
242 }
243 if ui.button("Cancel").clicked() ||
244 ui.input(|i| i.key_pressed(egui::Key::Escape))
245 {
246 dialog_action = DialogAction::Dismiss;
247 }
248 },
249 );
250 });
251
252 match dialog_action {
253 DialogAction::Dismiss => {
254 if let Some(confirm_dialog) = maybe_confirm_dialog.take() {
255 confirm_dialog.dismiss();
256 }
257 false
258 },
259 DialogAction::Submit => {
260 if let Some(confirm_dialog) = maybe_confirm_dialog.take() {
261 confirm_dialog.confirm();
262 }
263 false
264 },
265 DialogAction::Continue => true,
266 }
267 },
268 Dialog::Prompt(maybe_prompt_dialog) => {
269 let Some(prompt_dialog) = maybe_prompt_dialog else {
270 return false;
271 };
272
273 let mut dialog_action = DialogAction::Continue;
274 Modal::new("Prompt".into()).show(ctx, |ui| {
275 let mut prompt_text = prompt_dialog.current_value().to_owned();
276 make_dialog_label(prompt_dialog.message(), ui, Some(&mut prompt_text));
277 egui::Sides::new().show(
278 ui,
279 |_ui| {},
280 |ui| {
281 if ui.button("Ok").clicked() ||
282 ui.input(|i| i.key_pressed(egui::Key::Enter))
283 {
284 prompt_dialog.set_current_value(&prompt_text);
285 dialog_action = DialogAction::Submit;
286 }
287 if ui.button("Cancel").clicked() ||
288 ui.input(|i| i.key_pressed(egui::Key::Escape))
289 {
290 dialog_action = DialogAction::Dismiss;
291 }
292 },
293 );
294 prompt_dialog.set_current_value(&prompt_text);
295 });
296 match dialog_action {
297 DialogAction::Dismiss => {
298 if let Some(prompt_dialog) = maybe_prompt_dialog.take() {
299 prompt_dialog.dismiss();
300 }
301 false
302 },
303 DialogAction::Submit => {
304 if let Some(prompt_dialog) = maybe_prompt_dialog.take() {
305 prompt_dialog.confirm();
306 }
307 false
308 },
309 DialogAction::Continue => true,
310 }
311 },
312 Dialog::Authentication {
313 username,
314 password,
315 request,
316 } => {
317 let mut is_open = true;
318 Modal::new("authentication".into()).show(ctx, |ui| {
319 let mut frame = egui::Frame::default().inner_margin(10.0).begin(ui);
320 frame.content_ui.set_min_width(MINIMUM_UI_ELEMENT_WIDTH);
321
322 if let Some(request) = request {
323 let url =
324 egui::RichText::new(request.url().origin().unicode_serialization());
325 frame.content_ui.heading(url);
326 }
327
328 frame.content_ui.add_space(10.0);
329
330 frame
331 .content_ui
332 .label("This site is asking you to sign in.");
333 frame.content_ui.add_space(10.0);
334
335 frame.content_ui.label("Username:");
336 frame.content_ui.text_edit_singleline(username);
337 frame.content_ui.add_space(10.0);
338
339 frame.content_ui.label("Password:");
340 frame
341 .content_ui
342 .add(egui::TextEdit::singleline(password).password(true));
343
344 frame.end(ui);
345
346 egui::Sides::new().show(
347 ui,
348 |_ui| {},
349 |ui| {
350 if ui.button("Sign in").clicked() ||
351 ui.input(|i| i.key_pressed(egui::Key::Enter))
352 {
353 let request =
354 request.take().expect("non-None until dialog is closed");
355 request.authenticate(username.clone(), password.clone());
356 is_open = false;
357 }
358 if ui.button("Cancel").clicked() ||
359 ui.input(|i| i.key_pressed(egui::Key::Escape))
360 {
361 is_open = false;
362 }
363 },
364 );
365 });
366 is_open
367 },
368 Dialog::Permission { message, request } => {
369 let mut is_open = true;
370 let modal = Modal::new("permission".into());
371 modal.show(ctx, |ui| {
372 make_dialog_label(message, ui, None);
373 egui::Sides::new().show(
374 ui,
375 |_ui| {},
376 |ui| {
377 if ui.button("Allow").clicked() ||
378 ui.input(|i| i.key_pressed(egui::Key::Enter))
379 {
380 let request =
381 request.take().expect("non-None until dialog is closed");
382 request.allow();
383 is_open = false;
384 }
385 if ui.button("Deny").clicked() ||
386 ui.input(|i| i.key_pressed(egui::Key::Escape))
387 {
388 let request =
389 request.take().expect("non-None until dialog is closed");
390 request.deny();
391 is_open = false;
392 }
393 },
394 );
395 });
396 is_open
397 },
398 Dialog::SelectDevice {
399 request,
400 selected_device_index,
401 } => {
402 let mut is_open = true;
403 let modal = Modal::new("device_picker".into());
404 modal.show(ctx, |ui| {
405 if let Some(request) = request {
406 let mut frame = egui::Frame::default().inner_margin(10.0).begin(ui);
407 frame.content_ui.set_min_width(MINIMUM_UI_ELEMENT_WIDTH);
408
409 frame.content_ui.heading("Choose a Device");
410 frame.content_ui.add_space(10.0);
411
412 let devices = request.devices();
413 egui::ComboBox::from_label("")
414 .selected_text(devices[*selected_device_index].name.clone())
415 .show_ui(&mut frame.content_ui, |ui| {
416 for (i, device) in devices.iter().enumerate() {
417 ui.selectable_value(
418 selected_device_index,
419 i,
420 device.name.clone(),
421 );
422 }
423 });
424
425 frame.end(ui);
426 } else {
427 error!("Unexpected: None SelectDevice while the dialog is open");
428 }
429
430 egui::Sides::new().show(
431 ui,
432 |_ui| {},
433 |ui| {
434 if ui.button("Ok").clicked() ||
435 ui.input(|i| i.key_pressed(egui::Key::Enter))
436 {
437 let request =
438 request.take().expect("non-None until dialog is closed");
439 let choice = &request.devices()[*selected_device_index].clone();
440 if let Err(error) = request.pick_device(choice) {
441 warn!("Failed to send device selection: {error}");
442 }
443 is_open = false;
444 }
445 if ui.button("Cancel").clicked() ||
446 ui.input(|i| i.key_pressed(egui::Key::Escape))
447 {
448 let request =
449 request.take().expect("non-None until dialog is closed");
450 if let Err(error) = request.cancel() {
451 warn!("Failed to send cancellation: {error}");
452 }
453 is_open = false;
454 }
455 },
456 );
457 });
458 is_open
459 },
460 Dialog::SelectElement {
461 maybe_prompt,
462 toolbar_offset,
463 } => {
464 let Some(prompt) = maybe_prompt else {
465 return false;
467 };
468 let mut is_open = true;
469
470 let mut position = prompt.position();
471 position.min.y += toolbar_offset.0 as i32;
472 position.max.y += toolbar_offset.0 as i32;
473 let area = egui::Area::new(egui::Id::new("select-window"))
474 .fixed_pos(egui::pos2(position.min.x as f32, position.max.y as f32));
475
476 let mut selected_options = prompt.selected_options();
477
478 fn display_option(
479 ui: &mut egui::Ui,
480 option: &SelectElementOption,
481 selected_options: &mut Vec<usize>,
482 is_open: &mut bool,
483 in_group: bool,
484 allow_multiple: bool,
485 ) {
486 let is_checked = selected_options.contains(&option.id);
487
488 let label_text = if in_group {
490 format!(" {}", option.label)
491 } else {
492 option.label.to_owned()
493 };
494 let label = if option.is_disabled {
495 egui::RichText::new(&label_text).strikethrough()
496 } else {
497 egui::RichText::new(&label_text)
498 };
499 let clickable_area = ui
500 .allocate_ui_with_layout(
501 [ui.available_width(), 0.0].into(),
502 egui::Layout::top_down_justified(egui::Align::LEFT),
503 |ui| ui.selectable_label(is_checked, label),
504 )
505 .inner;
506
507 if clickable_area.clicked() && !option.is_disabled {
508 if allow_multiple {
509 if let Some(pos) =
510 selected_options.iter().position(|id| *id == option.id)
511 {
512 selected_options.remove(pos);
513 }
514 if let Some(position) =
515 selected_options.iter().position(|id| *id == option.id)
516 {
517 selected_options.remove(position);
518 selected_options.push(option.id);
519 }
520 } else {
521 selected_options.clear();
522 selected_options.push(option.id);
523 }
524 if !allow_multiple {
525 *is_open = false;
526 }
527 }
528
529 if clickable_area.hovered() && option.is_disabled {
530 ui.ctx().set_cursor_icon(egui::CursorIcon::NotAllowed);
531 }
532
533 if ui.ctx().input(|i| i.key_pressed(egui::Key::Escape)) {
534 *is_open = false;
535 }
536 }
537
538 let modal = Modal::new("select_element_picker".into()).area(area);
539 let backdrop_response = modal
540 .show(ctx, |ui| {
541 egui::ScrollArea::vertical().show(ui, |ui| {
542 for option_or_optgroup in prompt.options() {
543 match &option_or_optgroup {
544 SelectElementOptionOrOptgroup::Option(option) => {
545 display_option(
546 ui,
547 option,
548 &mut selected_options,
549 &mut is_open,
550 false,
551 prompt.allow_select_multiple(),
552 );
553 },
554 SelectElementOptionOrOptgroup::Optgroup { label, options } => {
555 ui.label(RichText::new(label).strong());
556
557 for option in options {
558 display_option(
559 ui,
560 option,
561 &mut selected_options,
562 &mut is_open,
563 true,
564 prompt.allow_select_multiple(),
565 );
566 }
567 },
568 }
569 }
570 });
571 })
572 .backdrop_response;
573
574 if backdrop_response.clicked() {
576 is_open = false;
577 }
578
579 prompt.select(selected_options);
580
581 if !is_open {
582 maybe_prompt.take().unwrap().submit();
583 }
584
585 is_open
586 },
587 Dialog::ColorPicker {
588 current_color,
589 maybe_prompt,
590 toolbar_offset,
591 } => {
592 let Some(prompt) = maybe_prompt else {
593 return false;
595 };
596 let mut is_open = true;
597
598 let mut position = prompt.position();
599 position.min.y += toolbar_offset.0 as i32;
600 position.max.y += toolbar_offset.0 as i32;
601 let area = egui::Area::new(egui::Id::new("select-window"))
602 .fixed_pos(egui::pos2(position.min.x as f32, position.max.y as f32));
603
604 let modal = Modal::new("select_element_picker".into()).area(area);
605 let backdrop_response = modal
606 .show(ctx, |ui| {
607 egui::widgets::color_picker::color_picker_color32(
608 ui,
609 current_color,
610 egui::widgets::color_picker::Alpha::Opaque,
611 );
612
613 ui.add_space(10.);
614
615 if ui.button("Dismiss").clicked() ||
616 ui.input(|i| i.key_pressed(egui::Key::Escape))
617 {
618 is_open = false;
619 prompt.select(None);
620 }
621 if ui.button("Select").clicked() {
622 is_open = false;
623 let selected_color = RgbColor {
624 red: current_color.r(),
625 green: current_color.g(),
626 blue: current_color.b(),
627 };
628 prompt.select(Some(selected_color));
629 }
630 })
631 .backdrop_response;
632
633 if backdrop_response.clicked() {
635 is_open = false;
636 }
637
638 is_open
639 },
640 Dialog::ContextMenu {
641 menu,
642 toolbar_offset,
643 } => {
644 let mut is_open = true;
645 if let Some(context_menu) = menu {
646 let mut selected_action = None;
647 let mut position = context_menu.position();
648 position.min.y += toolbar_offset.0 as i32;
649 position.max.y += toolbar_offset.0 as i32;
650
651 let response = Area::new(Id::new("context_menu"))
652 .fixed_pos(pos2(position.min.x as f32, position.min.y as f32))
653 .order(Order::Foreground)
654 .show(ctx, |ui| {
655 Frame::popup(ui.style()).show(ui, |ui| {
656 ui.set_min_width(MINIMUM_UI_ELEMENT_WIDTH);
657 for item in context_menu.items() {
658 match item {
659 ContextMenuItem::Item {
660 label,
661 action,
662 enabled,
663 } => {
664 let (color, sense) = match enabled {
665 true => (
666 ui.visuals().strong_text_color(),
667 Sense::click(),
668 ),
669 false => {
670 (ui.visuals().weak_text_color(), Sense::empty())
671 },
672 };
673
674 ui.style_mut().visuals.widgets.inactive.weak_bg_fill =
675 ui.visuals().panel_fill;
676 ui.style_mut().visuals.widgets.inactive.bg_fill =
677 ui.visuals().panel_fill;
678 let button =
679 Button::new(RichText::new(label).color(color))
680 .sense(sense)
681 .corner_radius(CornerRadius::ZERO)
682 .stroke(Stroke::NONE)
683 .wrap_mode(egui::TextWrapMode::Extend)
684 .min_size(Vec2 {
685 x: MINIMUM_UI_ELEMENT_WIDTH,
686 y: 0.0,
687 });
688
689 if ui.add(button).clicked() {
690 selected_action = Some(*action);
691 ui.close();
692 }
693 },
694 ContextMenuItem::Separator => {
695 ui.separator();
696 },
697 }
698 }
699 })
700 });
701
702 if response.response.clicked_elsewhere() {
703 is_open = false;
704 }
705
706 if let Some(action) = selected_action &&
707 let Some(context_menu) = menu.take()
708 {
709 context_menu.select(action);
710 return false;
711 }
712 }
713 is_open
714 },
715 }
716 }
717
718 pub(crate) fn embedder_control_id(&self) -> Option<EmbedderControlId> {
719 match self {
720 Dialog::SelectElement { maybe_prompt, .. } => {
721 maybe_prompt.as_ref().map(|element| element.id())
722 },
723 Dialog::ColorPicker { maybe_prompt, .. } => {
724 maybe_prompt.as_ref().map(|element| element.id())
725 },
726 Dialog::ContextMenu { menu, .. } => menu.as_ref().map(|menu| menu.id()),
727 _ => None,
728 }
729 }
730
731 pub(crate) fn new_context_menu(
732 menu: ContextMenu,
733 toolbar_offset: Length<f32, DeviceIndependentPixel>,
734 ) -> Dialog {
735 Dialog::ContextMenu {
736 menu: Some(menu),
737 toolbar_offset,
738 }
739 }
740}
741
742fn make_dialog_label(message: &str, ui: &mut egui::Ui, input_text: Option<&mut String>) {
743 let mut frame = egui::Frame::default().inner_margin(10.0).begin(ui);
744 frame.content_ui.set_min_width(MINIMUM_UI_ELEMENT_WIDTH);
745 frame.content_ui.label(message);
746 if let Some(input_text) = input_text {
747 frame.content_ui.text_edit_singleline(input_text);
748 }
749 frame.end(ui);
750}