servoshell/desktop/
dialog.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
5use 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
21/// The minimum width of many UI elements including dialog boxes and menus,
22/// for the sake of consistency.
23const 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    /// Returns false if the dialog has been closed, or true otherwise.
182    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                // The `default` field gets reused as the input buffer.
302                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                    // Prompt was dismissed, so the dialog should be closed too.
480                    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                    // TODO: Surely there's a better way to align text in a selectable label in egui.
503                    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                // FIXME: Doesn't update until you move your mouse or press a key - why?
570                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                    // Prompt was dismissed, so the dialog should be closed too.
589                    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                // FIXME: Doesn't update until you move your mouse or press a key - why?
629                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                            // Do not allow selecting text in menu labels.
651                            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        // From <https://w3c.github.io/webdriver/#dfn-handle-any-user-prompts>
692        // > Step 3: If the current user prompt is an alert dialog, set type to "alert". Otherwise,
693        // > if the current user prompt is a beforeunload dialog, set type to
694        // > "beforeUnload". Otherwise, if the current user prompt is a confirm dialog, set
695        // > type to "confirm". Otherwise, if the current user prompt is a prompt dialog,
696        // > set type to "prompt".
697        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}