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