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                        egui::ComboBox::from_label("")
414                            .selected_text(devices[*selected_device_index].name.clone())
415                            .show_ui(&mut frame.content_ui, |ui| {
416                                for (i, device) in devices.iter().enumerate() {
417                                    ui.selectable_value(
418                                        selected_device_index,
419                                        i,
420                                        device.name.clone(),
421                                    );
422                                }
423                            });
424
425                        frame.end(ui);
426                    } else {
427                        error!("Unexpected: None SelectDevice while the dialog is open");
428                    }
429
430                    egui::Sides::new().show(
431                        ui,
432                        |_ui| {},
433                        |ui| {
434                            if ui.button("Ok").clicked() ||
435                                ui.input(|i| i.key_pressed(egui::Key::Enter))
436                            {
437                                let request =
438                                    request.take().expect("non-None until dialog is closed");
439                                let choice = &request.devices()[*selected_device_index].clone();
440                                if let Err(error) = request.pick_device(choice) {
441                                    warn!("Failed to send device selection: {error}");
442                                }
443                                is_open = false;
444                            }
445                            if ui.button("Cancel").clicked() ||
446                                ui.input(|i| i.key_pressed(egui::Key::Escape))
447                            {
448                                let request =
449                                    request.take().expect("non-None until dialog is closed");
450                                if let Err(error) = request.cancel() {
451                                    warn!("Failed to send cancellation: {error}");
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_options = prompt.selected_options();
477
478                fn display_option(
479                    ui: &mut egui::Ui,
480                    option: &SelectElementOption,
481                    selected_options: &mut Vec<usize>,
482                    is_open: &mut bool,
483                    in_group: bool,
484                    allow_multiple: bool,
485                ) {
486                    let is_checked = selected_options.contains(&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                        if allow_multiple {
509                            if let Some(pos) =
510                                selected_options.iter().position(|id| *id == option.id)
511                            {
512                                selected_options.remove(pos);
513                            }
514                            if let Some(position) =
515                                selected_options.iter().position(|id| *id == option.id)
516                            {
517                                selected_options.remove(position);
518                                selected_options.push(option.id);
519                            }
520                        } else {
521                            selected_options.clear();
522                            selected_options.push(option.id);
523                        }
524                        if !allow_multiple {
525                            *is_open = false;
526                        }
527                    }
528
529                    if clickable_area.hovered() && option.is_disabled {
530                        ui.ctx().set_cursor_icon(egui::CursorIcon::NotAllowed);
531                    }
532
533                    if ui.ctx().input(|i| i.key_pressed(egui::Key::Escape)) {
534                        *is_open = false;
535                    }
536                }
537
538                let modal = Modal::new("select_element_picker".into()).area(area);
539                let backdrop_response = modal
540                    .show(ctx, |ui| {
541                        egui::ScrollArea::vertical().show(ui, |ui| {
542                            for option_or_optgroup in prompt.options() {
543                                match &option_or_optgroup {
544                                    SelectElementOptionOrOptgroup::Option(option) => {
545                                        display_option(
546                                            ui,
547                                            option,
548                                            &mut selected_options,
549                                            &mut is_open,
550                                            false,
551                                            prompt.allow_select_multiple(),
552                                        );
553                                    },
554                                    SelectElementOptionOrOptgroup::Optgroup { label, options } => {
555                                        ui.label(RichText::new(label).strong());
556
557                                        for option in options {
558                                            display_option(
559                                                ui,
560                                                option,
561                                                &mut selected_options,
562                                                &mut is_open,
563                                                true,
564                                                prompt.allow_select_multiple(),
565                                            );
566                                        }
567                                    },
568                                }
569                            }
570                        });
571                    })
572                    .backdrop_response;
573
574                // FIXME: Doesn't update until you move your mouse or press a key - why?
575                if backdrop_response.clicked() {
576                    is_open = false;
577                }
578
579                prompt.select(selected_options);
580
581                if !is_open {
582                    maybe_prompt.take().unwrap().submit();
583                }
584
585                is_open
586            },
587            Dialog::ColorPicker {
588                current_color,
589                maybe_prompt,
590                toolbar_offset,
591            } => {
592                let Some(prompt) = maybe_prompt else {
593                    // Prompt was dismissed, so the dialog should be closed too.
594                    return false;
595                };
596                let mut is_open = true;
597
598                let mut position = prompt.position();
599                position.min.y += toolbar_offset.0 as i32;
600                position.max.y += toolbar_offset.0 as i32;
601                let area = egui::Area::new(egui::Id::new("select-window"))
602                    .fixed_pos(egui::pos2(position.min.x as f32, position.max.y as f32));
603
604                let modal = Modal::new("select_element_picker".into()).area(area);
605                let backdrop_response = modal
606                    .show(ctx, |ui| {
607                        egui::widgets::color_picker::color_picker_color32(
608                            ui,
609                            current_color,
610                            egui::widgets::color_picker::Alpha::Opaque,
611                        );
612
613                        ui.add_space(10.);
614
615                        if ui.button("Dismiss").clicked() ||
616                            ui.input(|i| i.key_pressed(egui::Key::Escape))
617                        {
618                            is_open = false;
619                            prompt.select(None);
620                        }
621                        if ui.button("Select").clicked() {
622                            is_open = false;
623                            let selected_color = RgbColor {
624                                red: current_color.r(),
625                                green: current_color.g(),
626                                blue: current_color.b(),
627                            };
628                            prompt.select(Some(selected_color));
629                        }
630                    })
631                    .backdrop_response;
632
633                // FIXME: Doesn't update until you move your mouse or press a key - why?
634                if backdrop_response.clicked() {
635                    is_open = false;
636                }
637
638                is_open
639            },
640            Dialog::ContextMenu {
641                menu,
642                toolbar_offset,
643            } => {
644                let mut is_open = true;
645                if let Some(context_menu) = menu {
646                    let mut selected_action = None;
647                    let mut position = context_menu.position();
648                    position.min.y += toolbar_offset.0 as i32;
649                    position.max.y += toolbar_offset.0 as i32;
650
651                    let response = Area::new(Id::new("context_menu"))
652                        .fixed_pos(pos2(position.min.x as f32, position.min.y as f32))
653                        .order(Order::Foreground)
654                        .show(ctx, |ui| {
655                            Frame::popup(ui.style()).show(ui, |ui| {
656                                ui.set_min_width(MINIMUM_UI_ELEMENT_WIDTH);
657                                for item in context_menu.items() {
658                                    match item {
659                                        ContextMenuItem::Item {
660                                            label,
661                                            action,
662                                            enabled,
663                                        } => {
664                                            let (color, sense) = match enabled {
665                                                true => (
666                                                    ui.visuals().strong_text_color(),
667                                                    Sense::click(),
668                                                ),
669                                                false => {
670                                                    (ui.visuals().weak_text_color(), Sense::empty())
671                                                },
672                                            };
673
674                                            ui.style_mut().visuals.widgets.inactive.weak_bg_fill =
675                                                ui.visuals().panel_fill;
676                                            ui.style_mut().visuals.widgets.inactive.bg_fill =
677                                                ui.visuals().panel_fill;
678                                            let button =
679                                                Button::new(RichText::new(label).color(color))
680                                                    .sense(sense)
681                                                    .corner_radius(CornerRadius::ZERO)
682                                                    .stroke(Stroke::NONE)
683                                                    .wrap_mode(egui::TextWrapMode::Extend)
684                                                    .min_size(Vec2 {
685                                                        x: MINIMUM_UI_ELEMENT_WIDTH,
686                                                        y: 0.0,
687                                                    });
688
689                                            if ui.add(button).clicked() {
690                                                selected_action = Some(*action);
691                                                ui.close();
692                                            }
693                                        },
694                                        ContextMenuItem::Separator => {
695                                            ui.separator();
696                                        },
697                                    }
698                                }
699                            })
700                        });
701
702                    if response.response.clicked_elsewhere() {
703                        is_open = false;
704                    }
705
706                    if let Some(action) = selected_action &&
707                        let Some(context_menu) = menu.take()
708                    {
709                        context_menu.select(action);
710                        return false;
711                    }
712                }
713                is_open
714            },
715        }
716    }
717
718    pub(crate) fn embedder_control_id(&self) -> Option<EmbedderControlId> {
719        match self {
720            Dialog::SelectElement { maybe_prompt, .. } => {
721                maybe_prompt.as_ref().map(|element| element.id())
722            },
723            Dialog::ColorPicker { maybe_prompt, .. } => {
724                maybe_prompt.as_ref().map(|element| element.id())
725            },
726            Dialog::ContextMenu { menu, .. } => menu.as_ref().map(|menu| menu.id()),
727            _ => None,
728        }
729    }
730
731    pub(crate) fn new_context_menu(
732        menu: ContextMenu,
733        toolbar_offset: Length<f32, DeviceIndependentPixel>,
734    ) -> Dialog {
735        Dialog::ContextMenu {
736            menu: Some(menu),
737            toolbar_offset,
738        }
739    }
740}
741
742fn make_dialog_label(message: &str, ui: &mut egui::Ui, input_text: Option<&mut String>) {
743    let mut frame = egui::Frame::default().inner_margin(10.0).begin(ui);
744    frame.content_ui.set_min_width(MINIMUM_UI_ELEMENT_WIDTH);
745    frame.content_ui.label(message);
746    if let Some(input_text) = input_text {
747        frame.content_ui.text_edit_singleline(input_text);
748    }
749    frame.end(ui);
750}