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 let combo_box =
414 if let Some(selected_device) = devices.get(*selected_device_index) {
415 egui::ComboBox::from_label("")
416 .selected_text(selected_device.name.clone())
417 } else {
418 egui::ComboBox::from_label("")
419 };
420
421 combo_box.show_ui(&mut frame.content_ui, |ui| {
422 for (i, device) in devices.iter().enumerate() {
423 ui.selectable_value(selected_device_index, i, device.name.clone());
424 }
425 });
426
427 frame.end(ui);
428 } else {
429 error!("Unexpected: None SelectDevice while the dialog is open");
430 }
431
432 egui::Sides::new().show(
433 ui,
434 |_ui| {},
435 |ui| {
436 let ok_button = egui::Button::new("Ok");
437 let has_selected_device = request.as_ref().is_some_and(|request| {
438 request.devices().get(*selected_device_index).is_some()
439 });
440 if ui.add_enabled(has_selected_device, ok_button).clicked() ||
441 (has_selected_device &&
442 ui.input(|i| i.key_pressed(egui::Key::Enter)))
443 {
444 let request =
445 request.take().expect("non-None until dialog is closed");
446 let choice = &request.devices()[*selected_device_index].clone();
447 if let Err(error) = request.pick_device(choice) {
448 warn!("Failed to send device selection: {error}");
449 }
450 is_open = false;
451 }
452 if ui.button("Cancel").clicked() ||
453 ui.input(|i| i.key_pressed(egui::Key::Escape))
454 {
455 let request =
456 request.take().expect("non-None until dialog is closed");
457 if let Err(error) = request.cancel() {
458 warn!("Failed to send cancellation: {error}");
459 }
460 is_open = false;
461 }
462 },
463 );
464 });
465 is_open
466 },
467 Dialog::SelectElement {
468 maybe_prompt,
469 toolbar_offset,
470 } => {
471 let Some(prompt) = maybe_prompt else {
472 return false;
474 };
475 let mut is_open = true;
476
477 let mut position = prompt.position();
478 position.min.y += toolbar_offset.0 as i32;
479 position.max.y += toolbar_offset.0 as i32;
480 let area = egui::Area::new(egui::Id::new("select-window"))
481 .fixed_pos(egui::pos2(position.min.x as f32, position.max.y as f32));
482
483 let mut selected_options = prompt.selected_options();
484
485 fn display_option(
486 ui: &mut egui::Ui,
487 option: &SelectElementOption,
488 selected_options: &mut Vec<usize>,
489 is_open: &mut bool,
490 in_group: bool,
491 allow_multiple: bool,
492 ) {
493 let is_checked = selected_options.contains(&option.id);
494
495 let label_text = if in_group {
497 format!(" {}", option.label)
498 } else {
499 option.label.to_owned()
500 };
501 let label = if option.is_disabled {
502 egui::RichText::new(&label_text).strikethrough()
503 } else {
504 egui::RichText::new(&label_text)
505 };
506 let clickable_area = ui
507 .allocate_ui_with_layout(
508 [ui.available_width(), 0.0].into(),
509 egui::Layout::top_down_justified(egui::Align::LEFT),
510 |ui| ui.selectable_label(is_checked, label),
511 )
512 .inner;
513
514 if clickable_area.clicked() && !option.is_disabled {
515 if allow_multiple {
516 if let Some(position) =
517 selected_options.iter().position(|id| *id == option.id)
518 {
519 selected_options.remove(position);
520 } else {
521 selected_options.push(option.id);
522 }
523 } else {
524 selected_options.clear();
525 selected_options.push(option.id);
526 }
527 if !allow_multiple {
528 *is_open = false;
529 }
530 }
531
532 if clickable_area.hovered() && option.is_disabled {
533 ui.ctx().set_cursor_icon(egui::CursorIcon::NotAllowed);
534 }
535
536 if ui.ctx().input(|i| i.key_pressed(egui::Key::Escape)) {
537 *is_open = false;
538 }
539 }
540
541 let modal = Modal::new("select_element_picker".into()).area(area);
542 let backdrop_response = modal
543 .show(ctx, |ui| {
544 egui::ScrollArea::vertical().show(ui, |ui| {
545 for option_or_optgroup in prompt.options() {
546 match &option_or_optgroup {
547 SelectElementOptionOrOptgroup::Option(option) => {
548 display_option(
549 ui,
550 option,
551 &mut selected_options,
552 &mut is_open,
553 false,
554 prompt.allow_select_multiple(),
555 );
556 },
557 SelectElementOptionOrOptgroup::Optgroup { label, options } => {
558 ui.label(RichText::new(label).strong());
559
560 for option in options {
561 display_option(
562 ui,
563 option,
564 &mut selected_options,
565 &mut is_open,
566 true,
567 prompt.allow_select_multiple(),
568 );
569 }
570 },
571 }
572 }
573 });
574 })
575 .backdrop_response;
576
577 if backdrop_response.clicked() {
579 is_open = false;
580 }
581
582 prompt.select(selected_options);
583
584 if !is_open {
585 maybe_prompt.take().unwrap().submit();
586 }
587
588 is_open
589 },
590 Dialog::ColorPicker {
591 current_color,
592 maybe_prompt,
593 toolbar_offset,
594 } => {
595 let Some(prompt) = maybe_prompt else {
596 return false;
598 };
599 let mut is_open = true;
600
601 let mut position = prompt.position();
602 position.min.y += toolbar_offset.0 as i32;
603 position.max.y += toolbar_offset.0 as i32;
604 let area = egui::Area::new(egui::Id::new("select-window"))
605 .fixed_pos(egui::pos2(position.min.x as f32, position.max.y as f32));
606
607 let modal = Modal::new("select_element_picker".into()).area(area);
608 let backdrop_response = modal
609 .show(ctx, |ui| {
610 egui::widgets::color_picker::color_picker_color32(
611 ui,
612 current_color,
613 egui::widgets::color_picker::Alpha::Opaque,
614 );
615
616 ui.add_space(10.);
617
618 if ui.button("Dismiss").clicked() ||
619 ui.input(|i| i.key_pressed(egui::Key::Escape))
620 {
621 is_open = false;
622 prompt.select(None);
623 }
624 if ui.button("Select").clicked() {
625 is_open = false;
626 let selected_color = RgbColor {
627 red: current_color.r(),
628 green: current_color.g(),
629 blue: current_color.b(),
630 };
631 prompt.select(Some(selected_color));
632 }
633 })
634 .backdrop_response;
635
636 if backdrop_response.clicked() {
638 is_open = false;
639 }
640
641 is_open
642 },
643 Dialog::ContextMenu {
644 menu,
645 toolbar_offset,
646 } => {
647 let mut is_open = true;
648 if let Some(context_menu) = menu {
649 let mut selected_action = None;
650 let mut position = context_menu.position();
651 position.min.y += toolbar_offset.0 as i32;
652 position.max.y += toolbar_offset.0 as i32;
653
654 let response = Area::new(Id::new("context_menu"))
655 .fixed_pos(pos2(position.min.x as f32, position.min.y as f32))
656 .order(Order::Foreground)
657 .show(ctx, |ui| {
658 Frame::popup(ui.style()).show(ui, |ui| {
659 ui.set_min_width(MINIMUM_UI_ELEMENT_WIDTH);
660 for item in context_menu.items() {
661 match item {
662 ContextMenuItem::Item {
663 label,
664 action,
665 enabled,
666 } => {
667 let (color, sense) = match enabled {
668 true => (
669 ui.visuals().strong_text_color(),
670 Sense::click(),
671 ),
672 false => {
673 (ui.visuals().weak_text_color(), Sense::empty())
674 },
675 };
676
677 ui.style_mut().visuals.widgets.inactive.weak_bg_fill =
678 ui.visuals().panel_fill;
679 ui.style_mut().visuals.widgets.inactive.bg_fill =
680 ui.visuals().panel_fill;
681 let button =
682 Button::new(RichText::new(label).color(color))
683 .sense(sense)
684 .corner_radius(CornerRadius::ZERO)
685 .stroke(Stroke::NONE)
686 .wrap_mode(egui::TextWrapMode::Extend)
687 .min_size(Vec2 {
688 x: MINIMUM_UI_ELEMENT_WIDTH,
689 y: 0.0,
690 });
691
692 if ui.add(button).clicked() {
693 selected_action = Some(*action);
694 ui.close();
695 }
696 },
697 ContextMenuItem::Separator => {
698 ui.separator();
699 },
700 }
701 }
702 })
703 });
704
705 if response.response.clicked_elsewhere() {
706 is_open = false;
707 }
708
709 if let Some(action) = selected_action &&
710 let Some(context_menu) = menu.take()
711 {
712 context_menu.select(action);
713 return false;
714 }
715 }
716 is_open
717 },
718 }
719 }
720
721 pub(crate) fn embedder_control_id(&self) -> Option<EmbedderControlId> {
722 match self {
723 Dialog::SelectElement { maybe_prompt, .. } => {
724 maybe_prompt.as_ref().map(|element| element.id())
725 },
726 Dialog::ColorPicker { maybe_prompt, .. } => {
727 maybe_prompt.as_ref().map(|element| element.id())
728 },
729 Dialog::ContextMenu { menu, .. } => menu.as_ref().map(|menu| menu.id()),
730 _ => None,
731 }
732 }
733
734 pub(crate) fn new_context_menu(
735 menu: ContextMenu,
736 toolbar_offset: Length<f32, DeviceIndependentPixel>,
737 ) -> Dialog {
738 Dialog::ContextMenu {
739 menu: Some(menu),
740 toolbar_offset,
741 }
742 }
743}
744
745fn make_dialog_label(message: &str, ui: &mut egui::Ui, input_text: Option<&mut String>) {
746 let mut frame = egui::Frame::default().inner_margin(10.0).begin(ui);
747 frame.content_ui.set_min_width(MINIMUM_UI_ELEMENT_WIDTH);
748 frame.content_ui.label(message);
749 if let Some(input_text) = input_text {
750 frame.content_ui.text_edit_singleline(input_text);
751 }
752 frame.end(ui);
753}