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