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