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::{
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
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    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    /// Returns false if the dialog has been closed, or true otherwise.
153    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                    // Prompt was dismissed, so the dialog should be closed too.
466                    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                    // TODO: Surely there's a better way to align text in a selectable label in egui.
489                    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                // FIXME: Doesn't update until you move your mouse or press a key - why?
556                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                    // Prompt was dismissed, so the dialog should be closed too.
575                    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                // FIXME: Doesn't update until you move your mouse or press a key - why?
615                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}