1use 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 location_dirty: Cell<bool>,
49
50 load_status: LoadStatus,
51
52 status_text: Option<String>,
53
54 favicon_textures: HashMap<WebViewId, (egui::TextureHandle, egui::load::SizedTexture)>,
58}
59
60pub enum MinibrowserEvent {
61 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 #[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 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 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 fn is_in_egui_toolbar_rect(&self, position: Point2D<f32, DeviceIndependentPixel>) -> bool {
182 position.y < self.toolbar_height.get()
183 }
184
185 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 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 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 visuals.widgets.active.bg_stroke.width = 0.0;
219 visuals.widgets.hovered.bg_stroke.width = 0.0;
220 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 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 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 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 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 location_field.request_focus();
360 }
361 if location_field.gained_focus() {
363 if let Some(mut state) =
364 TextEditState::load(ui.ctx(), location_id)
365 {
366 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 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 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 *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 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 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 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 pub fn update_location_in_toolbar(&mut self, state: &RunningAppState) -> bool {
488 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 pub fn update_webview_data(&mut self, state: &RunningAppState) -> bool {
529 self.update_location_in_toolbar(state) |
534 self.update_load_status(state) |
535 self.update_status_text(state)
536 }
537
538 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 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 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
590fn 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 texture_cache.insert(id, (handle, texture));
614 }
615}