1use 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 location_dirty: bool,
50
51 load_status: LoadStatus,
52
53 status_text: Option<String>,
54
55 favicon_textures: HashMap<WebViewId, (egui::TextureHandle, egui::load::SizedTexture)>,
59
60 experimental_prefs_enabled: bool,
62}
63
64pub enum MinibrowserEvent {
65 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 options.zoom_with_keyboard = false;
118
119 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 fn is_in_egui_toolbar_rect(&self, position: Point2D<f32, DeviceIndependentPixel>) -> bool {
193 position.y < self.toolbar_height.get()
194 }
195
196 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 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 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 visuals.widgets.active.bg_stroke.width = 0.0;
230 visuals.widgets.hovered.bg_stroke.width = 0.0;
231 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 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 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 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 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 location_field.request_focus();
381 }
382 if location_field.gained_focus() {
384 if let Some(mut state) =
385 TextEditState::load(ui.ctx(), location_id)
386 {
387 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 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 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 *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 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 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 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 pub fn update_location_in_toolbar(&mut self, state: &RunningAppState) -> bool {
496 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 pub fn update_webview_data(
556 &mut self,
557 state: &RunningAppState,
558 window: Rc<dyn WindowPortsMethods>,
559 ) -> bool {
560 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 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 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 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
629fn 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 texture_cache.insert(id, (handle, texture));
653 }
654}