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