Skip to main content

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