servoshell/desktop/
minibrowser.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::collections::HashMap;
6use std::rc::Rc;
7use std::sync::Arc;
8use std::time::Instant;
9
10use dpi::PhysicalSize;
11use egui::text::{CCursor, CCursorRange};
12use egui::text_edit::TextEditState;
13use egui::{Button, Key, Label, LayerId, Modifiers, PaintCallback, TopBottomPanel, Vec2, pos2};
14use egui_glow::{CallbackFn, EguiGlow};
15use egui_winit::EventResponse;
16use euclid::{Box2D, Length, Point2D, Rect, Scale, Size2D};
17use log::{trace, warn};
18use servo::base::id::WebViewId;
19use servo::servo_geometry::DeviceIndependentPixel;
20use servo::servo_url::ServoUrl;
21use servo::webrender_api::units::DevicePixel;
22use servo::{
23    Image, LoadStatus, OffscreenRenderingContext, PixelFormat, PrefValue, RenderingContext, WebView,
24};
25use winit::event::{ElementState, MouseButton, WindowEvent};
26use winit::event_loop::ActiveEventLoop;
27use winit::window::Window;
28
29use super::app_state::RunningAppState;
30use super::events_loop::EventLoopProxy;
31use super::geometry::winit_position_to_euclid_point;
32use super::headed_window::Window as ServoWindow;
33use crate::desktop::headed_window::INITIAL_WINDOW_TITLE;
34use crate::desktop::window_trait::WindowPortsMethods;
35use crate::prefs::{EXPERIMENTAL_PREFS, ServoShellPreferences};
36use crate::running_app_state::RunningAppStateTrait;
37
38pub struct Minibrowser {
39    rendering_context: Rc<OffscreenRenderingContext>,
40    context: EguiGlow,
41    event_queue: Vec<MinibrowserEvent>,
42    toolbar_height: Length<f32, DeviceIndependentPixel>,
43
44    last_update: Instant,
45    last_mouse_position: Option<Point2D<f32, DeviceIndependentPixel>>,
46    location: String,
47
48    /// Whether the location has been edited by the user without clicking Go.
49    location_dirty: bool,
50
51    load_status: LoadStatus,
52
53    status_text: Option<String>,
54
55    /// Handle to the GPU texture of the favicon.
56    ///
57    /// These need to be cached across egui draw calls.
58    favicon_textures: HashMap<WebViewId, (egui::TextureHandle, egui::load::SizedTexture)>,
59
60    /// Whether the user has enabled experimental preferences.
61    experimental_prefs_enabled: bool,
62}
63
64pub enum MinibrowserEvent {
65    /// Go button clicked.
66    Go(String),
67    Back,
68    Forward,
69    Reload,
70    ReloadAll,
71    NewWebView,
72    CloseWebView(WebViewId),
73}
74
75fn truncate_with_ellipsis(input: &str, max_length: usize) -> String {
76    if input.chars().count() > max_length {
77        let truncated: String = input.chars().take(max_length.saturating_sub(1)).collect();
78        format!("{}…", truncated)
79    } else {
80        input.to_string()
81    }
82}
83
84impl Drop for Minibrowser {
85    fn drop(&mut self) {
86        self.context.destroy();
87    }
88}
89
90impl Minibrowser {
91    pub fn new(
92        window: &ServoWindow,
93        event_loop: &ActiveEventLoop,
94        event_loop_proxy: EventLoopProxy,
95        initial_url: ServoUrl,
96        preferences: &ServoShellPreferences,
97    ) -> Self {
98        let rendering_context = window.offscreen_rendering_context();
99        #[allow(clippy::arc_with_non_send_sync)]
100        let mut context = EguiGlow::new(
101            event_loop,
102            rendering_context.glow_gl_api(),
103            None,
104            None,
105            false,
106        );
107
108        let winit_window = window.winit_window().unwrap();
109        context
110            .egui_winit
111            .init_accesskit(event_loop, winit_window, event_loop_proxy);
112        winit_window.set_visible(true);
113
114        context.egui_ctx.options_mut(|options| {
115            // Disable the builtin egui handlers for the Ctrl+Plus, Ctrl+Minus and Ctrl+0
116            // shortcuts as they don't work well with servoshell's `device-pixel-ratio` CLI argument.
117            options.zoom_with_keyboard = false;
118
119            // On platforms where winit fails to obtain a system theme, fall back to a light theme
120            // since it is the more common default.
121            options.fallback_theme = egui::Theme::Light;
122        });
123
124        Self {
125            rendering_context,
126            context,
127            event_queue: vec![],
128            toolbar_height: Default::default(),
129            last_update: Instant::now(),
130            last_mouse_position: None,
131            location: initial_url.to_string(),
132            location_dirty: false,
133            load_status: LoadStatus::Complete,
134            status_text: None,
135            favicon_textures: Default::default(),
136            experimental_prefs_enabled: preferences.experimental_prefs_enabled,
137        }
138    }
139
140    pub(crate) fn take_events(&mut self) -> Vec<MinibrowserEvent> {
141        std::mem::take(&mut self.event_queue)
142    }
143
144    pub fn on_window_event(
145        &mut self,
146        window: &Window,
147        app_state: &RunningAppState,
148        event: &WindowEvent,
149    ) -> EventResponse {
150        let mut result = self.context.on_window_event(window, event);
151
152        if app_state.has_active_dialog() {
153            result.consumed = true;
154            return result;
155        }
156
157        result.consumed &= match event {
158            WindowEvent::CursorMoved { position, .. } => {
159                let scale = Scale::<_, DeviceIndependentPixel, _>::new(
160                    self.context.egui_ctx.pixels_per_point(),
161                );
162                self.last_mouse_position =
163                    Some(winit_position_to_euclid_point(*position).to_f32() / scale);
164                self.last_mouse_position
165                    .is_some_and(|p| self.is_in_egui_toolbar_rect(p))
166            },
167            WindowEvent::MouseInput {
168                state: ElementState::Pressed,
169                button: MouseButton::Forward,
170                ..
171            } => {
172                self.event_queue.push(MinibrowserEvent::Forward);
173                true
174            },
175            WindowEvent::MouseInput {
176                state: ElementState::Pressed,
177                button: MouseButton::Back,
178                ..
179            } => {
180                self.event_queue.push(MinibrowserEvent::Back);
181                true
182            },
183            WindowEvent::MouseWheel { .. } | WindowEvent::MouseInput { .. } => self
184                .last_mouse_position
185                .is_some_and(|p| self.is_in_egui_toolbar_rect(p)),
186            _ => true,
187        };
188        result
189    }
190
191    /// Return true iff the given position is over the egui toolbar.
192    fn is_in_egui_toolbar_rect(&self, position: Point2D<f32, DeviceIndependentPixel>) -> bool {
193        position.y < self.toolbar_height.get()
194    }
195
196    /// Create a frameless button with square sizing, as used in the toolbar.
197    fn toolbar_button(text: &str) -> egui::Button<'_> {
198        egui::Button::new(text)
199            .frame(false)
200            .min_size(Vec2 { x: 20.0, y: 20.0 })
201    }
202
203    /// Draws a browser tab, checking for clicks and queues appropriate `MinibrowserEvent`s.
204    /// Using a custom widget here would've been nice, but it doesn't seem as though egui
205    /// supports that, so we arrange multiple Widgets in a way that they look connected.
206    fn browser_tab(
207        ui: &mut egui::Ui,
208        webview: WebView,
209        event_queue: &mut Vec<MinibrowserEvent>,
210        favicon_texture: Option<egui::load::SizedTexture>,
211    ) {
212        let label = match (webview.page_title(), webview.url()) {
213            (Some(title), _) if !title.is_empty() => title,
214            (_, Some(url)) => url.to_string(),
215            _ => "New Tab".into(),
216        };
217
218        let inactive_bg_color = ui.visuals().window_fill;
219        let active_bg_color = ui.visuals().widgets.active.weak_bg_fill;
220        let selected = webview.focused();
221
222        // Setup a tab frame that will contain the favicon, title and close button
223        let mut tab_frame = egui::Frame::NONE.corner_radius(4).begin(ui);
224        {
225            tab_frame.content_ui.add_space(5.0);
226
227            let visuals = tab_frame.content_ui.visuals_mut();
228            // Remove the stroke so we don't see the border between the close button and the label
229            visuals.widgets.active.bg_stroke.width = 0.0;
230            visuals.widgets.hovered.bg_stroke.width = 0.0;
231            // Now we make sure the fill color is always the same, irrespective of state, that way
232            // we can make sure that both the label and close button have the same background color
233            visuals.widgets.noninteractive.weak_bg_fill = inactive_bg_color;
234            visuals.widgets.inactive.weak_bg_fill = inactive_bg_color;
235            visuals.widgets.hovered.weak_bg_fill = active_bg_color;
236            visuals.widgets.active.weak_bg_fill = active_bg_color;
237            visuals.selection.bg_fill = active_bg_color;
238            visuals.selection.stroke.color = visuals.widgets.active.fg_stroke.color;
239            visuals.widgets.hovered.fg_stroke.color = visuals.widgets.active.fg_stroke.color;
240
241            // Expansion would also show that they are 2 separate widgets
242            visuals.widgets.active.expansion = 0.0;
243            visuals.widgets.hovered.expansion = 0.0;
244
245            if let Some(favicon) = favicon_texture {
246                tab_frame.content_ui.add(
247                    egui::Image::from_texture(favicon)
248                        .fit_to_exact_size(egui::vec2(16.0, 16.0))
249                        .bg_fill(egui::Color32::TRANSPARENT),
250                );
251            }
252
253            let tab = tab_frame
254                .content_ui
255                .add(Button::selectable(
256                    selected,
257                    truncate_with_ellipsis(&label, 20),
258                ))
259                .on_hover_ui(|ui| {
260                    ui.label(&label);
261                });
262
263            let close_button = tab_frame
264                .content_ui
265                .add(egui::Button::new("X").fill(egui::Color32::TRANSPARENT));
266            if close_button.clicked() || close_button.middle_clicked() || tab.middle_clicked() {
267                event_queue.push(MinibrowserEvent::CloseWebView(webview.id()))
268            } else if !selected && tab.clicked() {
269                webview.focus();
270            }
271        }
272
273        let response = tab_frame.allocate_space(ui);
274        let fill_color = if selected || response.hovered() {
275            active_bg_color
276        } else {
277            inactive_bg_color
278        };
279        tab_frame.frame.fill = fill_color;
280        tab_frame.end(ui);
281    }
282
283    /// Update the minibrowser, but don’t paint.
284    pub fn update(
285        &mut self,
286        window: &dyn WindowPortsMethods,
287        state: &RunningAppState,
288        reason: &'static str,
289    ) {
290        let now = Instant::now();
291        let winit_window = window.winit_window().unwrap();
292        trace!(
293            "{:?} since last update ({})",
294            now - self.last_update,
295            reason
296        );
297        let Self {
298            rendering_context,
299            context,
300            event_queue,
301            toolbar_height,
302            last_update,
303            location,
304            location_dirty,
305            favicon_textures,
306            ..
307        } = self;
308
309        context.run(winit_window, |ctx| {
310            load_pending_favicons(ctx, state, favicon_textures);
311
312            // TODO: While in fullscreen add some way to mitigate the increased phishing risk
313            // when not displaying the URL bar: https://github.com/servo/servo/issues/32443
314            if winit_window.fullscreen().is_none() {
315                let frame = egui::Frame::default()
316                    .fill(ctx.style().visuals.window_fill)
317                    .inner_margin(4.0);
318                TopBottomPanel::top("toolbar").frame(frame).show(ctx, |ui| {
319                    ui.allocate_ui_with_layout(
320                        ui.available_size(),
321                        egui::Layout::left_to_right(egui::Align::Center),
322                        |ui| {
323                            if ui.add(Minibrowser::toolbar_button("⏴")).clicked() {
324                                event_queue.push(MinibrowserEvent::Back);
325                            }
326                            if ui.add(Minibrowser::toolbar_button("⏵")).clicked() {
327                                event_queue.push(MinibrowserEvent::Forward);
328                            }
329
330                            match self.load_status {
331                                LoadStatus::Started | LoadStatus::HeadParsed => {
332                                    if ui.add(Minibrowser::toolbar_button("X")).clicked() {
333                                        warn!("Do not support stop yet.");
334                                    }
335                                },
336                                LoadStatus::Complete => {
337                                    if ui.add(Minibrowser::toolbar_button("↻")).clicked() {
338                                        event_queue.push(MinibrowserEvent::Reload);
339                                    }
340                                },
341                            }
342                            ui.add_space(2.0);
343
344                            ui.allocate_ui_with_layout(
345                                ui.available_size(),
346                                egui::Layout::right_to_left(egui::Align::Center),
347                                |ui| {
348                                    let prefs_toggle = ui
349                                        .toggle_value(&mut self.experimental_prefs_enabled, "☢")
350                                        .on_hover_text("Enable experimental prefs");
351                                    if prefs_toggle.clicked() {
352                                        let enable = self.experimental_prefs_enabled;
353                                        for pref in EXPERIMENTAL_PREFS {
354                                            state
355                                                .servo()
356                                                .set_preference(pref, PrefValue::Bool(enable));
357                                        }
358                                        event_queue.push(MinibrowserEvent::ReloadAll);
359                                    }
360
361                                    let location_id = egui::Id::new("location_input");
362                                    let location_field = ui.add_sized(
363                                        ui.available_size(),
364                                        egui::TextEdit::singleline(location).id(location_id),
365                                    );
366
367                                    if location_field.changed() {
368                                        *location_dirty = true;
369                                    }
370                                    // Handle adddress bar shortcut.
371                                    if ui.input(|i| {
372                                        if cfg!(target_os = "macos") {
373                                            i.clone().consume_key(Modifiers::COMMAND, Key::L)
374                                        } else {
375                                            i.clone().consume_key(Modifiers::COMMAND, Key::L) ||
376                                                i.clone().consume_key(Modifiers::ALT, Key::D)
377                                        }
378                                    }) {
379                                        // The focus request immediately makes gained_focus return true.
380                                        location_field.request_focus();
381                                    }
382                                    // Select address bar text when it's focused (click or shortcut).
383                                    if location_field.gained_focus() {
384                                        if let Some(mut state) =
385                                            TextEditState::load(ui.ctx(), location_id)
386                                        {
387                                            // Select the whole input.
388                                            state.cursor.set_char_range(Some(CCursorRange::two(
389                                                CCursor::new(0),
390                                                CCursor::new(location.len()),
391                                            )));
392                                            state.store(ui.ctx(), location_id);
393                                        }
394                                    }
395                                    // Navigate to address when enter is pressed in the address bar.
396                                    if location_field.lost_focus() &&
397                                        ui.input(|i| i.clone().key_pressed(Key::Enter))
398                                    {
399                                        event_queue.push(MinibrowserEvent::Go(location.clone()));
400                                    }
401                                },
402                            );
403                        },
404                    );
405                });
406            };
407
408            // A simple Tab header strip
409            TopBottomPanel::top("tabs").show(ctx, |ui| {
410                ui.allocate_ui_with_layout(
411                    ui.available_size(),
412                    egui::Layout::left_to_right(egui::Align::Center),
413                    |ui| {
414                        for (id, webview) in state.webviews().into_iter() {
415                            let favicon = favicon_textures
416                                .get(&id)
417                                .map(|(_, favicon)| favicon)
418                                .copied();
419                            Self::browser_tab(ui, webview, event_queue, favicon);
420                        }
421                        if ui.add(Minibrowser::toolbar_button("+")).clicked() {
422                            event_queue.push(MinibrowserEvent::NewWebView);
423                        }
424                    },
425                );
426            });
427
428            // The toolbar height is where the Context’s available rect starts.
429            // For reasons that are unclear, the TopBottomPanel’s ui cursor exceeds this by one egui
430            // point, but the Context is correct and the TopBottomPanel is wrong.
431            *toolbar_height = Length::new(ctx.available_rect().min.y);
432            window.set_toolbar_height(*toolbar_height);
433
434            let scale =
435                Scale::<_, DeviceIndependentPixel, DevicePixel>::new(ctx.pixels_per_point());
436
437            state.for_each_active_dialog(|dialog| dialog.update(ctx));
438
439            // If the top parts of the GUI changed size, then update the size of the WebView and also
440            // the size of its RenderingContext.
441            let rect = ctx.available_rect();
442            let size = Size2D::new(rect.width(), rect.height()) * scale;
443            let rect = Box2D::from_origin_and_size(Point2D::origin(), size);
444            if let Some(webview) = state.focused_webview() &&
445                rect != webview.rect()
446            {
447                webview.move_resize(rect);
448                // `rect` is sized to just the WebView viewport, which is required by
449                // `OffscreenRenderingContext` See:
450                // <https://github.com/servo/servo/issues/38369#issuecomment-3138378527>
451                webview.resize(PhysicalSize::new(size.width as u32, size.height as u32))
452            }
453
454            if let Some(status_text) = &self.status_text {
455                egui::Tooltip::always_open(
456                    ctx.clone(),
457                    LayerId::background(),
458                    "tooltip layer".into(),
459                    pos2(0.0, ctx.available_rect().max.y),
460                )
461                .show(|ui| ui.add(Label::new(status_text.clone()).extend()));
462            }
463
464            state.repaint_servo_if_necessary();
465
466            if let Some(render_to_parent) = rendering_context.render_to_parent_callback() {
467                ctx.layer_painter(LayerId::background()).add(PaintCallback {
468                    rect: ctx.available_rect(),
469                    callback: Arc::new(CallbackFn::new(move |info, painter| {
470                        let clip = info.viewport_in_pixels();
471                        let rect_in_parent = Rect::new(
472                            Point2D::new(clip.left_px, clip.from_bottom_px),
473                            Size2D::new(clip.width_px, clip.height_px),
474                        );
475                        render_to_parent(painter.gl(), rect_in_parent)
476                    })),
477                });
478            }
479
480            *last_update = now;
481        });
482    }
483
484    /// Paint the minibrowser, as of the last update.
485    pub fn paint(&mut self, window: &Window) {
486        self.rendering_context
487            .parent_context()
488            .prepare_for_rendering();
489        self.context.paint(window);
490        self.rendering_context.parent_context().present();
491    }
492
493    /// Updates the location field from the given [WebViewManager], unless the user has started
494    /// editing it without clicking Go, returning true iff it has changed (needing an egui update).
495    pub fn update_location_in_toolbar(&mut self, state: &RunningAppState) -> bool {
496        // User edited without clicking Go?
497        if self.location_dirty {
498            return false;
499        }
500
501        let current_url_string = state
502            .focused_webview()
503            .and_then(|webview| Some(webview.url()?.to_string()));
504        match current_url_string {
505            Some(location) if location != self.location => {
506                self.location = location.to_owned();
507                true
508            },
509            _ => false,
510        }
511    }
512
513    pub fn update_location_dirty(&mut self, dirty: bool) {
514        self.location_dirty = dirty;
515    }
516
517    pub fn update_load_status(&mut self, state: &RunningAppState) -> bool {
518        let state_status = state
519            .focused_webview()
520            .map(|webview| webview.load_status())
521            .unwrap_or(LoadStatus::Complete);
522        let old_status = std::mem::replace(&mut self.load_status, state_status);
523        old_status != self.load_status
524    }
525
526    pub fn update_status_text(&mut self, state: &RunningAppState) -> bool {
527        let state_status = state
528            .focused_webview()
529            .and_then(|webview| webview.status_text());
530        let old_status = std::mem::replace(&mut self.status_text, state_status);
531        old_status != self.status_text
532    }
533
534    fn update_title(
535        &mut self,
536        state: &RunningAppState,
537        window: Rc<dyn WindowPortsMethods>,
538    ) -> bool {
539        if let Some(webview) = state.focused_webview() {
540            let title = webview
541                .page_title()
542                .filter(|title| !title.is_empty())
543                .map(|title| title.to_string())
544                .or_else(|| webview.url().map(|url| url.to_string()))
545                .unwrap_or_else(|| INITIAL_WINDOW_TITLE.to_string());
546
547            window.set_title_if_changed(&title)
548        } else {
549            false
550        }
551    }
552
553    /// Updates all fields taken from the given [WebViewManager], such as the location field.
554    /// Returns true iff the egui needs an update.
555    pub fn update_webview_data(
556        &mut self,
557        state: &RunningAppState,
558        window: Rc<dyn WindowPortsMethods>,
559    ) -> bool {
560        // Note: We must use the "bitwise OR" (|) operator here instead of "logical OR" (||)
561        //       because logical OR would short-circuit if any of the functions return true.
562        //       We want to ensure that all functions are called. The "bitwise OR" operator
563        //       does not short-circuit.
564        self.update_location_in_toolbar(state) |
565            self.update_load_status(state) |
566            self.update_status_text(state) |
567            self.update_title(state, window)
568    }
569
570    /// Returns true if a redraw is required after handling the provided event.
571    pub(crate) fn handle_accesskit_event(
572        &mut self,
573        event: &egui_winit::accesskit_winit::WindowEvent,
574    ) -> bool {
575        match event {
576            egui_winit::accesskit_winit::WindowEvent::InitialTreeRequested => {
577                self.context.egui_ctx.enable_accesskit();
578                true
579            },
580            egui_winit::accesskit_winit::WindowEvent::ActionRequested(req) => {
581                self.context
582                    .egui_winit
583                    .on_accesskit_action_request(req.clone());
584                true
585            },
586            egui_winit::accesskit_winit::WindowEvent::AccessibilityDeactivated => {
587                self.context.egui_ctx.disable_accesskit();
588                false
589            },
590        }
591    }
592
593    pub(crate) fn set_zoom_factor(&self, factor: f32) {
594        self.context.egui_ctx.set_zoom_factor(factor);
595    }
596}
597
598fn embedder_image_to_egui_image(image: &Image) -> egui::ColorImage {
599    let width = image.width as usize;
600    let height = image.height as usize;
601
602    match image.format {
603        PixelFormat::K8 => egui::ColorImage::from_gray([width, height], image.data()),
604        PixelFormat::KA8 => {
605            // Convert to rgba
606            let data: Vec<u8> = image
607                .data()
608                .chunks_exact(2)
609                .flat_map(|pixel| [pixel[0], pixel[0], pixel[0], pixel[1]])
610                .collect();
611            egui::ColorImage::from_rgba_unmultiplied([width, height], &data)
612        },
613        PixelFormat::RGB8 => egui::ColorImage::from_rgb([width, height], image.data()),
614        PixelFormat::RGBA8 => {
615            egui::ColorImage::from_rgba_unmultiplied([width, height], image.data())
616        },
617        PixelFormat::BGRA8 => {
618            // Convert from BGRA to RGBA
619            let data: Vec<u8> = image
620                .data()
621                .chunks_exact(4)
622                .flat_map(|chunk| [chunk[2], chunk[1], chunk[0], chunk[3]])
623                .collect();
624            egui::ColorImage::from_rgba_unmultiplied([width, height], &data)
625        },
626    }
627}
628
629/// Uploads all favicons that have not yet been processed to the GPU.
630fn load_pending_favicons(
631    ctx: &egui::Context,
632    state: &RunningAppState,
633    texture_cache: &mut HashMap<WebViewId, (egui::TextureHandle, egui::load::SizedTexture)>,
634) {
635    for id in state.take_pending_favicon_loads() {
636        let Some(webview) = state.webview_by_id(id) else {
637            continue;
638        };
639        let Some(favicon) = webview.favicon() else {
640            continue;
641        };
642
643        let egui_image = embedder_image_to_egui_image(&favicon);
644        let handle = ctx.load_texture(format!("favicon-{id:?}"), egui_image, Default::default());
645        let texture = egui::load::SizedTexture::new(
646            handle.id(),
647            egui::vec2(favicon.width as f32, favicon.height as f32),
648        );
649
650        // We don't need the handle anymore but we can't drop it either since that would cause
651        // the texture to be freed.
652        texture_cache.insert(id, (handle, texture));
653    }
654}